- Add flake.nix with dev shell - Add NixOS module for Azion runtime config - Add direnv .envrc for auto-activation - Update .gitignore with .p*, result, .worktrees/, docs/, .direnv/
523 lines
14 KiB
Nix
523 lines
14 KiB
Nix
{
|
|
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;
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|