Skip to content

Commit 2a4eadf

Browse files
committed
feat: SSHID — passkey-backed SSH public key resolver
Open-source reimagining of the SSH ID concept. - Fastify + TypeScript API serving a content-negotiated /:handle resolver (authorized_keys for tooling, profile page for browsers) - Passkey (WebAuthn) registration and login; sessions via signed cookies - SSH public-key parsing, validation and SHA256 fingerprinting - SQLite (WAL) storage with prepared statements and graceful shutdown - React + Vite + Tailwind UI: landing, dashboard, public profile - Multi-stage Docker image, docker-compose, and GitHub Actions CI/CD (GHCR build + Portainer redeploy)
0 parents  commit 2a4eadf

61 files changed

Lines changed: 9083 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.dockerignore

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
**/node_modules
2+
**/dist
3+
**/build
4+
**/*.tsbuildinfo
5+
.git
6+
.github
7+
.gitignore
8+
.env
9+
.env.*
10+
!.env.example
11+
data
12+
*.sqlite*
13+
*.db
14+
*.log
15+
coverage
16+
.vscode
17+
.idea
18+
README.md
19+
docs

.env.example

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# ─────────────────────────────────────────────────────────────
2+
# SSHID — environment configuration
3+
# Copy to `.env` and adjust. All values shown are safe defaults
4+
# for local development.
5+
# ─────────────────────────────────────────────────────────────
6+
7+
# HTTP server
8+
HOST=0.0.0.0
9+
PORT=8080
10+
11+
# Public origin the app is served from (no trailing slash).
12+
# Used to build authorized_keys URLs and to validate WebAuthn origins.
13+
PUBLIC_ORIGIN=http://localhost:8080
14+
15+
# WebAuthn relying party.
16+
# RP_ID MUST be the registrable domain (no scheme, no port), e.g. "sshid.koorosh.me".
17+
# For local development use "localhost".
18+
RP_ID=localhost
19+
RP_NAME=SSHID
20+
21+
# Secret used to sign session and challenge cookies.
22+
# Generate with: openssl rand -hex 32
23+
SESSION_SECRET=change-me-to-a-long-random-string-at-least-32-chars
24+
25+
# SQLite database location (directory is created automatically).
26+
DATABASE_PATH=./data/sshid.sqlite
27+
28+
# Logging: "debug" | "info" | "warn" | "error"
29+
LOG_LEVEL=info
30+
31+
# Set to "production" in deployment.
32+
NODE_ENV=development

.github/workflows/ci.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
verify:
11+
name: Type-check & build
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- uses: actions/setup-node@v4
17+
with:
18+
node-version: 24
19+
cache: npm
20+
21+
- name: Install dependencies
22+
run: npm ci
23+
24+
- name: Type-check
25+
run: npm run typecheck
26+
27+
- name: Build
28+
run: npm run build

.github/workflows/deploy.yml

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
name: Build & Deploy
2+
3+
on:
4+
push:
5+
branches: [main]
6+
tags: ['v*']
7+
workflow_dispatch:
8+
9+
concurrency:
10+
group: deploy-${{ github.ref }}
11+
cancel-in-progress: true
12+
13+
env:
14+
IMAGE: ghcr.io/${{ github.repository }}
15+
16+
jobs:
17+
build-and-push:
18+
name: Build & push image
19+
runs-on: ubuntu-latest
20+
permissions:
21+
contents: read
22+
packages: write
23+
outputs:
24+
digest: ${{ steps.build.outputs.digest }}
25+
steps:
26+
- uses: actions/checkout@v4
27+
28+
- name: Set up Docker Buildx
29+
uses: docker/setup-buildx-action@v3
30+
31+
- name: Log in to GHCR
32+
uses: docker/login-action@v3
33+
with:
34+
registry: ghcr.io
35+
username: ${{ github.actor }}
36+
password: ${{ secrets.GITHUB_TOKEN }}
37+
38+
- name: Extract image metadata
39+
id: meta
40+
uses: docker/metadata-action@v5
41+
with:
42+
images: ${{ env.IMAGE }}
43+
tags: |
44+
type=raw,value=latest,enable={{is_default_branch}}
45+
type=sha,format=short
46+
type=semver,pattern={{version}}
47+
48+
- name: Build and push
49+
id: build
50+
uses: docker/build-push-action@v6
51+
with:
52+
context: .
53+
push: true
54+
tags: ${{ steps.meta.outputs.tags }}
55+
labels: ${{ steps.meta.outputs.labels }}
56+
cache-from: type=gha
57+
cache-to: type=gha,mode=max
58+
59+
deploy:
60+
name: Redeploy on Portainer
61+
needs: build-and-push
62+
if: github.ref == 'refs/heads/main'
63+
runs-on: ubuntu-latest
64+
steps:
65+
- name: Trigger Portainer stack redeploy (pull latest image)
66+
env:
67+
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
68+
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
69+
STACK_ID: ${{ secrets.PORTAINER_STACK_ID }}
70+
ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
71+
run: |
72+
set -euo pipefail
73+
74+
# Re-deploy the existing stack with its current file + env, forcing an
75+
# image pull so the freshly pushed tag is rolled out. Reusing the
76+
# stored content means no secrets need to be re-supplied here.
77+
content=$(curl -fsS -H "X-API-Key: $PORTAINER_API_KEY" \
78+
"$PORTAINER_URL/api/stacks/$STACK_ID/file" | jq -r '.StackFileContent')
79+
env_json=$(curl -fsS -H "X-API-Key: $PORTAINER_API_KEY" \
80+
"$PORTAINER_URL/api/stacks/$STACK_ID" | jq -c '.Env // []')
81+
82+
jq -n --arg content "$content" --argjson env "$env_json" \
83+
'{StackFileContent: $content, Env: $env, Prune: true, PullImage: true}' \
84+
> payload.json
85+
86+
curl -fsS -X PUT \
87+
-H "X-API-Key: $PORTAINER_API_KEY" \
88+
-H "Content-Type: application/json" \
89+
"$PORTAINER_URL/api/stacks/$STACK_ID?endpointId=$ENDPOINT_ID" \
90+
--data @payload.json > /dev/null
91+
92+
echo "Portainer stack $STACK_ID redeployed."

.gitignore

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Dependencies
2+
node_modules/
3+
.pnp
4+
.pnp.js
5+
6+
# Build output
7+
dist/
8+
build/
9+
*.tsbuildinfo
10+
11+
# Environment
12+
.env
13+
.env.*
14+
!.env.example
15+
16+
# Data / runtime
17+
data/
18+
*.sqlite
19+
*.sqlite-shm
20+
*.sqlite-wal
21+
*.db
22+
23+
# Logs
24+
logs/
25+
*.log
26+
npm-debug.log*
27+
28+
# Editor / OS
29+
.vscode/
30+
.idea/
31+
.DS_Store
32+
Thumbs.db
33+
34+
# Test / coverage
35+
coverage/
36+
.nyc_output/

Dockerfile

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# syntax=docker/dockerfile:1
2+
3+
# ─── Build stage ──────────────────────────────────────────────────────────────
4+
# Compiles the web bundle and the server, then prunes dev dependencies so the
5+
# resulting node_modules (including the compiled better-sqlite3 binary) can be
6+
# copied verbatim into the slim runtime image.
7+
FROM node:24-bookworm-slim AS build
8+
WORKDIR /app
9+
10+
# Toolchain for any native module that lacks a prebuilt binary (better-sqlite3).
11+
RUN apt-get update \
12+
&& apt-get install -y --no-install-recommends python3 make g++ \
13+
&& rm -rf /var/lib/apt/lists/*
14+
15+
# Install dependencies first for better layer caching.
16+
COPY package.json package-lock.json ./
17+
COPY apps/server/package.json ./apps/server/package.json
18+
COPY apps/web/package.json ./apps/web/package.json
19+
RUN npm ci
20+
21+
# Build both workspaces.
22+
COPY tsconfig.base.json ./
23+
COPY apps ./apps
24+
RUN npm run build
25+
26+
# Drop dev dependencies; keep compiled native binaries.
27+
RUN npm prune --omit=dev
28+
29+
# ─── Runtime stage ────────────────────────────────────────────────────────────
30+
FROM node:24-bookworm-slim AS runtime
31+
WORKDIR /app
32+
33+
ENV NODE_ENV=production \
34+
HOST=0.0.0.0 \
35+
PORT=8080 \
36+
DATABASE_PATH=/app/data/sshid.sqlite \
37+
WEB_ROOT=/app/apps/web/dist
38+
39+
# Pruned production dependencies and compiled output.
40+
COPY --from=build /app/node_modules ./node_modules
41+
COPY --from=build /app/apps/server/package.json ./apps/server/package.json
42+
COPY --from=build /app/apps/server/dist ./apps/server/dist
43+
COPY --from=build /app/apps/web/dist ./apps/web/dist
44+
45+
# Persisted SQLite data, owned by the unprivileged runtime user.
46+
RUN mkdir -p /app/data && chown -R node:node /app/data
47+
USER node
48+
VOLUME ["/app/data"]
49+
EXPOSE 8080
50+
51+
# Liveness probe using Node's built-in fetch (no extra packages required).
52+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
53+
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
54+
55+
CMD ["node", "apps/server/dist/index.js"]

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 SSHID contributors
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

0 commit comments

Comments
 (0)