Modèle de données : - migration #11 git_credentials (secrets chiffrés SecretBox : secret_encrypted ; colonnes ssh/oauth posées pour P12b/P12c) ; #12 repos ALTER remote_url/git_service/credential_id (pas de FK) - types partagés api.ts (GitCredentialSummary sans secret + hasSecret/secretLast4, CRUD, RemoteRepoSummary, Clone*) ; protocole additif : topic 'clones' + message clone_update (CloneOperation) Backend : - core/git-credentials.ts (GitCredentialsManager(db, box)) : CRUD chiffré, test() (GET /user), getSecret()/authContext() internes, NULLification de repos.credential_id à la suppression - core/git-clients/ (github/gitlab/gitea) via fetch, sans dépendance : verify()+listRepos() paginés, erreurs typées AUTH_FAILED/RATE_LIMITED/UNREACHABLE, SSRF base_url http(s) - core/git-auth.ts : withGitAuth (GIT_ASKPASS éphémère 0o700, secret par env, GIT_TERMINAL_PROMPT=0, jamais dans l'URL/.git/config, nettoyage finally) - core/git.ts cloneRepo (spawn git clone --progress, parse progression, timeout) - core/clone-manager.ts (EventEmitter) : clone async, dest confiné sous scanRoots + non existant, auto-enregistrement via addRepo + métadonnées de provenance, nettoyage du clone partiel, events topic 'clones' - routes/git-connections.ts (CRUD + /test + /:id/repos + POST /repos/clone 202 + GET /repos/clone/:id) ; app.ts câble box→GitCredentialsManager + CloneManager→gateway ; gateway relaie 'clones' Frontend : - ws-client subscribeClones ; stores git-connections + clone (suivi WS) - components/settings/GitConnectionsSection (liste + formulaire pat/app_password, secret jamais ré-affiché) inséré dans SettingsView ; CloneRepoModal (connexion → repos distants paginés → dest scanRoots[0] → barre de progression WS → redirection) ; bouton « Cloner » dans DashboardView ; i18n EN+FR Tests : git-credentials (round-trip SecretBox, résumé sans secret, NULLification) ; acceptance-p12.mjs (clone bare local file:// → clone_update done + repo enregistré + secret ABSENT de l'API, de la DB et du .git/config) Sous-phases restantes : P12b (SSH), P12c (OAuth device flow).
229 lines
11 KiB
TypeScript
229 lines
11 KiB
TypeScript
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<string, string> = {
|
|
'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('; '),
|
|
};
|
|
|
|
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<string>([
|
|
`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)) {
|
|
return reply.status(403).send({ error: { code: 'BAD_ORIGIN', message: `Origin not allowed: ${origin}` } });
|
|
}
|
|
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);
|
|
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 });
|
|
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 };
|
|
}
|