diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d1b1131 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Claudeman Environment Configuration +# Copy this file to .env and set your values + +# HTTP Basic Auth password (required to enable authentication) +# If not set, the web interface is accessible without authentication +CLAUDEMAN_PASSWORD=your_secure_password_here + +# HTTP Basic Auth username (optional, defaults to "admin") +# CLAUDEMAN_USERNAME=admin diff --git a/CLAUDE.md b/CLAUDE.md index fcb6ca0..3b2b960 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,22 @@ Claudeman is a Claude Code session manager with a web interface and autonomous R npm install ``` +## Authentication + +The web interface supports optional HTTP Basic Auth. To enable it: + +1. Copy `.env.example` to `.env` +2. Set `CLAUDEMAN_PASSWORD` to your desired password +3. Optionally set `CLAUDEMAN_USERNAME` (defaults to "admin") +4. Restart the web server + +```bash +cp .env.example .env +# Edit .env and set CLAUDEMAN_PASSWORD=your_secure_password +``` + +When enabled, browsers will prompt for credentials before accessing the web interface. + ## Commands **CRITICAL**: `npm run dev` runs CLI help, NOT the web server. Use `npx tsx src/index.ts web` for development. @@ -470,6 +486,7 @@ All routes defined in `server.ts:buildServer()`. Key endpoint groups: | File | Purpose | |------|---------| +| `.env` | Environment config (CLAUDEMAN_PASSWORD, CLAUDEMAN_USERNAME for HTTP Basic Auth) | | `~/.claudeman/state.json` | Full session state (all settings, tokens, respawn config, Ralph state), tasks, app config | | `~/.claudeman/state-inner.json` | Ralph loop/todo state per session (separate to reduce writes) | | `~/.claudeman/screens.json` | Screen session metadata (for recovery after restart) | diff --git a/package-lock.json b/package-lock.json index 71eb377..56d6a7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@modelcontextprotocol/sdk": "^1.25.3", "chalk": "^5.3.0", "commander": "^12.1.0", + "dotenv": "^17.2.3", "fastify": "^5.1.0", "ink": "^6.6.0", "ink-spinner": "^5.0.0", @@ -23,7 +24,8 @@ "zod": "^4.3.6" }, "bin": { - "claudeman": "dist/index.js" + "claudeman": "dist/index.js", + "claudeman-mcp": "dist/mcp-server.js" }, "devDependencies": { "@types/node": "^20.14.0", @@ -1915,6 +1917,18 @@ "node": ">=6" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index fbf8c1e..e3f29f3 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@modelcontextprotocol/sdk": "^1.25.3", "chalk": "^5.3.0", "commander": "^12.1.0", + "dotenv": "^17.2.3", "fastify": "^5.1.0", "ink": "^6.6.0", "ink-spinner": "^5.0.0", diff --git a/src/web/server.ts b/src/web/server.ts index 6f99fc5..8a2ccf8 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -6,11 +6,15 @@ * - Server-Sent Events (SSE) for real-time updates at /api/events * - Static file serving for the web UI * - 60fps terminal streaming with batched updates + * - Optional HTTP Basic Auth (set CLAUDEMAN_PASSWORD in .env) * * @module web/server */ -import Fastify, { FastifyInstance, FastifyReply } from 'fastify'; +// Load environment variables from .env file (must be first) +import 'dotenv/config'; + +import Fastify, { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import fastifyStatic from '@fastify/static'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -94,6 +98,38 @@ const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/; const CTRL_L_PATTERN = /\x0c/g; const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/; +// HTTP Basic Auth credentials from environment +const AUTH_PASSWORD = process.env.CLAUDEMAN_PASSWORD || ''; +const AUTH_USERNAME = process.env.CLAUDEMAN_USERNAME || 'admin'; +const AUTH_ENABLED = AUTH_PASSWORD.length > 0; + +/** + * Validates HTTP Basic Auth credentials. + * Returns true if auth is disabled or credentials are valid. + */ +function checkBasicAuth(req: FastifyRequest): boolean { + if (!AUTH_ENABLED) return true; + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Basic ')) { + return false; + } + + try { + const base64Credentials = authHeader.slice(6); + const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8'); + const [username, password] = credentials.split(':'); + + // Constant-time comparison to prevent timing attacks + const usernameMatch = username === AUTH_USERNAME; + const passwordMatch = password === AUTH_PASSWORD; + + return usernameMatch && passwordMatch; + } catch { + return false; + } +} + /** * Sanitizes hook event data before broadcasting via SSE. * Extracts only relevant fields and limits total size to prevent @@ -286,6 +322,18 @@ export class WebServer extends EventEmitter { } private async setupRoutes(): Promise { + // HTTP Basic Auth middleware (if CLAUDEMAN_PASSWORD is set in .env) + if (AUTH_ENABLED) { + console.log('[Server] HTTP Basic Auth enabled (CLAUDEMAN_PASSWORD is set)'); + this.app.addHook('preHandler', async (req, reply) => { + if (!checkBasicAuth(req)) { + reply.header('WWW-Authenticate', 'Basic realm="Claudeman"'); + reply.code(401).send({ error: 'Unauthorized' }); + return; + } + }); + } + // Serve static files await this.app.register(fastifyStatic, { root: join(__dirname, 'public'),