{ config, lib, pkgs, ... }: let cfg = config.services.azion-scheduler; proxyScript = pkgs.writeText "azion-scheduler-proxy.js" '' 'use strict'; const http = require('node:http'); class HttpError extends Error { constructor(statusCode, message) { super(message); this.name = 'HttpError'; this.statusCode = statusCode; } } function readRequiredEnv(name) { const value = process.env[name]; if (!value) { throw new Error('Missing required environment variable: ' + name); } return value; } function parseBaseUrl(rawValue) { let parsedUrl; try { parsedUrl = new URL(rawValue); } catch (error) { if (error instanceof TypeError) { throw new Error('BASEROW_URL must be a valid absolute URL.'); } throw error; } if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { throw new Error('BASEROW_URL must start with http:// or https://.'); } return parsedUrl; } function parsePositiveInteger(rawValue, variableName) { const parsedValue = Number.parseInt(rawValue, 10); if (!Number.isInteger(parsedValue) || parsedValue <= 0) { throw new Error(variableName + ' must be a positive integer.'); } return parsedValue; } function createRowsUrl(baseUrl, tableId, rowId, isListRequest) { const rowPath = rowId === null ? '/api/database/rows/table/' + tableId + '/' : '/api/database/rows/table/' + tableId + '/' + rowId + '/'; const rowsUrl = new URL(rowPath, baseUrl); rowsUrl.searchParams.set('user_field_names', 'true'); if (isListRequest) { rowsUrl.searchParams.set('size', '200'); } return rowsUrl.toString(); } function sendJson(response, statusCode, payload) { const body = JSON.stringify(payload); response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), }); response.end(body); } function sendNoContent(response) { response.writeHead(204); response.end(); } function sendMethodNotAllowed(response, allowedMethods) { response.writeHead(405, { Allow: allowedMethods.join(', '), 'Content-Type': 'application/json; charset=utf-8', }); response.end(JSON.stringify({ error: 'Method not allowed.' })); } function extractErrorMessage(payload, fallbackMessage) { if (typeof payload === 'string' && payload.length > 0) { return payload; } if (payload && typeof payload === 'object') { if (typeof payload.error === 'string' && payload.error.length > 0) { return payload.error; } if (typeof payload.detail === 'string' && payload.detail.length > 0) { return payload.detail; } if (typeof payload.message === 'string' && payload.message.length > 0) { return payload.message; } return JSON.stringify(payload); } return fallbackMessage; } async function readJsonFromResponse(response) { const bodyText = await response.text(); if (bodyText.length === 0) { return null; } try { return JSON.parse(bodyText); } catch (error) { if (error instanceof SyntaxError) { throw new HttpError(502, 'Baserow returned invalid JSON.'); } throw error; } } async function readRequestBody(request) { const MAX_BODY_BYTES = 1024 * 1024; return await new Promise((resolve, reject) => { let body = ""; let hasSizeError = false; request.setEncoding('utf8'); request.on('data', (chunk) => { if (hasSizeError) { return; } body += chunk; if (Buffer.byteLength(body, 'utf8') > MAX_BODY_BYTES) { hasSizeError = true; reject(new HttpError(413, 'Request body exceeds 1 MB.')); request.destroy(); } }); request.on('end', () => { if (!hasSizeError) { resolve(body); } }); request.on('error', (error) => { reject(new HttpError(400, 'Failed to read request body: ' + error.message)); }); }); } async function readJsonBody(request) { const rawBody = await readRequestBody(request); if (rawBody.trim().length === 0) { return {}; } let parsedBody; try { parsedBody = JSON.parse(rawBody); } catch (error) { if (error instanceof SyntaxError) { throw new HttpError(400, 'Request body must be valid JSON.'); } throw error; } if (!parsedBody || typeof parsedBody !== 'object' || Array.isArray(parsedBody)) { throw new HttpError(400, 'Request body must be a JSON object.'); } return parsedBody; } const BASEROW_FETCH_TIMEOUT_MS = 10000; async function fetchWithTimeout(url, options) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), BASEROW_FETCH_TIMEOUT_MS); try { return await fetch(url, { ...options, signal: controller.signal, }); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw new HttpError(504, 'Baserow request timed out after 10000 ms.'); } throw error; } finally { clearTimeout(timeoutId); } } async function callBaserow(options) { const requestUrl = createRowsUrl( options.baseUrl, options.tableId, options.rowId === undefined ? null : options.rowId, options.isListRequest === true ); const headers = { Accept: 'application/json', Authorization: 'Token ' + options.token, }; const requestOptions = { method: options.method, headers, }; if (options.payload !== undefined && options.payload !== null) { headers['Content-Type'] = 'application/json'; requestOptions.body = JSON.stringify(options.payload); } const response = await fetchWithTimeout(requestUrl, requestOptions); if (!response.ok) { const errorPayload = await readJsonFromResponse(response); const message = extractErrorMessage(errorPayload, response.statusText || 'Request failed'); throw new HttpError(response.status, 'Baserow request failed: ' + message); } if (response.status === 204) { return null; } return await readJsonFromResponse(response); } function parseRowId(pathname, pattern, entityName) { const match = pathname.match(pattern); if (!match) { return null; } const rowId = Number.parseInt(match[1], 10); if (!Number.isInteger(rowId) || rowId <= 0) { throw new HttpError(400, entityName + ' ID must be a positive integer.'); } return rowId; } function ensureListResult(value, entityName) { if (!value || typeof value !== 'object' || !Array.isArray(value.results)) { throw new HttpError(502, 'Baserow list response for ' + entityName + ' is invalid.'); } return value.results; } const proxyPort = ${toString cfg.proxyPort}; const baserowUrl = parseBaseUrl(readRequiredEnv('BASEROW_URL')); const baserowToken = readRequiredEnv('BASEROW_TOKEN'); const serversTableId = parsePositiveInteger(readRequiredEnv('SERVERS_TABLE_ID'), 'SERVERS_TABLE_ID'); const processesTableId = parsePositiveInteger(readRequiredEnv('PROCESSES_TABLE_ID'), 'PROCESSES_TABLE_ID'); async function handleRequest(request, response) { const method = request.method || 'GET'; const requestUrl = new URL(request.url || '/', 'http://127.0.0.1'); const pathname = requestUrl.pathname; if (pathname === '/health') { if (method !== 'GET') { sendMethodNotAllowed(response, ['GET']); return; } sendJson(response, 200, { status: 'ok' }); return; } if (pathname === '/servers') { if (method === 'GET') { const listResponse = await callBaserow({ baseUrl: baserowUrl, token: baserowToken, tableId: serversTableId, method: 'GET', isListRequest: true, }); sendJson(response, 200, ensureListResult(listResponse, 'servers')); return; } if (method === 'POST') { const payload = await readJsonBody(request); const createdRow = await callBaserow({ baseUrl: baserowUrl, token: baserowToken, tableId: serversTableId, method: 'POST', payload, }); sendJson(response, 201, createdRow); return; } sendMethodNotAllowed(response, ['GET', 'POST']); return; } const serverRowId = parseRowId(pathname, /^\/servers\/(\d+)$/, 'Server'); if (serverRowId !== null) { if (method !== 'DELETE') { sendMethodNotAllowed(response, ['DELETE']); return; } await callBaserow({ baseUrl: baserowUrl, token: baserowToken, tableId: serversTableId, method: 'DELETE', rowId: serverRowId, }); sendNoContent(response); return; } if (pathname === '/processes') { if (method === 'GET') { const listResponse = await callBaserow({ baseUrl: baserowUrl, token: baserowToken, tableId: processesTableId, method: 'GET', isListRequest: true, }); sendJson(response, 200, ensureListResult(listResponse, 'processes')); return; } if (method === 'POST') { const payload = await readJsonBody(request); const createdRow = await callBaserow({ baseUrl: baserowUrl, token: baserowToken, tableId: processesTableId, method: 'POST', payload, }); sendJson(response, 201, createdRow); return; } sendMethodNotAllowed(response, ['GET', 'POST']); return; } const processRowId = parseRowId(pathname, /^\/processes\/(\d+)$/, 'Process'); if (processRowId !== null) { if (method === 'PATCH') { const payload = await readJsonBody(request); const updatedRow = await callBaserow({ baseUrl: baserowUrl, token: baserowToken, tableId: processesTableId, method: 'PATCH', rowId: processRowId, payload, }); sendJson(response, 200, updatedRow); return; } if (method === 'DELETE') { await callBaserow({ baseUrl: baserowUrl, token: baserowToken, tableId: processesTableId, method: 'DELETE', rowId: processRowId, }); sendNoContent(response); return; } sendMethodNotAllowed(response, ['PATCH', 'DELETE']); return; } sendJson(response, 404, { error: 'Not found.' }); } const server = http.createServer((request, response) => { handleRequest(request, response).catch((error) => { if (error instanceof HttpError) { sendJson(response, error.statusCode, { error: error.message }); return; } if (error instanceof Error) { console.error('[azion-scheduler-proxy] Request failed:', error.message); sendJson(response, 500, { error: 'Internal server error.' }); return; } console.error('[azion-scheduler-proxy] Request failed with unknown error type.'); sendJson(response, 500, { error: 'Internal server error.' }); }); }); server.on('clientError', (error, socket) => { console.error('[azion-scheduler-proxy] Client error:', error.message); socket.end('HTTP/1.1 400 Bad Request\\r\\n\\r\\n'); }); server.listen(proxyPort, '127.0.0.1', () => { console.log('[azion-scheduler-proxy] Listening on 127.0.0.1:' + proxyPort); }); ''; in { options.services.azion-scheduler = { enable = lib.mkEnableOption "azion-scheduler web UI"; package = lib.mkOption { type = lib.types.package; description = "The azion-scheduler package to serve."; }; port = lib.mkOption { type = lib.types.port; default = 3039; description = "Port where nginx listens locally."; }; proxyPort = lib.mkOption { type = lib.types.port; default = 3049; description = "Port for the internal Baserow proxy service."; }; environmentFile = lib.mkOption { type = lib.types.path; description = "File containing BASEROW_* runtime credentials and table IDs."; }; }; config = lib.mkIf cfg.enable { systemd.services.azion-scheduler-proxy = { description = "AZion Scheduler Baserow proxy"; wantedBy = [ "multi-user.target" ]; before = [ "nginx.service" ]; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; serviceConfig = { Type = "simple"; EnvironmentFile = cfg.environmentFile; ExecStart = "${pkgs.nodejs}/bin/node ${proxyScript}"; Restart = "on-failure"; RestartSec = 3; DynamicUser = true; NoNewPrivileges = true; }; }; systemd.services.nginx = { after = [ "azion-scheduler-proxy.service" ]; wants = [ "azion-scheduler-proxy.service" ]; }; services.nginx = { enable = true; virtualHosts."azion-scheduler" = { listen = [ { addr = "127.0.0.1"; port = cfg.port; } ]; locations."/" = { root = "${cfg.package}"; tryFiles = "$uri $uri/ /index.html"; }; locations."/api/" = { proxyPass = "http://127.0.0.1:${toString cfg.proxyPort}/"; extraConfig = '' proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; client_max_body_size 1m; ''; }; }; }; }; }