refactor(desloppify): SSO login-page route, CLAUDE.md rewrite, gitignore cleanup
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

- sso-gate.js: add GET /api/v1/auth/login-page?service= route; auto-login
  HTML for chat/plex/jellyfin/emby now served from code instead of inline
  Caddyfile respond blobs. Fix merge() try-block syntax error (was missing
  closing } before catch, breaking Jellyfin/Emby localStorage merge).
- middleware.js: add /api/v1/auth/login-page to PUBLIC_ROUTES.
- CLAUDE.md: complete rewrite — was describing the old Windows-local
  C:/caddy/ layout; now accurately describes DNS2 production (paths,
  container, caddy-apply workflow, SSO architecture, common mistakes).
- .gitignore: cover runtime JSON/log/cert files that were sitting untracked
  in dev root (audit-log, backup-history, credentials, health-history, etc.),
  plus generated-certs/, pki/, assets/.
- Remove tracked dev-root noise: comprehensive-test.js, license-keygen.js,
  test-security-fixes.js (scripts that don't belong at repo root).
- Remove stale routes/openclaw.js (leftover from old monolithic structure).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sami
2026-06-28 03:48:11 -07:00
co-authored by Claude Sonnet 4.6
parent 5f6c25d2e3
commit a2e6566958
5 changed files with 196 additions and 486 deletions
+106 -173
View File
@@ -10,217 +10,149 @@
- When deploying new containers, always use `E:/dockerdata/<app-name>/` for bind mount paths - When deploying new containers, always use `E:/dockerdata/<app-name>/` for bind mount paths
- For CIFS volumes in docker-compose, use `//Sami-pc/e_share/dockerdata/...` as the device path - For CIFS volumes in docker-compose, use `//Sami-pc/e_share/dockerdata/...` as the device path
## CRITICAL: Production vs Development Paths ## CRITICAL: Production is on DNS2 (not this machine)
DashCaddy runs on **DNS2** (`100.121.150.22` via Tailscale / `194.233.88.206` public).
SSH in with: `ssh root@100.121.150.22`
### Production Layout on DNS2
### Production Files (LIVE - what actually runs)
``` ```
C:/caddy/ /opt/dashcaddy/ # git repo (auto-updated)
├── Caddyfile # Active Caddy configuration ├── dashcaddy-api/
├── services.json # Services shown on dashboard │ ├── *.js # API server source
├── dns-credentials.json # DNS API credentials │ └── data/
├── config.json # DashCaddy configuration │ ├── services.json # LIVE services list
└── sites/ │ ├── config.json # LIVE DashCaddy config
── status/ # Dashboard frontend files ── dns-credentials.json # DNS API credentials
└── assets/ # Logos, fonts, icons └── credentials.json # Encrypted app credentials
├── status/ # Dashboard frontend (built)
│ ├── index.html
│ ├── dist/ # Bundled JS (core/features/onboarding/init)
│ ├── js/ # Source JS (also served statically)
│ ├── css/
│ └── assets/
├── ca/ # DashCA static site
├── updates/ # Auto-updater staging + history
└── start.sh # Container launch script (run by @reboot cron)
``` ```
### Development Files (for editing/testing) ### Docker Container
- **Name**: `dashcaddy-api`
- **Image**: `dashcaddy-dashcaddy-api:latest`
- **Port**: `127.0.0.1:3001` (Caddy proxies to it)
- **Started by**: `/opt/dashcaddy/start.sh` via root `@reboot` cron
Key container mounts:
| Container path | Host path |
|---|---|
| `/app/data/` | `/opt/dashcaddy/dashcaddy-api/data/` |
| `/app/assets` | `/opt/dashcaddy/status/assets` |
| `/caddyfile` | `/etc/caddy/Caddyfile` |
| `/app/backups` | `/opt/dashcaddy/backups` |
### Caddy
- **Config**: `/etc/caddy/Caddyfile` (git-guarded — edit then run `caddy-apply`)
- **Admin API**: `http://localhost:2019` (NOT 2021)
- **TLS storage**: `/var/lib/caddy/`
- **Static files**: Caddy serves `/opt/dashcaddy/status/` for `status.sami`
### Development Files (for editing)
``` ```
e:/CaddyCerts/sites/ e:/CaddyCerts/sites/
├── caddy-api/ ├── dashcaddy-api/ # API server source (NOT caddy-api/)
│ ├── server.js # API server source code │ ├── server.js
│ ├── app-templates.js # Docker app templates (52+ apps) │ ├── src/app.js # Express app factory
│ ├── services.json # DEV ONLY - not used in production! │ ├── routes/ # Route handlers
│ ├── middleware.js
│ └── ... │ └── ...
└── status/ └── status/ # Dashboard frontend source
── index.html # Dashboard UI source ── index.html # HTML template (~853 lines)
├── js/ # Source JS modules
├── css/
├── dist/ # Built output (run node build.js)
└── build.js # Build script (uses esbuild)
``` ```
## Docker Container Mount Points
The `caddy-api` container mounts production files:
| Container Path | Host Path (Production) |
|----------------|------------------------|
| `/app/services.json` | `C:/caddy/services.json` |
| `/app/dns-credentials.json` | `C:/caddy/dns-credentials.json` |
| `/caddyfile` | `C:/caddy/Caddyfile` |
| `/app/assets` | `C:/caddy/sites/status/assets` |
## When Making Changes ## When Making Changes
### To add/remove services from dashboard: ### To add/remove services from dashboard:
Edit `C:/caddy/services.json` (NOT e:/CaddyCerts/sites/caddy-api/services.json) Edit `/opt/dashcaddy/dashcaddy-api/data/services.json` on DNS2 directly,
OR use the dashboard UI at `https://status.sami`.
### To modify Caddy reverse proxy rules: ### To modify Caddy reverse proxy rules:
Edit `C:/caddy/Caddyfile`, then reload via:
```bash ```bash
curl -X POST http://localhost:2019/load -H "Content-Type: text/caddyfile" --data-binary @"C:/caddy/Caddyfile" ssh root@100.121.150.22
# Edit /etc/caddy/Caddyfile
caddy-apply "reason for change" # validates + reloads + git commits
``` ```
### To modify API server code: ### To modify API server code:
Edit `e:/CaddyCerts/sites/caddy-api/server.js`, then: 1. Edit `e:/CaddyCerts/sites/dashcaddy-api/` locally
1. Copy to production: `C:/caddy/sites/caddy-api/` 2. `scp` changed files to `root@100.121.150.22:/opt/dashcaddy/dashcaddy-api/`
2. Restart container: `docker restart caddy-api` 3. Rebuild container: `ssh root@100.121.150.22 "bash /opt/dashcaddy/start.sh"`
### To modify app templates: ### To modify dashboard frontend:
Edit `e:/CaddyCerts/sites/caddy-api/app-templates.js` 1. Edit source in `e:/CaddyCerts/sites/status/js/` or `status/index.html`
(Templates are loaded at runtime, changes require container restart) 2. Build: `cd e:/CaddyCerts/sites/status && node build.js`
3. Deploy: `scp -r dist/ index.html sw.js root@100.121.150.22:/opt/dashcaddy/status/`
### To modify dashboard UI: ### To modify DashCA:
Edit `e:/CaddyCerts/sites/status/index.html`
Copy to `C:/caddy/sites/status/` for production
### To modify DashCA (CA certificate distribution):
Edit files in `e:/CaddyCerts/sites/ca/`, then: Edit files in `e:/CaddyCerts/sites/ca/`, then:
1. Regenerate certificate formats: `cd e:/CaddyCerts/sites/ca/scripts && bash generate-all.sh` 1. Regenerate: `cd e:/CaddyCerts/sites/ca/scripts && bash generate-all.sh`
2. Copy to production: `cp -r e:/CaddyCerts/sites/ca/* C:/caddy/sites/ca/` 2. Deploy: `scp -r e:/CaddyCerts/sites/ca/* root@100.121.150.22:/opt/dashcaddy/ca/`
3. Reload Caddy if Caddyfile changes were made
## DashCA - Certificate Authority Distribution ## DashCA - Certificate Authority Distribution
**Purpose**: Provides a one-click installation page for the root CA certificate, allowing users to easily trust *.sami domains on any device. **Purpose**: One-click CA cert install page so *.sami domains are trusted on all devices.
**Access**: `https://ca.sami`
**Access**: https://ca.sami (or https://ca.yourdomain for other installations)
### File Locations
**Development (for editing):**
```
e:/CaddyCerts/sites/ca/
├── index.html # Landing page
├── root.crt, root.der # Certificate formats
├── root.mobileconfig # Apple profile
├── intermediate.crt # Intermediate CA
├── cert-info.json # Certificate metadata
├── scripts/
│ ├── install.ps1 # Windows installer
│ ├── install.sh # Linux/macOS installer
│ ├── generate-cert-info.js # Extract cert metadata
│ ├── generate-mobileconfig.js # Generate Apple profile
│ └── generate-all.sh # Regenerate all formats
└── assets/ # Icons, logos
```
**Production (served by Caddy):**
```
C:/caddy/sites/ca/
├── index.html
├── root.crt, root.der
├── root.mobileconfig
├── install.ps1, install.sh
└── assets/
```
### Certificate Source
Caddy's built-in PKI generates certificates at:
- **Root CA**: `C:/caddy/certs/pki/authorities/local/root.crt`
- **Intermediate CA**: `C:/caddy/certs/pki/authorities/local/intermediate.crt`
**Certificate Info:** **Certificate Info:**
- **CN**: Sami Home Network Root CA - **CN**: Sami Home Network Root CA
- **Algorithm**: ECDSA P-256 with SHA-256 - **Algorithm**: ECDSA P-256 with SHA-256
- **Valid Until**: Dec 22, 2034 (~10 years) - **Valid Until**: Dec 22, 2034
- **Fingerprint**: `08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:21:29:0E` - **Fingerprint**: `08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:21:29:0E`
### Deployment **Certificate Source** (on DNS2):
- Root CA: `/etc/ssl/sami-ca/root.crt`
DashCA is a **static site** (not Docker-based), deployed via the app selector: - Intermediate CA: auto-generated by Caddy at `/var/lib/caddy/pki/authorities/local/`
1. Navigate to App Selector in dashboard
2. Find "DashCA" in Security category
3. Click Deploy
4. System automatically:
- Creates `C:/caddy/sites/ca/` directory
- Copies files from development directory
- Generates certificate formats (DER, mobileconfig)
- Adds ca.sami block to Caddyfile
- Reloads Caddy configuration
- Registers service in `services.json`
### Updating Certificates
When Caddy's CA certificate is renewed (every ~10 years):
```bash
# 1. Regenerate all certificate formats
cd e:/CaddyCerts/sites/ca/scripts
bash generate-all.sh
# 2. Update fingerprint in installation scripts
# Edit install.ps1 - update $ExpectedFingerprint
# Edit install.sh - update EXPECTED_FP
# 3. Copy to production
cp -r e:/CaddyCerts/sites/ca/* C:/caddy/sites/ca/
# 4. Notify users via dashboard or email
```
### API Endpoints ### API Endpoints
- `GET /api/ca/info` — certificate metadata
- **GET /api/ca/info** - Returns certificate metadata (name, fingerprint, expiration, etc.) - `GET /api/health/ca` — CA expiration health (`healthy` / `warning` / `critical`)
- **GET /api/health/ca** - Returns CA expiration health status
- `healthy`: >90 days remaining
- `warning`: 30-90 days remaining
- `critical`: <30 days remaining
### Caddyfile Configuration
DashCA's Caddyfile block (auto-generated on deployment):
- **Root**: `C:/caddy/sites/ca`
- **TLS**: Internal (uses Caddy's local CA)
- **MIME Types**: Proper headers for .crt, .der, .mobileconfig, .ps1, .sh files
- **SPA Fallback**: Rewrites non-file requests to /index.html
- **Cache Control**: Certificates cached for 24h, HTML not cached
### Supported Platforms
- **Windows**: PowerShell installer (installs to LocalMachine\Root store)
- **macOS**: .mobileconfig profile or command-line installer
- **Linux**: Shell installer (Debian, RedHat, Arch)
- **iOS**: .mobileconfig profile (requires manual trust in Settings)
- **Android**: Direct .crt download (installs as user certificate)
### Landing Page Features
- Automatic OS detection
- QR code for mobile access
- Certificate info display (loaded from `/api/ca/info`)
- Platform-specific installation instructions
- Copy-to-clipboard for fingerprint and commands
- Download links for all certificate formats
### Troubleshooting
**Issue**: Certificate fingerprint mismatch during installation
**Cause**: CA certificate was renewed
**Solution**: Regenerate certificates and update fingerprints in install scripts
**Issue**: *.sami sites still show warnings after CA install
**Cause**: Browser may have cached the untrusted state
**Solution**: Clear browser cache, restart browser, or visit site in incognito mode
**Issue**: iOS doesn't trust certificate after profile install
**Cause**: iOS requires manual trust enablement
**Solution**: Settings → General → About → Certificate Trust Settings → Enable trust
## Key Services ## Key Services
| Service | Port | Description | | Service | Where | Port | Notes |
|---------|------|-------------| |---------|-------|------|-------|
| Caddy (HTTPS) | 443 | Reverse proxy | | Caddy (HTTPS) | DNS2 | 443 | Reverse proxy |
| Caddy Admin | 2019 | Caddy API (note: NOT 2021) | | Caddy Admin | DNS2 | 2019 | Caddy API |
| DashCaddy API | 3001 | Dashboard backend | | DashCaddy API | DNS2 | 3001 | Dashboard backend (container) |
| DNS2 (Primary) | 100.74.102.61:5380 | Technitium DNS | | Technitium DNS (primary) | DNS2 | 5380 | `100.121.150.22` |
| DNS1 (Secondary) | 192.168.254.204:5380 | Technitium DNS | | Technitium DNS (secondary) | DNS1 (this PC) | 5380 | `100.71.97.12` |
## SSO Architecture
`import dashcaddy_auth <serviceId>` in the Caddyfile expands to a `forward_auth` gate that:
1. Checks the DashCaddy TOTP session (cookie domain `.sami` — shared across all `*.sami`)
2. Injects credentials (API key, Basic Auth, app cookies) into upstream request headers
For client-side auto-login (chat, Plex, Jellyfin, Emby):
- Caddy redirects `path /` to `/dashcaddy-login`
- `/dashcaddy-login` proxies to `GET /api/v1/auth/login-page?service=<id>` on the API
- That page's JS fetches `/dashcaddy-api/api/auth/app-token/<id>` and stores the token in `localStorage`
## Common Mistakes to Avoid ## Common Mistakes to Avoid
1. **Wrong services.json**: The API container reads from `C:/caddy/services.json`, not the development copy 1. **Wrong API source dir**: It's `dashcaddy-api/`, NOT `caddy-api/` (old name, no longer exists)
2. **Caddy admin port**: It's 2019, not 2021 (check with `netstat` if unsure) 2. **Wrong services file**: Edit the one in `/opt/dashcaddy/dashcaddy-api/data/` on DNS2, not the dev copy
3. **DNS server**: DNS2 (100.74.102.61) is PRIMARY, DNS1 is secondary 3. **Caddyfile edits without caddy-apply**: Always use `caddy-apply` — it validates, reloads, and git-commits
4. **Caddyfile not reloaded**: After editing, must POST to /load endpoint or restart Caddy 4. **Caddy admin port**: It's 2019, not 2021
5. **Frontend changes without build**: Edit JS source, then `node build.js`, then deploy `dist/`
6. **DNS2 Tailscale IP**: `100.121.150.22` (NOT the old `100.104.4.5` or `100.74.102.61`)
--- ---
@@ -316,3 +248,4 @@ vi /opt/dashcaddy/services.json # live-reloaded by the watcher
- **Purpose**: Unified management for Docker + Caddy + DNS - **Purpose**: Unified management for Docker + Caddy + DNS
- **Local TLD (Windows)**: `.sami` - **Local TLD (Windows)**: `.sami`
- **Local TLD (Linux, DNS2)**: `.home` (default; configurable via `siteConfig.tld`) - **Local TLD (Linux, DNS2)**: `.home` (default; configurable via `siteConfig.tld`)
- **Repo**: `/opt/dashcaddy/` on DNS2 (git, auto-updated by self-updater)
+21
View File
@@ -14,3 +14,24 @@ error.log
# Test artifacts # Test artifacts
coverage/ coverage/
audit-routes.js audit-routes.js
comprehensive-test.js
test-security-fixes.js
license-keygen.js
# Runtime-generated data files (written by the running server, not source)
alert-config.json
audit-log.json
audit-log.json.lock
backup-config.json
backup-history.json
container-stats.json
credentials.json
health-config.json
health-history.json
update-config.json
update-history.json
# Runtime certificate/key directories
generated-certs/
pki/
assets/
-313
View File
@@ -1,313 +0,0 @@
#!/usr/bin/env node
/**
* DashCaddy License Code Generator
*
* Admin-only CLI tool for generating license codes.
* NOT shipped with the product — runs only on the developer's machine.
*
* Usage:
* node license-keygen.js --duration 365 --count 10
* node license-keygen.js --duration 30 --count 1 --output codes.txt
* node license-keygen.js --verify DC-XXXXX-XXXXX-XXXXX-XXXXX
* node license-keygen.js --init-secret
*/
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
// Master secret file — lives only on admin machine, NEVER shipped
const SECRET_FILE = path.join(__dirname, '.license-secret');
// License code format: DC-AAAAA-BBBBB-CCCCC-DDDDD
// Encodes: version(4bit) + duration_days(12bit) + code_id(32bit) + created_ts(32bit) + hmac(48bit)
// Total: 128 bits = 16 bytes, base32-encoded into 4 groups of 5 chars
const VALID_DURATIONS = [30, 90, 180, 365];
const LIFETIME_DURATION = 0; // Admin-only, not publicly available
const VERSION = 1;
// Base32 alphabet (Crockford variant — no I/L/O/U to avoid confusion)
const BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
function base32Encode(buffer) {
let bits = '';
for (const byte of buffer) {
bits += byte.toString(2).padStart(8, '0');
}
// Pad to multiple of 5
while (bits.length % 5 !== 0) bits += '0';
let result = '';
for (let i = 0; i < bits.length; i += 5) {
const index = parseInt(bits.substring(i, i + 5), 2);
result += BASE32[index];
}
return result;
}
function base32Decode(str) {
let bits = '';
for (const char of str.toUpperCase()) {
const index = BASE32.indexOf(char);
if (index === -1) throw new Error(`Invalid base32 character: ${char}`);
bits += index.toString(2).padStart(5, '0');
}
const bytes = [];
for (let i = 0; i + 8 <= bits.length; i += 8) {
bytes.push(parseInt(bits.substring(i, i + 8), 2));
}
return Buffer.from(bytes);
}
function getSecret() {
if (!fs.existsSync(SECRET_FILE)) {
console.error('No master secret found. Run with --init-secret first.');
process.exit(1);
}
return fs.readFileSync(SECRET_FILE, 'utf8').trim();
}
function initSecret() {
if (fs.existsSync(SECRET_FILE)) {
console.error('Master secret already exists at', SECRET_FILE);
console.error('Delete it first if you want to regenerate (WARNING: invalidates all existing codes).');
process.exit(1);
}
const secret = crypto.randomBytes(32).toString('hex');
fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 });
console.log('Master secret generated and saved to', SECRET_FILE);
console.log('KEEP THIS FILE SAFE. It is needed to generate and validate all license codes.');
console.log('DO NOT ship this file with the product.');
}
function generateCode(secret, durationDays, codeId) {
// Pack payload: version(4b) + duration_days(12b) + code_id(32b) + created_ts(32b) = 80 bits = 10 bytes
const payload = Buffer.alloc(10);
// Byte 0-1: version (4 bits) + duration (12 bits) = 16 bits
const versionAndDuration = ((VERSION & 0x0F) << 12) | (durationDays & 0x0FFF);
payload.writeUInt16BE(versionAndDuration, 0);
// Byte 2-5: code_id (32 bits)
payload.writeUInt32BE(codeId, 2);
// Byte 6-9: created timestamp (32 bits, seconds since epoch)
const createdTs = Math.floor(Date.now() / 1000);
payload.writeUInt32BE(createdTs, 6);
// HMAC the payload to get signature
const hmac = crypto.createHmac('sha256', secret).update(payload).digest();
// Take first 5 bytes of HMAC (40 bits) — fits exactly in 25 base32 chars with 10-byte payload
const signature = hmac.subarray(0, 5);
// Combine: payload (10 bytes) + signature (5 bytes) = 15 bytes = 120 bits
// 25 base32 chars = 125 bits, comfortably fits 120 bits
const combined = Buffer.concat([payload, signature]);
let encoded = base32Encode(combined);
while (encoded.length < 25) encoded += '0';
encoded = encoded.substring(0, 25);
const groups = [];
for (let i = 0; i < 25; i += 5) {
groups.push(encoded.substring(i, i + 5));
}
return `DC-${groups.join('-')}`;
}
function parseCode(code) {
// Strip prefix and dashes
const cleaned = code.replace(/^DC-/, '').replace(/-/g, '');
if (cleaned.length !== 25) {
throw new Error(`Invalid code length: expected 25 base32 chars, got ${cleaned.length}`);
}
// Decode base32 — 25 chars = 125 bits = 15 full bytes
const decoded = base32Decode(cleaned);
if (decoded.length < 15) {
const padded = Buffer.alloc(15);
decoded.copy(padded);
return parsePayload(padded);
}
return parsePayload(decoded.subarray(0, 15));
}
function parsePayload(buffer) {
const payload = buffer.subarray(0, 10);
const signature = buffer.subarray(10, 15);
const versionAndDuration = payload.readUInt16BE(0);
const version = (versionAndDuration >> 12) & 0x0F;
const durationDays = versionAndDuration & 0x0FFF;
const codeId = payload.readUInt32BE(2);
const createdTs = payload.readUInt32BE(6);
return { version, durationDays, codeId, createdTs, payload, signature };
}
function verifyCode(secret, code) {
try {
const { version, durationDays, codeId, createdTs, payload, signature } = parseCode(code);
// Verify HMAC (5-byte signature)
const expectedHmac = crypto.createHmac('sha256', secret).update(payload).digest();
const expectedSig = expectedHmac.subarray(0, 5);
if (!crypto.timingSafeEqual(signature, expectedSig)) {
return { valid: false, reason: 'Invalid signature — code is forged or corrupted' };
}
if (version !== VERSION) {
return { valid: false, reason: `Unsupported version: ${version}` };
}
// Accept lifetime (0) and standard durations
if (durationDays !== LIFETIME_DURATION && !VALID_DURATIONS.includes(durationDays)) {
return { valid: false, reason: `Invalid duration: ${durationDays} days` };
}
const createdDate = new Date(createdTs * 1000);
const isLifetime = durationDays === LIFETIME_DURATION;
const expiresDate = isLifetime ? null : new Date(createdTs * 1000 + durationDays * 86400000);
return {
valid: true,
version,
durationDays,
codeId,
createdAt: createdDate.toISOString(),
expiresAt: isLifetime ? null : expiresDate.toISOString(),
expired: isLifetime ? false : Date.now() > expiresDate.getTime()
};
} catch (error) {
return { valid: false, reason: error.message };
}
}
// CLI
function main() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.length === 0) {
console.log(`
DashCaddy License Code Generator
Usage:
node license-keygen.js --init-secret Initialize master secret (first time only)
node license-keygen.js --duration <days> [options] Generate license codes
node license-keygen.js --verify <code> Verify a license code
node license-keygen.js --decode <code> Decode and display code details
Options:
--duration <days> Code validity: 30, 90, 180, or 365 days (required for generation)
--count <n> Number of codes to generate (default: 1)
--start-id <n> Starting code ID (default: auto from counter file)
--output <file> Write codes to file instead of stdout
--json Output as JSON
Valid durations: ${VALID_DURATIONS.join(', ')} days
`);
process.exit(0);
}
if (args.includes('--init-secret')) {
initSecret();
return;
}
if (args.includes('--verify') || args.includes('--decode')) {
const codeIndex = args.indexOf('--verify') !== -1 ? args.indexOf('--verify') : args.indexOf('--decode');
const code = args[codeIndex + 1];
if (!code) {
console.error('Please provide a code to verify.');
process.exit(1);
}
const secret = getSecret();
const result = verifyCode(secret, code);
if (args.includes('--json')) {
console.log(JSON.stringify(result, null, 2));
} else if (result.valid) {
const isLifetime = result.durationDays === 0;
console.log('Code is VALID');
console.log(` Version: ${result.version}`);
console.log(` Duration: ${isLifetime ? 'LIFETIME' : result.durationDays + ' days'}`);
console.log(` Code ID: ${result.codeId}`);
console.log(` Created: ${result.createdAt}`);
console.log(` Expires: ${isLifetime ? 'NEVER' : result.expiresAt}`);
console.log(` Status: ${isLifetime ? 'LIFETIME' : (result.expired ? 'EXPIRED' : 'ACTIVE')}`);
} else {
console.log('Code is INVALID');
console.log(` Reason: ${result.reason}`);
}
return;
}
// Generate codes
const isLifetime = args.includes('--lifetime');
const durationIndex = args.indexOf('--duration');
if (!isLifetime && durationIndex === -1) {
console.error('--duration is required. Use --help for usage.');
process.exit(1);
}
const duration = isLifetime ? LIFETIME_DURATION : parseInt(args[durationIndex + 1]);
if (!isLifetime && !VALID_DURATIONS.includes(duration)) {
console.error(`Invalid duration: ${duration}. Valid: ${VALID_DURATIONS.join(', ')}`);
process.exit(1);
}
const countIndex = args.indexOf('--count');
const count = countIndex !== -1 ? parseInt(args[countIndex + 1]) : 1;
// Load or create counter file for auto-incrementing code IDs
const counterFile = path.join(__dirname, '.license-counter');
let startId;
const startIdIndex = args.indexOf('--start-id');
if (startIdIndex !== -1) {
startId = parseInt(args[startIdIndex + 1]);
} else if (fs.existsSync(counterFile)) {
startId = parseInt(fs.readFileSync(counterFile, 'utf8').trim()) + 1;
} else {
startId = 1;
}
const secret = getSecret();
const codes = [];
for (let i = 0; i < count; i++) {
const codeId = startId + i;
const code = generateCode(secret, duration, codeId);
codes.push({ code, codeId, durationDays: duration });
}
// Save counter
fs.writeFileSync(counterFile, String(startId + count - 1));
// Output
const outputIndex = args.indexOf('--output');
if (args.includes('--json')) {
const output = JSON.stringify(codes, null, 2);
if (outputIndex !== -1) {
fs.writeFileSync(args[outputIndex + 1], output);
console.log(`${count} code(s) written to ${args[outputIndex + 1]}`);
} else {
console.log(output);
}
} else {
const lines = codes.map(c => `${c.code} (${c.durationDays === 0 ? 'LIFETIME' : c.durationDays + ' days'}, ID: ${c.codeId})`);
if (outputIndex !== -1) {
fs.writeFileSync(args[outputIndex + 1], codes.map(c => c.code).join('\n') + '\n');
console.log(`${count} code(s) written to ${args[outputIndex + 1]}`);
} else {
lines.forEach(l => console.log(l));
}
}
console.log(`\nGenerated ${count} code(s) for ${duration === 0 ? 'LIFETIME' : duration + ' days'}. Next ID: ${startId + count}`);
}
// Also export for use by license-manager.js
module.exports = { verifyCode, parseCode, VALID_DURATIONS, VERSION };
if (require.main === module) {
main();
}
+68
View File
@@ -196,5 +196,73 @@ module.exports = function(deps) {
} }
}, 'auth-app-token')); }, 'auth-app-token'));
// Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream)
router.get('/auth/login-page', (req, res) => {
const service = (req.query.service || '').replace(/[^a-z]/g, '');
const html = buildLoginPage(service);
if (!html) return res.status(404).send('Unknown service');
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.send(html);
});
return router; return router;
}; };
function buildLoginPage(service) {
const SHELL = (body) => `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>__TITLE__</title>
<style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-space:pre-wrap}</style>
</head><body><p id="m">__TITLE__</p><div id="d"></div>
<script>(function(){
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m');
function go(u){setTimeout(function(){location.replace(u)},300)}
function fail(msg,info){m.innerHTML=msg;d.textContent=info||''}
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include'})}
function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))}
${body}
})()</script></body></html>`;
const pages = {
chat: {
title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa',
body: `if(ls.getItem('token')){go('/?direct=1');return}
d.textContent='Fetching token from DashCaddy...';
ft('chat').then(function(r){d.textContent+=' Status: '+r.status;return r.text()}).then(function(t){
d.textContent+='\\n'+t.substring(0,300);
try{var j=JSON.parse(t);if(j.token){ls.setItem('token',j.token);go('/?direct=1')}
else{fail('Auto-login unavailable. <a href="/auth?nologin=1">Sign in manually</a>','No token field in response')}}
catch(e){fail('Auto-login error. <a href="/auth?nologin=1">Sign in manually</a>','Parse error: '+e.message)}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/auth?nologin=1">Sign in manually</a>','Fetch error: '+e.message)})`
},
plex: {
title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
body: `if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
ft('plex').then(function(r){return r.json()}).then(function(j){
if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1')}
else{fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a>',JSON.stringify(j))}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+e.message)})`
},
jellyfin: {
title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){
if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/')}
else{fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a>',JSON.stringify(j))}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+e.message)})`
},
emby: {
title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
body: `ft('emby').then(function(r){return r.json()}).then(function(j){
if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/')}
else{fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a>',JSON.stringify(j))}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+e.message)})`
},
};
const cfg = pages[service];
if (!cfg) return null;
return SHELL(cfg.body)
.replace(/__TITLE__/g, cfg.title)
.replace('__BG__', cfg.bg)
.replace('__ACCENT__', cfg.accent);
}
@@ -326,6 +326,7 @@ module.exports = function configureMiddleware(app, {
{ path: '/api/v1/totp/check-session', exact: true }, { path: '/api/v1/totp/check-session', exact: true },
{ path: '/api/v1/auth/gate/', prefix: true }, { path: '/api/v1/auth/gate/', prefix: true },
{ path: '/api/v1/auth/app-token/', prefix: true }, { path: '/api/v1/auth/app-token/', prefix: true },
{ path: '/api/v1/auth/login-page', exact: true, method: 'GET' },
{ path: '/api/v1/services', exact: true, method: 'GET' }, { path: '/api/v1/services', exact: true, method: 'GET' },
{ path: '/api/v1/ca/info', exact: true, method: 'GET' }, { path: '/api/v1/ca/info', exact: true, method: 'GET' },
{ path: '/api/v1/ca/root.crt', exact: true, method: 'GET' }, { path: '/api/v1/ca/root.crt', exact: true, method: 'GET' },