import Fastify, { type FastifyError, type FastifyInstance, type FastifyRequest } from 'fastify'; import fastifyCookie from '@fastify/cookie'; import fastifyWebsocket from '@fastify/websocket'; import fastifyStatic from '@fastify/static'; import { existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Config } from './config.js'; import type { Db } from './db/index.js'; import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.js'; import { PtyManager } from './core/pty-manager.js'; import { DiscoveryService } from './core/discovery-service.js'; import { SessionArchiveService } from './core/session-archive.js'; import { SettingsBus } from './core/settings-bus.js'; import { GitCredentialsManager } from './core/git-credentials.js'; import { CloneManager } from './core/clone-manager.js'; import { registerGitConnectionRoutes } from './routes/git-connections.js'; import { WorktreeManager } from './core/worktree-manager.js'; import { RepoDiscoveryService } from './core/repo-discovery.js'; import { GroupManager } from './core/group-manager.js'; import { PushService } from './core/push-service.js'; import { loadSecretBox } from './core/secret-box.js'; import { registerAuthRoutes } from './routes/auth.js'; import { registerSessionRoutes } from './routes/sessions.js'; import { registerProjectRoutes } from './routes/projects.js'; import { registerRepoRoutes } from './routes/repos.js'; import { registerGroupRoutes } from './routes/groups.js'; import { registerWorktreeRoutes } from './routes/worktrees.js'; import { registerGitRoutes } from './routes/git.js'; import { registerFileRoutes } from './routes/files.js'; import { registerPushRoutes } from './routes/push.js'; import { registerSettingsRoutes } from './routes/settings.js'; import { registerFsRoutes } from './routes/fs.js'; import { FsWatcherService } from './core/fs-watcher.js'; import { registerAuditRoutes } from './routes/audit.js'; import { registerDataRoutes } from './routes/data.js'; import { registerWsGateway } from './ws/gateway.js'; import { isHttpsRequest } from './routes/auth.js'; // En-têtes de sécurité posés sur TOUTES les réponses (defense-in-depth + conformité scanners // entreprise). Le terminal web reste du RCE par conception : ces en-têtes durcissent la SPA et // le transport, ils ne remplacent pas le modèle loopback + auth + Origin. const SECURITY_HEADERS: Record = { 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'no-referrer', 'Cross-Origin-Opener-Policy': 'same-origin', 'Permissions-Policy': 'geolocation=(), microphone=(), camera=(), payment=()', // CSP calibrée pour la SPA : Tailwind/xterm injectent du style inline ; le WebSocket impose // ws:/wss: en connect-src ; le service worker push impose worker-src 'self'. 'Content-Security-Policy': [ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data:", "font-src 'self' data:", "connect-src 'self' ws: wss:", "worker-src 'self'", "manifest-src 'self'", "frame-ancestors 'none'", "base-uri 'self'", "object-src 'none'", "form-action 'self'", ].join('; '), }; // Politique de cache du statique. Piège à connaître : `npm pack` normalise le mtime de TOUS les // fichiers du tarball à une date constante (1985-10-26). L'etag faible de @fastify/static étant // dérivé de taille+mtime, deux versions différentes d'un même fichier non haché produisent le // MÊME etag dès que leur taille coïncide : le client reçoit un 304 et garde indéfiniment // l'ancienne copie. Vécu en production sur index.html à la mise à jour de l'app desktop : l'index // obsolète référençait des `/assets/.js` disparus, le fallback SPA répondait du text/html // pour ces modules, et la page restait noire. // Conséquence : seuls les fichiers dont le NOM porte un hash de contenu (/assets/) sont // cachables ; tout le reste (index.html, sw.js, theme-boot.js, manifest, icônes) part en // no-store, la revalidation par etag n'étant pas fiable ici. export function cacheControlFor(pathname: string): string { return pathname.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-store'; } declare module 'fastify' { interface FastifyRequest { authContext: AuthContext | null; } interface FastifyContextConfig { public?: boolean; } } export interface AppBundle { app: FastifyInstance; auth: AuthService; manager: PtyManager; discovery: DiscoveryService; sessionArchive: SessionArchiveService; repoDiscovery: RepoDiscoveryService; worktrees: WorktreeManager; groups: GroupManager; push: PushService; fsWatcher: FsWatcherService; settingsBus: SettingsBus; gitCredentials: GitCredentialsManager; clones: CloneManager; } export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle { const app = Fastify({ logger: { level: process.env.ARBORETUM_LOG ?? 'info' } }); // Chiffrement au repos des secrets (server_secret, clé privée VAPID) dans la base. const box = loadSecretBox(config.dataDir); const auth = new AuthService(db, box); const limiter = new LoginRateLimiter(); const push = new PushService(db, config.vapidContact, undefined, box); const manager = new PtyManager(db, config.claudeSessionsDir, push); const discovery = new DiscoveryService({ db, ptyManager: manager, projectsDir: config.claudeProjectsDir, sessionsDir: config.claudeSessionsDir, }); // Archivage auto des sessions terminées (P10). Démarré dans runDaemon() : jamais ici (tests). const sessionArchive = new SessionArchiveService({ db }); // Watcher FS temps réel (P7) : armé à la demande sur les worktrees regardés via les commandes // WS watch/unwatch. closeAll() au drain (libération des descripteurs). const fsWatcher = new FsWatcherService(); const worktrees = new WorktreeManager(db, manager, discovery, fsWatcher); // Démarré dans runDaemon() (jamais ici) → le scan ne tourne pas pendant les tests qui appellent buildApp. const repoDiscovery = new RepoDiscoveryService(db, worktrees); const groups = new GroupManager(db); // P11 · bus de diffusion des réglages : PATCH /settings émet, la gateway relaie au topic 'settings'. const settingsBus = new SettingsBus(); // P12 · services git distants : `box` (SecretBox) câblé ici pour chiffrer les secrets des credentials. const gitCredentials = new GitCredentialsManager(db, box); const clones = new CloneManager(db, worktrees, gitCredentials); // En-têtes de sécurité sur toute réponse + no-store sur les réponses sensibles (API/WS). // onSend DOIT retourner le payload (sinon Fastify vide la réponse). app.addHook('onSend', async (req, reply, payload) => { reply.removeHeader('Server'); // anti-fingerprinting (no-op si absent) for (const [k, v] of Object.entries(SECURITY_HEADERS)) reply.header(k, v); if (isHttpsRequest(req)) { reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); } if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) { reply.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'); } return payload; }); // Garde CSRF defense-in-depth : une mutation porteuse d'un corps DOIT être en application/json // (bloque les soumissions de formulaire cross-site en text/plain, que Fastify parserait sinon). app.addHook('preHandler', async (req, reply) => { if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(req.method)) return; const len = req.headers['content-length']; if (!len || len === '0') return; // pas de corps : rien à valider const ct = (req.headers['content-type'] ?? '').toLowerCase(); if (!ct.startsWith('application/json')) { return reply.status(415).send({ error: { code: 'UNSUPPORTED_MEDIA_TYPE', message: 'Content-Type must be application/json' } }); } }); // Handler d'erreur : ne jamais fuiter de stack/chemin au client ; détail complet côté logs. app.setErrorHandler((err: FastifyError, req, reply) => { const status = err.statusCode && err.statusCode >= 400 && err.statusCode < 600 ? err.statusCode : 500; if (status >= 500) { req.log.error({ err }, 'unhandled error'); return reply.status(status).send({ error: { code: 'INTERNAL', message: 'Internal Server Error' } }); } // erreurs client (4xx) : code générique, message court non sensible. return reply.status(status).send({ error: { code: err.code ?? 'BAD_REQUEST', message: err.message } }); }); void app.register(fastifyCookie); void app.register(fastifyWebsocket, { options: { maxPayload: 1024 * 1024 }, }); const allowedOrigins = new Set([ `http://127.0.0.1:${config.port}`, `http://localhost:${config.port}`, ...config.allowedOrigins, ]); const authenticate = (req: FastifyRequest): AuthContext | null => { const bearer = req.headers.authorization; if (bearer?.startsWith('Bearer ')) { const ctx = auth.verifyRawToken(bearer.slice(7)); if (ctx) return ctx; } return auth.verifyCookie(req.cookies[auth.cookieName]); }; // Garde globale : auth sur tout /api/** et /ws ; check Origin strict quand l'en-tête est présent // (anti cross-site WebSocket hijacking : le cookie SameSite=Strict ne suffit pas pour les upgrades). app.addHook('preValidation', async (req, reply) => { const isApi = req.url.startsWith('/api/'); const isWs = req.url.startsWith('/ws'); if (!isApi && !isWs) return; // statique : public (la SPA gère son écran de login) const origin = req.headers.origin; if (origin && !allowedOrigins.has(origin)) { // Message ACTIONNABLE : c'est le premier mur de tout accès non-loopback (LAN, reverse proxy, // Tailscale). Un « Origin not allowed » sec laissait chercher pendant des heures, alors que la // correction tient en un flag. Le log serveur porte la même consigne. const hint = `Origin not allowed: ${origin}. Restart the daemon with --allow-origin ${origin} (repeatable) to permit it.`; req.log.warn({ origin, allowed: [...allowedOrigins] }, hint); return reply.status(403).send({ error: { code: 'BAD_ORIGIN', message: hint } }); } req.authContext = authenticate(req); if (req.routeOptions.config.public) return; if (!req.authContext) { return reply.status(401).send({ error: { code: 'UNAUTHORIZED', message: 'Authentication required' } }); } }); registerAuthRoutes(app, auth, limiter, serverVersion, db); registerSessionRoutes(app, manager, discovery, sessionArchive, db); registerProjectRoutes(app, manager, db); registerRepoRoutes(app, worktrees, db, manager); registerGroupRoutes(app, groups, db, worktrees, manager); registerWorktreeRoutes(app, worktrees, db); registerGitRoutes(app, worktrees, db); registerFileRoutes(app, worktrees, db); registerPushRoutes(app, push, db); registerSettingsRoutes(app, db, config, serverVersion, push, settingsBus); registerGitConnectionRoutes(app, gitCredentials, clones, db); registerFsRoutes(app); registerAuditRoutes(app, db); registerDataRoutes(app, db, auth); // La route websocket doit être déclarée APRÈS le chargement du plugin (contexte // encapsulé) : sinon le handler reçoit la signature REST (request, reply). void app.register(async (scoped) => { registerWsGateway(scoped, manager, discovery, sessionArchive, worktrees, groups, settingsBus, clones, serverVersion); }); // SPA buildée embarquée dans le paquet npm (public/) : absente en dev (vite dev sert le front) const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public'); if (existsSync(publicDir)) { void app.register(fastifyStatic, { root: publicDir, wildcard: false, // Validation conditionnelle désactivée : l'etag faible et le Last-Modified dérivent du mtime, // que `npm pack` fige (cf. cacheControlFor). Les laisser actifs ferait répondre 304 aux // clients qui détiennent encore un index.html périmé d'une version antérieure : ils y // resteraient bloqués. Sans etag, ils reçoivent un 200 et se réparent d'eux-mêmes. Le coût // est nul pour /assets (noms hachés, servis immutable) et négligeable ailleurs. etag: false, lastModified: false, // Indispensable : sinon le plugin écrit son propre `cache-control: public, max-age=0` // APRÈS setHeaders et écrase le no-store ci-dessous. cacheControl: false, // `setHeaders` s'applique aussi aux `reply.sendFile` du fallback SPA ci-dessous. setHeaders(res, path) { const rel = path.slice(publicDir.length).replace(/\\/g, '/'); res.setHeader('Cache-Control', cacheControlFor(rel)); }, }); app.setNotFoundHandler((req, reply) => { if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) { return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'Route not found' } }); } return reply.sendFile('index.html'); }); } return { app, auth, manager, discovery, sessionArchive, repoDiscovery, worktrees, groups, push, fsWatcher, settingsBus, gitCredentials, clones }; }