Files
ARCG-Remote-Station-Software/server/index.js
OE6DXD e1a4ce0b8b initialize generic rms-software repository
Add the reusable RMS core application (server, web UI, plugins, tests, tools) with generic defaults, GPL licensing, and maintainer context documentation so deployments can consume this repo as software source independent of station-specific overlays.
2026-03-16 03:31:08 +01:00

7531 lines
264 KiB
JavaScript

const http = require("http");
const fs = require("fs");
const fsp = require("fs/promises");
const path = require("path");
const crypto = require("crypto");
const { spawn } = require("child_process");
const { WebSocketServer } = require("ws");
const { createStorageProvider } = require("./storage");
const DEBUG_REMOTE_INTERFACE_ENABLED = true;
const DEBUG_REMOTE_DEFAULT_TOKEN = "dbg_8f4e5c9b71a64f2fa3e7c1d6b5a9e2f0_owrx";
const rootDir = path.resolve(__dirname, "..");
const initialEnvKeys = new Set(Object.keys(process.env));
if (!initialEnvKeys.has("DATA_DIR")) {
loadDotEnv(path.join(rootDir, ".env"));
}
const defaultDataDir = resolveDefaultDataDir(rootDir);
const config = {
port: Number(process.env.PORT || 8080),
dataDir: path.resolve(rootDir, process.env.DATA_DIR || defaultDataDir),
brandingUploadsDir: path.resolve(rootDir, process.env.BRANDING_UPLOADS_DIR || path.join(process.env.DATA_DIR || defaultDataDir, "uploads")),
vswrImagesDir: path.resolve(rootDir, process.env.VSWR_IMAGES_DIR_PATH || path.join(process.env.DATA_DIR || defaultDataDir, "vswr", "images")),
storageProvider: String(process.env.STORAGE_PROVIDER || "json").trim(),
storageModulePath: String(process.env.STORAGE_MODULE_PATH || "").trim(),
storageSqlitePath: String(process.env.STORAGE_SQLITE_PATH || "").trim(),
pluginDir: path.resolve(rootDir, process.env.PLUGIN_DIR || "./plugins"),
stationName: process.env.STATION_NAME || "ARCG Stradnerkogel",
adminEmails: new Set(
String(process.env.ADMIN_EMAILS || "")
.split(",")
.map((value) => normalizeEmail(value))
.filter(Boolean)
),
accessTokenTtlSec: Number(process.env.ACCESS_TOKEN_TTL_SEC || 10800),
refreshTokenTtlSec: Number(process.env.REFRESH_TOKEN_TTL_SEC || 60 * 60 * 24 * 14),
jwtSecret: process.env.JWT_SECRET || "change-me-in-production",
jwtIssuer: process.env.JWT_ISSUER || "rms-arcg",
jwtAudience: process.env.JWT_AUDIENCE || "rms-clients",
authRateWindowMs: Number(process.env.AUTH_RATE_WINDOW_MS || 10 * 60 * 1000),
authRateLimit: Number(process.env.AUTH_RATE_LIMIT || 25),
actionRateWindowMs: Number(process.env.ACTION_RATE_WINDOW_MS || 60 * 1000),
actionRateLimit: Number(process.env.ACTION_RATE_LIMIT || 30),
openWebRxPttLiveFreqToleranceHz: Number(process.env.OPENWEBRX_PTT_LIVE_FREQ_TOLERANCE_HZ || 50),
openWebRxLiveStateTtlMs: Number(process.env.OPENWEBRX_LIVE_STATE_TTL_MS || 10000),
openWebRxPttBlockedBandConfigIdsRaw: String(process.env.OPENWEBRX_PTT_BLOCKED_BAND_CONFIG_IDS || "27,6,24"),
openWebRxTxPollMs: Number(process.env.OPENWEBRX_TX_POLL_MS || 3000),
txAudioEnabled: String(process.env.TX_AUDIO_ENABLED || "true").trim().toLowerCase(),
txAudioAlsaDevice: String(process.env.TX_AUDIO_ALSA_DEVICE || "plughw:CARD=CODEC,DEV=0").trim(),
txAudioInputMime: String(process.env.TX_AUDIO_INPUT_MIME || "webm").trim().toLowerCase(),
txAudioStopOnDisconnect: String(process.env.TX_AUDIO_STOP_ON_DISCONNECT || "true").trim().toLowerCase(),
txAudioChunkMs: Number(process.env.TX_AUDIO_CHUNK_MS || 100),
txAudioSessionTimeoutMs: Number(process.env.TX_AUDIO_SESSION_TIMEOUT_MS || 120000),
txAudioFfmpegPath: String(process.env.TX_AUDIO_FFMPEG_PATH || "").trim(),
txAudioFfmpegExtraArgs: String(process.env.TX_AUDIO_FFMPEG_EXTRA_ARGS || "").trim(),
openWebRxPttCommandsEnabled: String(process.env.OPENWEBRX_PTT_COMMANDS_ENABLED || "false").trim().toLowerCase(),
openWebRxPttDevice: String(process.env.OPENWEBRX_PTT_DEVICE || process.env.RMS_MICROHAM_DEV || "/dev/rms-microham-u3").trim(),
openWebRxPttTimeoutMs: Number(process.env.OPENWEBRX_PTT_TIMEOUT_MS || 5000),
openWebRxPttDownCmd: String(process.env.OPENWEBRX_PTT_DOWN_CMD || "").trim(),
openWebRxPttUpCmd: String(process.env.OPENWEBRX_PTT_UP_CMD || "").trim(),
rotorDevice: String(process.env.RMS_ROTOR_DEV || process.env.RMS_FTDI_DEV || "/dev/rms-ftdi-uart").trim(),
rotorModel: Number(process.env.ROTOR_ROTCTL_MODEL || 902),
rotorSetEnabled: String(process.env.ROTOR_SET_ENABLED || "true").trim().toLowerCase(),
rotorGetTimeoutMs: Number(process.env.ROTOR_GET_TIMEOUT_MS || 10000),
rotorSetTimeoutMs: Number(process.env.ROTOR_SET_TIMEOUT_MS || 20000),
rotorMonitorMaxMs: Number(process.env.ROTOR_MONITOR_MAX_MS || 120000),
rotorStatusRetryCount: Number(process.env.ROTOR_STATUS_RETRY_COUNT || 6),
rotorStatusRetryDelayMs: Number(process.env.ROTOR_STATUS_RETRY_DELAY_MS || 400),
rotorPostSetStatusTimeoutMs: Number(process.env.ROTOR_POST_SET_STATUS_TIMEOUT_MS || 5000),
autoDisableTxBeforeActivation: String(process.env.AUTO_DISABLE_TX_BEFORE_ACTIVATION || "").trim().toLowerCase(),
stationMaxUsageSec: Number(process.env.STATION_MAX_USAGE_SEC || 3600),
primaryEmailDomain: String(process.env.PRIMARY_EMAIL_DOMAIN || "arcg.at").toLowerCase(),
publicBaseUrl: String(process.env.PUBLIC_BASE_URL || ""),
approverEmails: new Set(
String(process.env.APPROVER_EMAILS || "")
.split(",")
.map((value) => normalizeEmail(value))
.filter(Boolean)
)
};
config.execMode = resolveExecMode(process.env.RMS_EXEC_MODE, process.env.npm_lifecycle_event);
config.openWebRxPttBlockedBandConfigIds = parseBandConfigIdList(config.openWebRxPttBlockedBandConfigIdsRaw);
config.simulateHardware = config.execMode !== "prod";
if (!config.autoDisableTxBeforeActivation) {
config.autoDisableTxBeforeActivation = config.execMode === "prod" ? "true" : "false";
}
config.rootDir = rootDir;
applyRuntimePathDefaults(config, initialEnvKeys);
function resolveDefaultDataDir(appRootDir) {
const normalized = path.resolve(appRootDir);
const marker = `${path.sep}releases${path.sep}`;
const markerIdx = normalized.indexOf(marker);
if (markerIdx !== -1) {
const deployBase = normalized.slice(0, markerIdx);
return path.join(deployBase, "shared", "data");
}
return "./data";
}
function parseBandConfigIdList(raw) {
return String(raw || "")
.split(",")
.map((value) => String(value || "").trim())
.filter(Boolean);
}
function isPttBlockedForBandConfigId(bandConfigId) {
const selected = String(bandConfigId || "").trim();
if (!selected) {
return false;
}
return Array.isArray(config.openWebRxPttBlockedBandConfigIds)
&& config.openWebRxPttBlockedBandConfigIds.includes(selected);
}
function applyRuntimePathDefaults(appConfig, envKeysBeforeDotEnv) {
if (!envKeysBeforeDotEnv.has("DATA_DIR")) {
return;
}
const defaults = {
TX_STATE_PATH: "tx-state.json",
OPENWEBRX_BAND_STATE_PATH: "openwebrx-band-state.json",
OPENWEBRX_ACCESS_POLICY_FILE: "openwebrx-access-policy.txt",
OPENWEBRX_PERSISTENT_USERS_FILE: "openwebrx-persistent-users.txt",
BRANDING_UPLOADS_DIR: "uploads"
};
for (const [key, relative] of Object.entries(defaults)) {
if (!envKeysBeforeDotEnv.has(key)) {
process.env[key] = path.join(appConfig.dataDir, relative);
}
}
}
const files = {
users: path.join(config.dataDir, "users.json"),
station: path.join(config.dataDir, "station-state.json"),
audit: path.join(config.dataDir, "audit.log"),
auth: path.join(config.dataDir, "auth-state.json"),
plugins: path.join(config.dataDir, "plugin-state.json"),
approvals: path.join(config.dataDir, "approval-requests.json"),
system: path.join(config.dataDir, "system-state.json"),
mailOutbox: path.join(config.dataDir, "mail-outbox.log")
};
const runtime = {
users: [],
station: null,
authState: {
refreshTokens: [],
tokenVersionByUser: {},
emailTokens: [],
otpChallenges: []
},
approvalRequests: [],
systemState: {
maintenanceMode: false,
maintenanceMessage: "Wartungsmodus aktiv. Login ist derzeit deaktiviert.",
branding: {
logoLightUrl: null,
logoDarkUrl: null
},
updatedAt: null
},
pluginState: {
enabled: {},
providers: {},
settings: {}
},
plugins: new Map(),
pluginHealth: {},
rateBuckets: new Map(),
jobs: new Map(),
currentActivationJobId: null,
swrRun: {
running: false,
token: null,
source: null,
phase: null,
startedAt: null,
expectedDurationMs: 0,
startedBy: null,
lastStatus: null,
lastError: null,
lastFinishedAt: null
},
txFollowRoute: null,
openWebRxAntennaRoute: null,
openWebRxSession: {
activeOwnerUserId: null,
expiresAtMs: 0,
lastEnsureSdrAtMs: 0
},
openWebRxLiveStateByUserId: {},
rotor: {
azimuth: null,
rawAzimuth: null,
moving: false,
targetAzimuth: null,
pendingTargetAzimuth: null,
pendingSource: null,
pendingRequestedByUserId: null,
queueWorkerActive: false,
statusRefreshInFlight: false,
phase: "idle",
commandInProgress: false,
commandStartedAt: null,
lastChangeAt: null,
min: 0,
max: 360,
updatedAt: null,
lastResult: null,
lastError: null
},
txAudio: {
wsServer: null,
ffmpeg: null,
clients: new Set(),
running: false,
startedAt: null,
ownerUserId: null,
alsaDevice: null,
stopRequested: false,
lastError: null,
lastExit: null,
idleTimer: null
},
pttActive: false,
sseClients: new Set(),
eventSeq: 0
};
let mutex = Promise.resolve();
let storage = null;
start().catch((error) => {
console.error("Startup failed", error);
process.exit(1);
});
async function start() {
storage = await createStorageProvider(config);
await ensureDataFiles();
runtime.users = await readJson(files.users, []);
runtime.station = await readJson(files.station, buildDefaultStationState());
runtime.authState = await readJson(files.auth, runtime.authState);
runtime.pluginState = await readJson(files.plugins, runtime.pluginState);
await migratePluginSettingsDefaults();
runtime.approvalRequests = await readJson(files.approvals, []);
runtime.systemState = normalizeSystemState(await readJson(files.system, runtime.systemState));
await reconcileBrandingFromUploads();
await loadPlugins();
await applyAdminRoles();
await reconcileStationLeaseOnStartup();
const server = http.createServer(routeRequest);
initTxAudioWebSocket(server);
server.listen(config.port, () => {
console.log(`RMS API listening on http://localhost:${config.port}`);
});
setInterval(() => {
broadcastEvent("system.heartbeat", { ok: true });
}, 20000).unref();
setInterval(() => {
refreshPluginHealth().catch(() => {});
}, 30000).unref();
setInterval(() => {
refreshRotorStatusCache().catch(() => {});
}, 2000).unref();
setInterval(() => {
enforceStationLeaseTimeout().catch(() => {});
}, 5000).unref();
setInterval(() => {
enforceOpenWebRxPttConnectionWatchdog().catch(() => {});
}, 2000).unref();
}
async function routeRequest(req, res) {
try {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
const method = req.method || "GET";
if (method === "GET" && url.pathname === "/api/health") {
return sendJson(res, 200, {
ok: true,
stationName: config.stationName,
maintenanceMode: Boolean(runtime.systemState.maintenanceMode),
maintenanceMessage: runtime.systemState.maintenanceMessage
});
}
if (method === "GET" && url.pathname === "/v1/public/system") {
return sendJson(res, 200, {
maintenanceMode: Boolean(runtime.systemState.maintenanceMode),
maintenanceMessage: runtime.systemState.maintenanceMessage,
branding: runtime.systemState.branding,
updatedAt: runtime.systemState.updatedAt
});
}
if (method === "GET" && url.pathname === "/v1/public/auth-methods") {
return sendJson(res, 200, { methods: listPublicAuthMethods() });
}
if (method === "POST" && (url.pathname === "/v1/auth/request-access" || url.pathname === "/v1/auth/register" || url.pathname === "/v1/auth/login")) {
if (!enforceRateLimit(req, res, `auth:request-access:${clientIp(req)}`, config.authRateLimit, config.authRateWindowMs)) {
return;
}
const body = await readJsonBody(req);
return handleRequestAccess(req, res, body);
}
if (method === "POST" && url.pathname === "/v1/auth/verify-email") {
const body = await readJsonBody(req);
return handleVerifyEmail(req, res, body);
}
if (method === "POST" && url.pathname === "/v1/auth/request-approval") {
const body = await readJsonBody(req);
return handleRequestApproval(req, res, body);
}
if (method === "POST" && url.pathname === "/v1/auth/refresh") {
if (!enforceRateLimit(req, res, `auth:refresh:${clientIp(req)}`, config.authRateLimit, config.authRateWindowMs)) {
return;
}
const body = await readJsonBody(req);
return handleRefresh(req, res, body);
}
if (method === "POST" && url.pathname === "/v1/auth/logout") {
const body = await readJsonBody(req);
return handleLogout(res, body);
}
if (method === "POST" && url.pathname === "/v1/auth/logout-all") {
const auth = requireAuth(req, res);
if (!auth) return;
return handleLogoutAll(res, auth.user);
}
if (method === "GET" && url.pathname === "/v1/me") {
const auth = requireAuth(req, res);
if (!auth) return;
return sendJson(res, 200, { user: sanitizeUser(auth.user), capabilities: userCapabilities(auth.user) });
}
if (method === "GET" && url.pathname === "/v1/auth/session/diag") {
const auth = requireAuth(req, res);
if (!auth) return;
return handleAuthSessionDiag(res, auth.user, auth.payload);
}
if (method === "GET" && url.pathname === "/v1/help/content") {
const auth = requireAuth(req, res);
if (!auth) return;
return handleHelpContent(res, auth.user);
}
if (method === "PUT" && url.pathname === "/v1/me/auth-method") {
const auth = requireAuth(req, res);
if (!auth) return;
const body = await readJsonBody(req);
return handleSelfAuthMethodUpdate(res, auth.user, body);
}
if (method === "PUT" && url.pathname === "/v1/me/language") {
const auth = requireAuth(req, res);
if (!auth) return;
const body = await readJsonBody(req);
return handleSelfLanguageUpdate(res, auth.user, body);
}
if (method === "GET" && url.pathname === "/v1/station/status") {
const auth = requireAuth(req, res);
if (!auth) return;
return sendJson(res, 200, buildStationStatusView());
}
if (method === "GET" && url.pathname === "/v1/swr/report") {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["operator", "approver", "admin"])) {
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
}
const report = await buildSwrReportView(auth.user);
return sendJson(res, 200, report);
}
if (method === "POST" && url.pathname === "/v1/swr/run-check") {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["operator", "approver", "admin"])) {
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
}
if (denyIfStationOwnedByOther(res, auth.user, "SWR-Check")) {
return;
}
try {
const { result, report } = await runSwrCheckAndBuildReport(auth.user);
await appendAudit("vswr.run.manual", auth.user, { ok: true });
return sendJson(res, 200, { ok: true, result, report });
} catch (error) {
await appendAudit("vswr.run.manual", auth.user, { ok: false, error: String(error && error.message ? error.message : error) });
if (error && error.code === "TX_SWITCH_LOCK") {
return sendError(res, 409, "tx.switch_locked", String(error.message || error), error.details || null);
}
if (error && error.code === "OPENWEBRX_SESSION_ACTIVE") {
return sendError(res, 409, "vswr.unsafe_openwebrx_active", String(error.message || error));
}
if (error && error.code === "VSWR_WHILE_STATION_ACTIVE") {
return sendError(res, 409, "vswr.unsafe_station_active", String(error.message || error));
}
if (error && error.code === "SWR_ALREADY_RUNNING") {
return sendError(res, 409, "swr.running", String(error.message || error));
}
return sendError(res, 500, "vswr.run.failed", String(error && error.message ? error.message : error));
}
}
if (method === "POST" && url.pathname === "/v1/station/activation-jobs") {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["operator", "approver", "admin"])) {
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
}
if (denyIfStationOwnedByOther(res, auth.user, "Aktivierung")) {
return;
}
if (!enforceRateLimit(req, res, `station:activate:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
return;
}
return handleActivationStart(res, auth.user);
}
if (method === "GET" && url.pathname.startsWith("/v1/station/activation-jobs/")) {
const auth = requireAuth(req, res);
if (!auth) return;
const jobId = decodeURIComponent(url.pathname.slice("/v1/station/activation-jobs/".length));
const job = runtime.jobs.get(jobId);
if (!job) {
return sendError(res, 404, "station.job.not_found", "Job nicht gefunden");
}
return sendJson(res, 200, { job });
}
if (method === "POST" && url.pathname === "/v1/station/release") {
const auth = requireAuth(req, res);
if (!auth) return;
if (denyIfStationOwnedByOther(res, auth.user, "Freigabe", { allowAdmin: true })) {
return;
}
if (!enforceRateLimit(req, res, `station:release:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
return;
}
return handleStationRelease(res, auth.user);
}
if (method === "POST" && url.pathname === "/v1/station/reservations/next") {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["operator", "approver", "admin"])) {
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
}
if (!enforceRateLimit(req, res, `station:reserve-next:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
return;
}
return handleReserveNextSlot(res, auth.user);
}
if (method === "DELETE" && url.pathname === "/v1/station/reservations/next") {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["operator", "approver", "admin"])) {
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
}
if (!enforceRateLimit(req, res, `station:reserve-delete:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
return;
}
return handleDeleteOwnReservation(res, auth.user);
}
if (method === "POST" && url.pathname === "/v1/openwebrx/session") {
const auth = requireAuth(req, res);
if (!auth) return;
if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX Session")) {
return;
}
if (!enforceRateLimit(req, res, `openwebrx:session:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
return;
}
return handleOpenWebRxSessionIssue(res, auth.user);
}
if (method === "POST" && url.pathname === "/v1/openwebrx/tx/enable") {
const auth = requireAuth(req, res);
if (!auth) return;
if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX TX")) {
return;
}
if (!enforceRateLimit(req, res, `openwebrx:tx-enable:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
return;
}
return handleOpenWebRxTxEnable(res, auth.user);
}
if (method === "POST" && url.pathname === "/v1/openwebrx/tx/disable") {
const auth = requireAuth(req, res);
if (!auth) return;
if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX TX")) {
return;
}
if (!enforceRateLimit(req, res, `openwebrx:tx-disable:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
return;
}
return handleOpenWebRxTxDisable(res, auth.user);
}
if (method === "POST" && url.pathname === "/v1/openwebrx/ptt/down") {
const auth = requireAuth(req, res);
if (!auth) return;
if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX PTT")) {
return;
}
if (!enforceRateLimit(req, res, `openwebrx:ptt-down:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
return;
}
return handleOpenWebRxPttDown(res, auth.user);
}
if (method === "POST" && url.pathname === "/v1/openwebrx/ptt/up") {
const auth = requireAuth(req, res);
if (!auth) return;
if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX PTT")) {
return;
}
if (!enforceRateLimit(req, res, `openwebrx:ptt-up:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
return;
}
return handleOpenWebRxPttUp(res, auth.user);
}
if (method === "GET" && url.pathname === "/v1/openwebrx/tx/status") {
const auth = requireAuth(req, res);
if (!auth) return;
return handleOpenWebRxTxStatus(res, auth.user);
}
if (method === "GET" && url.pathname === "/v1/openwebrx/rotor/status") {
const auth = requireAuth(req, res);
if (!auth) return;
return handleOpenWebRxRotorStatus(res, auth.user);
}
if (method === "GET" && url.pathname === "/v1/openwebrx/bands") {
const auth = requireAuth(req, res);
if (!auth) return;
return handleOpenWebRxBands(res, auth.user);
}
if (method === "POST" && url.pathname === "/v1/openwebrx/bands/select") {
const auth = requireAuth(req, res);
if (!auth) return;
if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX Bandwechsel")) {
return;
}
if (!enforceRateLimit(req, res, `openwebrx:band-select:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
return;
}
const body = await readJsonBody(req);
return handleOpenWebRxBandSelect(res, auth.user, body);
}
if (method === "POST" && url.pathname === "/v1/openwebrx/rotor/set") {
const auth = requireAuth(req, res);
if (!auth) return;
if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX Rotor")) {
return;
}
if (!enforceRateLimit(req, res, `openwebrx:rotor-set:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
return;
}
const body = await readJsonBody(req);
return handleOpenWebRxRotorSet(res, auth.user, body, "api");
}
if (method === "POST" && url.pathname === "/v1/openwebrx/session/close") {
const auth = requireAuth(req, res);
if (!auth) return;
if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX Session", { allowAdmin: true })) {
return;
}
if (!enforceRateLimit(req, res, `openwebrx:session-close:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
return;
}
return handleOpenWebRxSessionClose(res, auth.user);
}
if (method === "GET" && url.pathname === "/v1/openwebrx/plugin/state") {
return handleOpenWebRxPluginState(req, res, url);
}
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/tx/enable") {
return handleOpenWebRxPluginTx(req, res, url, true);
}
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/tx/disable") {
return handleOpenWebRxPluginTx(req, res, url, false);
}
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/ptt/down") {
return handleOpenWebRxPluginPtt(req, res, url, true);
}
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/ptt/up") {
return handleOpenWebRxPluginPtt(req, res, url, false);
}
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/live-state") {
const body = await readJsonBody(req);
return handleOpenWebRxPluginLiveState(req, res, url, body);
}
if (method === "GET" && url.pathname === "/v1/openwebrx/plugin/audio/status") {
return handleOpenWebRxPluginAudioStatus(req, res, url);
}
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/audio/connect") {
return handleOpenWebRxPluginAudioConnect(req, res, url);
}
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/audio/disconnect") {
return handleOpenWebRxPluginAudioDisconnect(req, res, url);
}
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/bands/select") {
const body = await readJsonBody(req);
return handleOpenWebRxPluginBandSelect(req, res, url, body);
}
if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/rotor/set") {
const body = await readJsonBody(req);
return handleOpenWebRxPluginRotorSet(req, res, url, body);
}
if (method === "GET" && url.pathname === "/v1/openwebrx/authorize") {
return handleOpenWebRxAuthorize(req, res, url);
}
if (method === "POST" && url.pathname === "/v1/debug/collect/owrx") {
return handleDebugCollect(req, res, url, "owrx");
}
if (method === "GET" && url.pathname === "/v1/debug/logs/owrx") {
return handleDebugLogs(req, res, url, "owrx");
}
if (method === "POST" && url.pathname === "/v1/debug/clear/owrx") {
return handleDebugClear(req, res, url, "owrx");
}
if (method === "GET" && url.pathname === "/v1/debug/snapshot/owrx") {
return handleDebugSnapshot(req, res, url, "owrx");
}
if (method === "POST" && url.pathname === "/v1/debug/collect/usb") {
return handleDebugCollect(req, res, url, "usb");
}
if (method === "GET" && url.pathname === "/v1/debug/logs/usb") {
return handleDebugLogs(req, res, url, "usb");
}
if (method === "POST" && url.pathname === "/v1/debug/clear/usb") {
return handleDebugClear(req, res, url, "usb");
}
if (method === "GET" && url.pathname === "/v1/debug/snapshot/usb") {
return handleDebugSnapshot(req, res, url, "usb");
}
if (method === "POST" && url.pathname === "/v1/debug/collect/alsa") {
return handleDebugCollect(req, res, url, "alsa");
}
if (method === "GET" && url.pathname === "/v1/debug/logs/alsa") {
return handleDebugLogs(req, res, url, "alsa");
}
if (method === "POST" && url.pathname === "/v1/debug/clear/alsa") {
return handleDebugClear(req, res, url, "alsa");
}
if (method === "GET" && url.pathname === "/v1/debug/snapshot/alsa") {
return handleDebugSnapshot(req, res, url, "alsa");
}
if (method === "POST" && url.pathname === "/v1/debug/collect/soapy") {
return handleDebugCollect(req, res, url, "soapy");
}
if (method === "GET" && url.pathname === "/v1/debug/logs/soapy") {
return handleDebugLogs(req, res, url, "soapy");
}
if (method === "POST" && url.pathname === "/v1/debug/clear/soapy") {
return handleDebugClear(req, res, url, "soapy");
}
if (method === "GET" && url.pathname === "/v1/debug/snapshot/soapy") {
return handleDebugSnapshot(req, res, url, "soapy");
}
if (method === "GET" && url.pathname === "/v1/debug/which") {
return handleDebugWhich(req, res, url);
}
if (method === "GET" && url.pathname === "/v1/ui/controls") {
const auth = requireAuth(req, res);
if (!auth) return;
return sendJson(res, 200, { controls: await buildUiControls(auth.user) });
}
if (method === "POST" && url.pathname.startsWith("/v1/ui/controls/") && url.pathname.includes("/actions/")) {
const auth = requireAuth(req, res);
if (!auth) return;
if (!enforceRateLimit(req, res, `ui:action:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) {
return;
}
const body = await readJsonBody(req);
return handleUiControlAction(req, res, auth.user, url.pathname, body);
}
if (method === "GET" && url.pathname === "/v1/plugins") {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin"])) {
return sendError(res, 403, "auth.forbidden", "Nur Admin");
}
return sendJson(res, 200, {
plugins: await listPlugins(),
providers: runtime.pluginState.providers,
capabilities: await buildCapabilitiesMatrix()
});
}
if (method === "GET" && url.pathname === "/v1/admin/capabilities") {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin"])) {
return sendError(res, 403, "auth.forbidden", "Nur Admin");
}
return sendJson(res, 200, { capabilities: await buildCapabilitiesMatrix() });
}
if (method === "GET" && url.pathname === "/v1/admin/users") {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin", "approver"])) {
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
}
return sendJson(res, 200, { users: runtime.users.map((entry) => sanitizeUser(entry)) });
}
if (method === "PUT" && url.pathname.match(/^\/v1\/admin\/users\/[^/]+\/role$/)) {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin"])) {
return sendError(res, 403, "auth.forbidden", "Nur Admin");
}
const body = await readJsonBody(req);
return handleAdminUserRoleUpdate(res, auth.user, url.pathname, body);
}
if (method === "PUT" && url.pathname.match(/^\/v1\/admin\/users\/[^/]+\/auth-methods$/)) {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin"])) {
return sendError(res, 403, "auth.forbidden", "Nur Admin");
}
const body = await readJsonBody(req);
return handleAdminUserAuthMethodsUpdate(res, auth.user, url.pathname, body);
}
if (method === "GET" && url.pathname === "/v1/approvals") {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin", "approver"])) {
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
}
return sendJson(res, 200, { approvals: listApprovalsView() });
}
if (method === "GET" && url.pathname === "/v1/activity-log") {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin", "approver"])) {
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
}
const limitRaw = Number(url.searchParams.get("limit") || 200);
const limit = Math.max(20, Math.min(1000, Number.isFinite(limitRaw) ? limitRaw : 200));
const entries = await listStationActivityLog(limit);
return sendJson(res, 200, { entries });
}
if (method === "POST" && url.pathname.match(/^\/v1\/approvals\/[^/]+\/(approve|reject)$/)) {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin", "approver"])) {
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
}
return handleApprovalDecision(res, auth.user, url.pathname);
}
if (method === "PUT" && url.pathname === "/v1/admin/maintenance") {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin"])) {
return sendError(res, 403, "auth.forbidden", "Nur Admin");
}
const body = await readJsonBody(req);
return handleMaintenanceUpdate(res, auth.user, body);
}
if (method === "PUT" && url.pathname === "/v1/admin/branding/logo") {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin"])) {
return sendError(res, 403, "auth.forbidden", "Nur Admin");
}
const body = await readJsonBody(req);
return handleAdminBrandingLogoUpdate(res, auth.user, body);
}
if (method === "DELETE" && url.pathname === "/v1/admin/branding/logo") {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin"])) {
return sendError(res, 403, "auth.forbidden", "Nur Admin");
}
const theme = String(url.searchParams.get("theme") || "").toLowerCase();
return handleAdminBrandingLogoDelete(res, auth.user, theme);
}
if (method === "GET" && url.pathname.match(/^\/v1\/plugins\/[^/]+\/settings-schema$/)) {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin"])) {
return sendError(res, 403, "auth.forbidden", "Nur Admin");
}
return handlePluginSettingsSchema(res, url.pathname);
}
if (method === "GET" && url.pathname.match(/^\/v1\/plugins\/[^/]+\/settings$/)) {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin"])) {
return sendError(res, 403, "auth.forbidden", "Nur Admin");
}
return handlePluginSettingsGet(res, url.pathname);
}
if (method === "PUT" && url.pathname.match(/^\/v1\/plugins\/[^/]+\/settings$/)) {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin"])) {
return sendError(res, 403, "auth.forbidden", "Nur Admin");
}
const body = await readJsonBody(req);
return handlePluginSettingsPut(res, auth.user, url.pathname, body);
}
if (method === "POST" && url.pathname.match(/^\/v1\/plugins\/[^/]+\/(enable|disable)$/)) {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin"])) {
return sendError(res, 403, "auth.forbidden", "Nur Admin");
}
return handlePluginToggle(req, res, auth.user, url.pathname);
}
if (method === "PUT" && url.pathname.startsWith("/v1/admin/capabilities/") && url.pathname.endsWith("/provider")) {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin"])) {
return sendError(res, 403, "auth.forbidden", "Nur Admin");
}
const body = await readJsonBody(req);
return handleCapabilityProviderSwitch(res, auth.user, url.pathname, body);
}
if (method === "GET" && url.pathname === "/v1/events/stream") {
return handleEventStream(req, res, url);
}
if (method === "GET" || method === "HEAD") {
return serveStaticFile(rootDir, req, res, url.pathname);
}
return sendError(res, 404, "route.not_found", "Nicht gefunden");
} catch (error) {
if (error && error.code === "INVALID_JSON") {
return sendError(res, 400, "request.invalid_json", "Ungueltiges JSON");
}
console.error("Unhandled request error", error);
return sendError(res, 500, "server.error", "Interner Fehler");
}
}
async function handleRequestAccess(req, res, body) {
const email = normalizeEmail(body && body.email);
const requestedMethod = typeof (body && body.method) === "string" ? body.method.trim() : "";
if (!isValidEmail(email)) {
return sendError(res, 400, "auth.invalid_email", "Bitte gueltige E-Mail angeben");
}
let user = runtime.users.find((entry) => entry.email === email);
if (runtime.systemState.maintenanceMode) {
const canBypass = (user && user.role === "admin") || config.adminEmails.has(email);
if (!canBypass) {
return sendError(res, 503, "auth.maintenance", runtime.systemState.maintenanceMessage);
}
}
const nowIso = new Date().toISOString();
if (!user) {
const role = runtime.users.length === 0 || config.adminEmails.has(email)
? "admin"
: (config.approverEmails.has(email) ? "approver" : "operator");
user = {
id: crypto.randomUUID(),
email,
role,
status: "pending_verification",
accountType: isPrimaryDomainEmail(email) ? "primary-domain" : "external-domain",
enabledAuthMethods: listPublicAuthMethods().map((entry) => entry.id),
primaryAuthMethod: preferredAuthMethodId(listPublicAuthMethods()) || null,
preferredLanguage: "de",
createdAt: nowIso,
emailVerifiedAt: null,
approvedAt: null,
deniedAt: null
};
runtime.users.push(user);
await writeJson(files.users, runtime.users);
await appendAudit("auth.register_email", user, { accountType: user.accountType });
}
if (user.status === "denied") {
return sendError(res, 403, "auth.access_denied", "Kein Zugriff. Bitte Freigabe anfordern.", {
requestApprovalUrl: `${publicBaseUrlFor(req)}/login?requestApproval=1&email=${encodeURIComponent(email)}`
});
}
const selectedMethod = resolveAuthMethodForUser(user, requestedMethod);
if (!selectedMethod) {
const token = await issueEmailToken(user.id, user.status === "active" ? "login" : "verify");
const actionPath = user.status === "active" ? "/login?loginToken=" : "/login?verifyToken=";
const link = `${publicBaseUrlFor(req)}${actionPath}${encodeURIComponent(token)}`;
const message = buildAuthEmailMessage(req, {
user,
type: "link",
subject: user.status === "active" ? "ARCG Login-Link" : "ARCG E-Mail bestaetigen",
text: user.status === "active"
? `Dein Login-Link: ${link}`
: `Bitte E-Mail bestaetigen: ${link}`,
actionLink: link,
actionLabel: user.status === "active" ? "Jetzt einloggen" : "E-Mail bestaetigen"
});
await sendEmailMessage(
email,
message.subject,
message.text,
message.html
);
await appendAudit("auth.request_access", user, { status: user.status, method: "fallback-mail" });
return sendJson(res, 200, {
ok: true,
method: "smtp-link",
challengeType: "link",
challengeHint: "Fallback per Mail aktiv",
message: user.status === "active"
? "Login-Link wurde per E-Mail versendet."
: "Bestaetigungslink wurde per E-Mail versendet.",
domainAllowed: isPrimaryDomainEmail(email),
requestApprovalHint: !isPrimaryDomainEmail(email)
? "Adresse ausserhalb der Hauptdomain: Nach Bestaetigung wird eine Freigabe angefordert."
: null
});
}
let challengeType = selectedMethod.type;
let challengeHint = null;
if (selectedMethod.type === "link") {
const token = await issueEmailToken(user.id, user.status === "active" ? "login" : "verify");
const actionPath = user.status === "active" ? "/login?loginToken=" : "/login?verifyToken=";
const link = `${publicBaseUrlFor(req)}${actionPath}${encodeURIComponent(token)}`;
const message = buildAuthEmailMessage(req, {
user,
type: "link",
subject: user.status === "active" ? "ARCG Login-Link" : "ARCG E-Mail bestaetigen",
text: user.status === "active"
? `Dein Login-Link: ${link}`
: `Bitte E-Mail bestaetigen: ${link}`,
actionLink: link,
actionLabel: user.status === "active" ? "Jetzt einloggen" : "E-Mail bestaetigen"
});
await dispatchAuthChallenge(req, user, selectedMethod, {
type: "link",
subject: message.subject,
text: message.text,
html: message.html,
token,
link
});
} else if (selectedMethod.type === "otp") {
const otpCode = await issueOtpChallenge(user.id, user.status === "active" ? "login" : "verify");
challengeHint = `Code wurde ueber ${selectedMethod.label} gesendet.`;
const message = buildAuthEmailMessage(req, {
user,
type: "otp",
subject: user.status === "active" ? "ARCG Login-Code" : "ARCG Bestaetigungs-Code",
text: `Dein Code lautet: ${otpCode}`,
code: otpCode
});
await dispatchAuthChallenge(req, user, selectedMethod, {
type: "otp",
subject: message.subject,
text: message.text,
html: message.html,
code: otpCode
});
} else {
return sendError(res, 400, "auth.method_invalid", "Unbekannte Bestaetigungsart");
}
await appendAudit("auth.request_access", user, { status: user.status });
return sendJson(res, 200, {
ok: true,
method: selectedMethod.id,
challengeType,
challengeHint,
message: challengeType === "otp"
? "Code wurde versendet."
: user.status === "active"
? "Login-Link wurde per E-Mail versendet."
: "Bestaetigungslink wurde per E-Mail versendet.",
domainAllowed: isPrimaryDomainEmail(email),
requestApprovalHint: !isPrimaryDomainEmail(email)
? "Adresse ausserhalb der Hauptdomain: Nach Bestaetigung wird eine Freigabe angefordert."
: null
});
}
async function handleVerifyEmail(req, res, body) {
const token = typeof (body && body.token) === "string" ? body.token : "";
const otpEmail = normalizeEmail(body && body.email);
const otpCode = typeof (body && body.code) === "string" ? body.code.trim() : "";
let verification = null;
if (token) {
verification = consumeEmailToken(token);
} else if (otpEmail && otpCode) {
const otpUser = runtime.users.find((entry) => entry.email === otpEmail);
if (!otpUser) {
return sendError(res, 404, "auth.user_not_found", "Benutzer nicht gefunden");
}
verification = consumeOtpChallenge(otpUser.id, otpCode);
} else {
return sendError(res, 400, "auth.token_missing", "Token oder OTP-Code fehlt");
}
if (!verification.ok) {
return sendError(res, 400, "auth.token_invalid", verification.message || "Token ungueltig");
}
const user = runtime.users.find((entry) => entry.id === verification.userId);
if (!user) {
return sendError(res, 404, "auth.user_not_found", "Benutzer nicht gefunden");
}
if (verification.purpose === "verify") {
user.emailVerifiedAt = new Date().toISOString();
if (user.accountType === "primary-domain" || user.role === "admin") {
user.status = "active";
user.approvedAt = new Date().toISOString();
user.deniedAt = null;
await writeJson(files.users, runtime.users);
await appendAudit("auth.verified_auto_approved", user, null);
} else {
user.status = "pending_approval";
user.deniedAt = null;
await writeJson(files.users, runtime.users);
await createApprovalRequest(user, req);
await appendAudit("auth.verified_pending_approval", user, null);
return sendJson(res, 200, {
ok: true,
verified: true,
approved: false,
message: "E-Mail bestaetigt. Kein direkter Zugriff fuer diese Domain. Freigabe wurde angefordert."
});
}
}
if (runtime.systemState.maintenanceMode && user.role !== "admin") {
return sendError(res, 503, "auth.maintenance", runtime.systemState.maintenanceMessage);
}
if (user.status !== "active") {
return sendError(res, 403, "auth.not_approved", "Benutzer ist noch nicht freigegeben");
}
const tokens = await issueTokenPair(user, req);
await appendAudit("auth.login", user, { sid: tokens.sid, via: verification.purpose });
return sendJson(res, 200, {
ok: true,
user: sanitizeUser(user),
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresInSec: config.accessTokenTtlSec
});
}
async function handleRequestApproval(req, res, body) {
const email = normalizeEmail(body && body.email);
if (!isValidEmail(email)) {
return sendError(res, 400, "auth.invalid_email", "Bitte gueltige E-Mail angeben");
}
const user = runtime.users.find((entry) => entry.email === email);
if (!user) {
return sendError(res, 404, "auth.user_not_found", "Benutzer nicht gefunden");
}
if (user.accountType === "primary-domain") {
return sendError(res, 409, "approval.not_needed", "Fuer diese Domain ist keine Freigabe erforderlich");
}
if (!user.emailVerifiedAt) {
return sendError(res, 409, "approval.email_not_verified", "Bitte zuerst E-Mail bestaetigen");
}
user.status = "pending_approval";
user.deniedAt = null;
await writeJson(files.users, runtime.users);
await createApprovalRequest(user, req);
await appendAudit("auth.request_approval", user, null);
return sendJson(res, 200, { ok: true, message: "Freigabe wurde angefordert" });
}
async function handleRefresh(req, res, body) {
const refreshToken = typeof (body && body.refreshToken) === "string" ? body.refreshToken : "";
if (!refreshToken) {
return sendError(res, 400, "auth.refresh_missing", "refreshToken fehlt");
}
const verified = verifyJwt(refreshToken, "refresh");
if (!verified.ok) {
return sendError(res, 401, "auth.invalid_refresh", "Refresh Token ungueltig");
}
const payload = verified.payload;
const user = runtime.users.find((entry) => entry.id === payload.sub);
if (!user) {
return sendError(res, 401, "auth.invalid_refresh", "Refresh Token ungueltig");
}
if (runtime.systemState.maintenanceMode && user.role !== "admin") {
return sendError(res, 503, "auth.maintenance", runtime.systemState.maintenanceMessage);
}
if (user.status !== "active") {
return sendError(res, 403, "auth.not_approved", "Benutzer ist nicht freigegeben");
}
const tokenVersion = runtime.authState.tokenVersionByUser[user.id] || 0;
if (payload.tv !== tokenVersion) {
return sendError(res, 401, "auth.invalid_refresh", "Refresh Token ungueltig");
}
const tokenHash = sha256(refreshToken);
const record = runtime.authState.refreshTokens.find((entry) => entry.id === payload.rid);
if (!record || record.tokenHash !== tokenHash) {
return sendError(res, 401, "auth.invalid_refresh", "Refresh Token ungueltig");
}
if (record.revokedAt) {
await revokeSessionFamily(record.sid);
await appendAudit("auth.refresh.reuse_detected", user, { sid: record.sid, rid: record.id });
return sendError(res, 401, "auth.refresh_reuse", "Refresh Token bereits verwendet");
}
if (Date.now() > record.expiresAtMs) {
return sendError(res, 401, "auth.refresh_expired", "Refresh Token abgelaufen");
}
record.revokedAt = new Date().toISOString();
const next = await issueTokenPair(user, req, { sid: record.sid, rotatedFrom: record.id });
await saveAuthState();
await appendAudit("auth.refresh", user, { sid: record.sid, rid: next.rid });
return sendJson(res, 200, {
ok: true,
user: sanitizeUser(user),
accessToken: next.accessToken,
refreshToken: next.refreshToken,
expiresInSec: config.accessTokenTtlSec
});
}
async function handleLogout(res, body) {
const refreshToken = typeof (body && body.refreshToken) === "string" ? body.refreshToken : "";
if (!refreshToken) {
return sendJson(res, 200, { ok: true });
}
const verified = verifyJwt(refreshToken, "refresh");
if (!verified.ok) {
return sendJson(res, 200, { ok: true });
}
const record = runtime.authState.refreshTokens.find((entry) => entry.id === verified.payload.rid);
if (record && !record.revokedAt) {
record.revokedAt = new Date().toISOString();
await saveAuthState();
}
return sendJson(res, 200, { ok: true });
}
async function handleLogoutAll(res, user) {
runtime.authState.tokenVersionByUser[user.id] = (runtime.authState.tokenVersionByUser[user.id] || 0) + 1;
for (const token of runtime.authState.refreshTokens) {
if (token.userId === user.id && !token.revokedAt) {
token.revokedAt = new Date().toISOString();
}
}
await saveAuthState();
await appendAudit("auth.logout_all", user, null);
return sendJson(res, 200, { ok: true });
}
async function handleAuthSessionDiag(res, user, payload) {
if (!hasRole(user, ["admin"])) {
return sendError(res, 403, "auth.forbidden", "Nur Admin");
}
const nowMs = Date.now();
const nowSec = Math.floor(nowMs / 1000);
const expSec = Number(payload && payload.exp ? payload.exp : 0);
const tokenRemainingSec = expSec > 0 ? Math.max(0, expSec - nowSec) : 0;
const ttlDetails = getEffectiveAccessTokenTtlDetails(user, nowSec);
return sendJson(res, 200, {
ok: true,
token: {
expiresAt: expSec > 0 ? new Date(expSec * 1000).toISOString() : null,
remainingSec: tokenRemainingSec
},
config: {
configuredAccessTokenTtlSec: Number.isFinite(Number(config.accessTokenTtlSec)) ? Math.floor(Number(config.accessTokenTtlSec)) : null,
minimumAccessTokenTtlSec: ttlDetails.minimumTtlSec,
effectiveNextIssueTtlSec: ttlDetails.effectiveTtlSec
},
station: {
isInUse: Boolean(runtime.station && runtime.station.isInUse),
activeByUserId: runtime.station && runtime.station.activeByUserId ? String(runtime.station.activeByUserId) : null,
endsAt: runtime.station && runtime.station.endsAt ? runtime.station.endsAt : null,
ownerSessionBoostApplied: Boolean(ttlDetails.ownerSessionBoostApplied)
}
});
}
async function handleAdminUserRoleUpdate(res, actor, pathname, body) {
const match = pathname.match(/^\/v1\/admin\/users\/([^/]+)\/role$/);
if (!match) {
return sendError(res, 404, "user.not_found", "Benutzer nicht gefunden");
}
const userId = decodeURIComponent(match[1]);
const role = typeof (body && body.role) === "string" ? body.role : "";
if (!["admin", "approver", "operator"].includes(role)) {
return sendError(res, 400, "user.role.invalid", "Rolle ungueltig");
}
const user = runtime.users.find((entry) => entry.id === userId);
if (!user) {
return sendError(res, 404, "user.not_found", "Benutzer nicht gefunden");
}
if (config.adminEmails.has(user.email) && role !== "admin") {
return sendError(res, 409, "user.role.locked", "Konfigurierter Admin kann nicht herabgestuft werden");
}
user.role = role;
if (user.status !== "active") {
user.status = "active";
user.approvedAt = new Date().toISOString();
}
await writeJson(files.users, runtime.users);
await appendAudit("admin.user.role.update", actor, { userId, email: user.email, role });
return sendJson(res, 200, { ok: true, user: sanitizeUser(user) });
}
async function handleSelfAuthMethodUpdate(res, user, body) {
const primaryMethod = typeof (body && body.primaryMethod) === "string" ? body.primaryMethod : "";
if (!primaryMethod) {
return sendError(res, 400, "user.auth_methods.invalid", "primaryMethod erforderlich");
}
const availableIds = listPublicAuthMethods().map((entry) => entry.id);
if (!availableIds.includes(primaryMethod)) {
return sendError(res, 400, "user.auth_methods.invalid", "Methode nicht verfuegbar");
}
const enabledMethods = Array.isArray(user.enabledAuthMethods) ? user.enabledAuthMethods : [];
if (!enabledMethods.includes(primaryMethod)) {
return sendError(res, 400, "user.auth_methods.invalid", "Methode ist fuer Benutzer nicht freigeschaltet");
}
user.primaryAuthMethod = primaryMethod;
await writeJson(files.users, runtime.users);
await appendAudit("user.auth_method.update", user, { primaryMethod });
return sendJson(res, 200, { ok: true, user: sanitizeUser(user) });
}
async function handleSelfLanguageUpdate(res, user, body) {
const preferredLanguage = typeof (body && body.preferredLanguage) === "string"
? body.preferredLanguage.trim().toLowerCase()
: "";
if (!preferredLanguage) {
return sendError(res, 400, "user.language.invalid", "preferredLanguage erforderlich");
}
const allowed = new Set(["de", "en"]);
if (!allowed.has(preferredLanguage)) {
return sendError(res, 400, "user.language.invalid", "Sprache nicht verfuegbar");
}
user.preferredLanguage = preferredLanguage;
await writeJson(files.users, runtime.users);
await appendAudit("user.language.update", user, { preferredLanguage });
return sendJson(res, 200, { ok: true, user: sanitizeUser(user) });
}
async function handleAdminUserAuthMethodsUpdate(res, actor, pathname, body) {
const match = pathname.match(/^\/v1\/admin\/users\/([^/]+)\/auth-methods$/);
if (!match) {
return sendError(res, 404, "user.not_found", "Benutzer nicht gefunden");
}
const userId = decodeURIComponent(match[1]);
const user = runtime.users.find((entry) => entry.id === userId);
if (!user) {
return sendError(res, 404, "user.not_found", "Benutzer nicht gefunden");
}
const availableIds = listPublicAuthMethods().map((entry) => entry.id);
const enabledMethods = Array.isArray(body && body.enabledMethods)
? body.enabledMethods.filter((entry) => typeof entry === "string" && availableIds.includes(entry))
: [];
if (enabledMethods.length === 0) {
return sendError(res, 400, "user.auth_methods.invalid", "Mindestens eine Bestaetigungsart muss aktiv sein");
}
const primaryMethod = typeof (body && body.primaryMethod) === "string" && enabledMethods.includes(body.primaryMethod)
? body.primaryMethod
: enabledMethods[0];
user.enabledAuthMethods = Array.from(new Set(enabledMethods));
user.primaryAuthMethod = primaryMethod;
await writeJson(files.users, runtime.users);
await appendAudit("admin.user.auth_methods.update", actor, {
userId: user.id,
email: user.email,
enabledMethods: user.enabledAuthMethods,
primaryMethod: user.primaryAuthMethod
});
return sendJson(res, 200, { ok: true, user: sanitizeUser(user) });
}
async function handleApprovalDecision(res, actor, pathname) {
const match = pathname.match(/^\/v1\/approvals\/([^/]+)\/(approve|reject)$/);
if (!match) {
return sendError(res, 404, "approval.not_found", "Freigabe nicht gefunden");
}
const approvalId = decodeURIComponent(match[1]);
const action = match[2];
const approval = runtime.approvalRequests.find((entry) => entry.id === approvalId);
if (!approval) {
return sendError(res, 404, "approval.not_found", "Freigabe nicht gefunden");
}
const user = runtime.users.find((entry) => entry.id === approval.userId);
if (!user) {
return sendError(res, 404, "user.not_found", "Benutzer nicht gefunden");
}
approval.updatedAt = new Date().toISOString();
approval.updatedBy = actor.email;
approval.status = action === "approve" ? "approved" : "rejected";
approval.decidedAt = approval.updatedAt;
approval.decidedBy = actor.email;
if (action === "approve") {
user.status = "active";
user.approvedAt = approval.updatedAt;
user.deniedAt = null;
} else {
user.status = "denied";
user.deniedAt = approval.updatedAt;
}
await writeJson(files.users, runtime.users);
await saveApprovalRequests();
await appendAudit(`approval.${action}`, actor, { approvalId, userId: user.id, email: user.email });
broadcastEvent("approval.status.changed", { approvalId, status: approval.status, userId: user.id, email: user.email });
await sendEmailMessage(
user.email,
action === "approve" ? "ARCG Zugriff freigegeben" : "ARCG Zugriff abgelehnt",
action === "approve"
? "Dein Zugriff wurde freigegeben. Du kannst jetzt einen Login-Link anfordern."
: "Dein Zugriff wurde abgelehnt."
);
const approvalView = listApprovalsView().find((entry) => entry.id === approval.id) || approval;
return sendJson(res, 200, { ok: true, approval: approvalView, user: sanitizeUser(user) });
}
function listApprovalsView() {
return runtime.approvalRequests.map((approval) => {
const user = runtime.users.find((entry) => entry.id === approval.userId);
return {
...approval,
userStatus: user ? user.status : "unknown",
userRole: user ? user.role : "unknown"
};
});
}
async function handleMaintenanceUpdate(res, actor, body) {
const enabled = Boolean(body && body.enabled);
const message = typeof (body && body.message) === "string" && body.message.trim()
? body.message.trim()
: "Wartungsmodus aktiv. Login ist derzeit deaktiviert.";
runtime.systemState.maintenanceMode = enabled;
runtime.systemState.maintenanceMessage = message;
runtime.systemState.updatedAt = new Date().toISOString();
await saveSystemState();
await appendAudit("admin.maintenance.update", actor, { enabled, message });
if (enabled) {
await forceLogoutAllUsers();
broadcastEvent("system.maintenance.enabled", { message });
} else {
broadcastEvent("system.maintenance.disabled", { message });
}
return sendJson(res, 200, {
ok: true,
maintenanceMode: runtime.systemState.maintenanceMode,
maintenanceMessage: runtime.systemState.maintenanceMessage,
branding: runtime.systemState.branding,
updatedAt: runtime.systemState.updatedAt
});
}
async function handleAdminBrandingLogoUpdate(res, actor, body) {
const theme = typeof (body && body.theme) === "string" ? body.theme.trim().toLowerCase() : "";
if (theme !== "light" && theme !== "dark") {
return sendError(res, 400, "branding.theme.invalid", "Theme muss 'light' oder 'dark' sein");
}
const dataUrl = typeof (body && body.dataUrl) === "string" ? body.dataUrl.trim() : "";
if (!dataUrl) {
return sendError(res, 400, "branding.data.missing", "Logo-Bild fehlt");
}
const parsed = parseImageDataUrl(dataUrl);
if (!parsed.ok) {
return sendError(res, 400, "branding.data.invalid", parsed.message || "Ungueltiges Bildformat");
}
if (parsed.buffer.length > 2 * 1024 * 1024) {
return sendError(res, 413, "branding.data.too_large", "Bild ist zu gross (max 2MB)");
}
const uploadsDir = config.brandingUploadsDir;
await fsp.mkdir(uploadsDir, { recursive: true });
const fileName = `logo-${theme}.${parsed.ext}`;
const filePath = path.join(uploadsDir, fileName);
await fsp.writeFile(filePath, parsed.buffer);
runtime.systemState = normalizeSystemState({
...runtime.systemState,
branding: {
...runtime.systemState.branding,
...(theme === "light" ? { logoLightUrl: `/uploads/${fileName}` } : { logoDarkUrl: `/uploads/${fileName}` })
},
updatedAt: new Date().toISOString()
});
await saveSystemState();
await appendAudit("admin.branding.logo.update", actor, {
theme,
fileName,
bytes: parsed.buffer.length
});
broadcastEvent("branding.updated", { theme, branding: runtime.systemState.branding });
return sendJson(res, 200, {
ok: true,
branding: runtime.systemState.branding,
updatedAt: runtime.systemState.updatedAt
});
}
async function handleAdminBrandingLogoDelete(res, actor, theme) {
if (theme !== "light" && theme !== "dark") {
return sendError(res, 400, "branding.theme.invalid", "Theme muss 'light' oder 'dark' sein");
}
const key = theme === "light" ? "logoLightUrl" : "logoDarkUrl";
const currentUrl = runtime.systemState.branding && runtime.systemState.branding[key];
await removeBrandingFileIfExists(currentUrl);
runtime.systemState = normalizeSystemState({
...runtime.systemState,
branding: {
...runtime.systemState.branding,
[key]: null
},
updatedAt: new Date().toISOString()
});
await saveSystemState();
await appendAudit("admin.branding.logo.delete", actor, { theme });
broadcastEvent("branding.updated", { theme, branding: runtime.systemState.branding });
return sendJson(res, 200, {
ok: true,
branding: runtime.systemState.branding,
updatedAt: runtime.systemState.updatedAt
});
}
async function removeBrandingFileIfExists(urlPath) {
if (!urlPath || typeof urlPath !== "string") {
return;
}
const clean = urlPath.split("?")[0];
if (!clean.startsWith("/uploads/")) {
return;
}
const publicDir = path.resolve(config.brandingUploadsDir);
const relativeUploadPath = clean.replace(/^\/uploads\//, "");
const safePath = path
.normalize(relativeUploadPath)
.replace(/^[/\\]+/, "")
.replace(/^([.][.][/\\])+/, "");
const fullPath = path.resolve(publicDir, safePath);
const resolvedPublicDir = path.resolve(publicDir);
if (fullPath !== resolvedPublicDir && !fullPath.startsWith(resolvedPublicDir + path.sep)) {
return;
}
try {
await fsp.unlink(fullPath);
} catch {
// ignore missing file
}
}
async function reconcileBrandingFromUploads() {
const light = runtime.systemState.branding && runtime.systemState.branding.logoLightUrl
? runtime.systemState.branding.logoLightUrl
: await findExistingLogoUrl("light");
const dark = runtime.systemState.branding && runtime.systemState.branding.logoDarkUrl
? runtime.systemState.branding.logoDarkUrl
: await findExistingLogoUrl("dark");
if (light === runtime.systemState.branding.logoLightUrl && dark === runtime.systemState.branding.logoDarkUrl) {
return;
}
runtime.systemState = normalizeSystemState({
...runtime.systemState,
branding: {
...runtime.systemState.branding,
logoLightUrl: light,
logoDarkUrl: dark
},
updatedAt: runtime.systemState.updatedAt || new Date().toISOString()
});
await saveSystemState();
}
async function findExistingLogoUrl(theme) {
const exts = ["png", "jpg", "jpeg", "svg", "webp"];
for (const ext of exts) {
const fileName = `logo-${theme}.${ext}`;
const fullPath = path.join(config.brandingUploadsDir, fileName);
try {
await fsp.access(fullPath, fs.constants.R_OK);
return `/uploads/${fileName}`;
} catch {
// try next extension
}
}
return null;
}
function parseImageDataUrl(value) {
const match = /^data:(image\/(png|jpeg|jpg|svg\+xml|webp));base64,([A-Za-z0-9+/=\s]+)$/i.exec(value);
if (!match) {
return { ok: false, message: "Nur PNG/JPEG/SVG/WEBP als Data-URL erlaubt" };
}
const mime = match[1].toLowerCase();
const extMap = {
"image/png": "png",
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/svg+xml": "svg",
"image/webp": "webp"
};
const ext = extMap[mime];
if (!ext) {
return { ok: false, message: "Dateiformat nicht unterstuetzt" };
}
try {
const base64 = match[3].replace(/\s+/g, "");
const buffer = Buffer.from(base64, "base64");
if (!buffer.length) {
return { ok: false, message: "Leeres Bild" };
}
return { ok: true, mime, ext, buffer };
} catch {
return { ok: false, message: "Bilddaten konnten nicht gelesen werden" };
}
}
function normalizeSystemState(state) {
const input = state && typeof state === "object" ? state : {};
const branding = input.branding && typeof input.branding === "object" ? input.branding : {};
return {
maintenanceMode: Boolean(input.maintenanceMode),
maintenanceMessage: typeof input.maintenanceMessage === "string" && input.maintenanceMessage.trim()
? input.maintenanceMessage
: "Wartungsmodus aktiv. Login ist derzeit deaktiviert.",
branding: {
logoLightUrl: typeof branding.logoLightUrl === "string" && branding.logoLightUrl.trim() ? branding.logoLightUrl : null,
logoDarkUrl: typeof branding.logoDarkUrl === "string" && branding.logoDarkUrl.trim() ? branding.logoDarkUrl : null
},
updatedAt: input.updatedAt || null
};
}
async function migratePluginSettingsDefaults() {
runtime.pluginState.settings = runtime.pluginState.settings && typeof runtime.pluginState.settings === "object"
? runtime.pluginState.settings
: {};
const dataDir = config.dataDir;
let changed = false;
changed = ensureVswrNativeSettings(dataDir) || changed;
changed = ensureVswrNanoVnaSettings(dataDir) || changed;
changed = ensureVswrReportReaderSettings(dataDir) || changed;
changed = ensureOpenWebRxBandmapSettings() || changed;
changed = ensureMicrohamSettings() || changed;
if (changed) {
await savePluginState();
}
}
function ensureOpenWebRxBandmapSettings() {
const pluginId = "rms.openwebrx.bandmap";
const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object"
? { ...runtime.pluginState.settings[pluginId] }
: {};
const next = { ...current };
const syncFromEnv = boolFromEnv("OPENWEBRX_BANDMAP_SYNC_SETTINGS_FROM_ENV", config.execMode === "prod");
if (syncFromEnv) {
next.csvPath = String(process.env.OPENWEBRX_BANDMAP_CSV_PATH || "").trim();
next.configFilePath = String(process.env.OPENWEBRX_CONFIG_PATH || "").trim();
next.setCommandTemplate = String(process.env.OPENWEBRX_BAND_SET_CMD_TEMPLATE || "").trim();
next.stateFilePath = String(process.env.OPENWEBRX_BAND_STATE_PATH || "").trim();
next.timeoutMs = clampInteger(Number(process.env.OPENWEBRX_BAND_TIMEOUT_MS || 20000), 1000, 120000, 20000);
} else if (!Number.isFinite(Number(next.timeoutMs)) || Number(next.timeoutMs) < 1000) {
next.timeoutMs = 20000;
}
if (JSON.stringify(current) !== JSON.stringify(next)) {
runtime.pluginState.settings[pluginId] = next;
return true;
}
return false;
}
function ensureVswrNativeSettings(dataDir) {
const pluginId = "rms.vswr.native";
const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object"
? { ...runtime.pluginState.settings[pluginId] }
: {};
const next = { ...current };
const defaultReportPath = String(process.env.VSWR_REPORT_JSON_PATH || path.join(dataDir, "vswr", "swr-report.json"));
const defaultOutputBase = String(process.env.VSWR_OUTPUT_BASE_DIR || path.join(dataDir, "vswr", "output"));
const defaultCheckCommand = String(process.env.VSWR_CHECK_CMD || "/opt/remotestation/bin/vswr-check.sh").trim();
const defaultCommand = String(
process.env.NANOVNA_COMMAND_TEMPLATE
|| process.env.VSWR_CHECK_CMD
|| `${defaultCheckCommand} {band} {startHz} {endHz} {bandDir}`
);
next.reportJsonPath = normalizeVswrPath(next.reportJsonPath, dataDir, defaultReportPath);
next.outputBaseDir = normalizeVswrPath(next.outputBaseDir, dataDir, defaultOutputBase);
if (!String(next.nanovnaCommandTemplate || "").trim()) {
next.nanovnaCommandTemplate = defaultCommand;
}
if (String(next.nanovnaCommandTemplate || "").includes("NanoVNASaver.py")) {
next.nanovnaCommandTemplate = defaultCheckCommand;
}
if (!Number.isFinite(Number(next.timeoutMsPerBand)) || Number(next.timeoutMsPerBand) < 1000) {
next.timeoutMsPerBand = Number(process.env.VSWR_TIMEOUT_MS_PER_BAND || 90000);
}
if (!String(next.publicImagesBaseUrl || "").trim()) {
next.publicImagesBaseUrl = String(process.env.VSWR_IMAGES_BASE_URL || deriveVswrImagesBaseUrl());
}
if (JSON.stringify(current) !== JSON.stringify(next)) {
runtime.pluginState.settings[pluginId] = next;
return true;
}
return false;
}
function ensureVswrNanoVnaSettings(dataDir) {
const pluginId = "rms.vswr.nanovna";
const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object"
? { ...runtime.pluginState.settings[pluginId] }
: {};
const next = { ...current };
const defaultMeta = String(process.env.VSWR_METADATA_PATH || path.join(dataDir, "vswr", "metadata.txt"));
next.metadataPath = normalizeVswrPath(next.metadataPath, dataDir, defaultMeta);
if (!String(next.checkCommand || "").trim()) {
next.checkCommand = String(process.env.VSWR_CHECK_CMD || "/opt/remotestation/bin/vswr-check.sh");
}
if (!Number.isFinite(Number(next.timeoutMs)) || Number(next.timeoutMs) < 1000) {
next.timeoutMs = Number(process.env.VSWR_CHECK_TIMEOUT_MS || 240000);
}
if (!Number.isFinite(Number(next.expectedDurationMs)) || Number(next.expectedDurationMs) < 1000) {
next.expectedDurationMs = Number(process.env.SWR_CHECK_DURATION_MS || 54000);
}
if (JSON.stringify(current) !== JSON.stringify(next)) {
runtime.pluginState.settings[pluginId] = next;
return true;
}
return false;
}
function ensureVswrReportReaderSettings(dataDir) {
const pluginId = "rms.vswr.report_reader";
const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object"
? { ...runtime.pluginState.settings[pluginId] }
: {};
const next = { ...current };
const defaultHtml = String(process.env.VSWR_OVERVIEW_HTML_PATH || path.join(dataDir, "vswr", "index.html"));
const defaultMeta = String(process.env.VSWR_METADATA_PATH || path.join(dataDir, "vswr", "metadata.txt"));
const defaultImages = String(process.env.VSWR_IMAGES_DIR_PATH || path.join(dataDir, "vswr", "images"));
next.overviewHtmlPath = normalizeVswrPath(next.overviewHtmlPath, dataDir, defaultHtml);
next.metadataPath = normalizeVswrPath(next.metadataPath, dataDir, defaultMeta);
next.imagesDirPath = normalizeVswrPath(next.imagesDirPath, dataDir, defaultImages);
if (!String(next.publicImagesBaseUrl || "").trim()) {
next.publicImagesBaseUrl = String(process.env.VSWR_IMAGES_BASE_URL || deriveVswrImagesBaseUrl());
}
if (JSON.stringify(current) !== JSON.stringify(next)) {
runtime.pluginState.settings[pluginId] = next;
return true;
}
return false;
}
function ensureMicrohamSettings() {
const pluginId = "rms.microham";
const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object"
? { ...runtime.pluginState.settings[pluginId] }
: {};
const next = { ...current };
const syncFromEnv = boolFromEnv("MICROHAM_SYNC_SETTINGS_FROM_ENV", config.execMode === "prod");
if (!syncFromEnv) {
return false;
}
next.device = String(process.env.MICROHAM_DEVICE || process.env.RMS_MICROHAM_DEV || "/dev/rms-microham-u3").trim() || "/dev/rms-microham-u3";
next.pttCommandsEnabled = boolFromEnv("MICROHAM_PTT_COMMANDS_ENABLED", true);
next.pttDownCommand = String(process.env.MICROHAM_PTT_DOWN_CMD || "").trim();
next.pttUpCommand = String(process.env.MICROHAM_PTT_UP_CMD || "").trim();
next.pttTimeoutMs = clampInteger(Number(process.env.MICROHAM_PTT_TIMEOUT_MS || 5000), 1000, 60000, 5000);
next.pttApplyBandState = boolFromEnv("MICROHAM_PTT_APPLY_BAND_STATE", true);
next.pttRigctlModel = String(process.env.MICROHAM_PTT_RIGCTL_MODEL || "3023").trim() || "3023";
next.pttRigctlBaud = String(process.env.MICROHAM_PTT_RIGCTL_BAUD || "19200").trim() || "19200";
next.pttRigctlSetConf = String(process.env.MICROHAM_PTT_RIGCTL_SETCONF || "rts_state=OFF,dtr_state=OFF").trim() || "rts_state=OFF,dtr_state=OFF";
next.audioEnabled = boolFromEnv("MICROHAM_AUDIO_ENABLED", true);
next.audioAlsaDevice = String(process.env.MICROHAM_AUDIO_ALSA_DEVICE || "plughw:CARD=CODEC,DEV=0").trim() || "plughw:CARD=CODEC,DEV=0";
next.audioInputMime = String(process.env.MICROHAM_AUDIO_INPUT_MIME || "webm").trim().toLowerCase() === "ogg" ? "ogg" : "webm";
next.audioStopOnDisconnect = boolFromEnv("MICROHAM_AUDIO_STOP_ON_DISCONNECT", true);
next.audioChunkMs = clampInteger(Number(process.env.MICROHAM_AUDIO_CHUNK_MS || 100), 40, 2000, 100);
next.audioSessionTimeoutMs = clampInteger(Number(process.env.MICROHAM_AUDIO_SESSION_TIMEOUT_MS || 120000), 1000, 3600000, 120000);
next.audioFfmpegPath = String(process.env.MICROHAM_AUDIO_FFMPEG_PATH || "").trim();
if (next.audioFfmpegExtraArgs === undefined || next.audioFfmpegExtraArgs === null) {
next.audioFfmpegExtraArgs = String(process.env.MICROHAM_AUDIO_FFMPEG_EXTRA_ARGS || "").trim();
}
if (JSON.stringify(current) !== JSON.stringify(next)) {
runtime.pluginState.settings[pluginId] = next;
return true;
}
return false;
}
function boolFromEnv(name, fallback) {
const raw = String(process.env[name] ?? "").trim().toLowerCase();
if (!raw) {
return Boolean(fallback);
}
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
}
function clampInteger(value, min, max, fallback) {
if (!Number.isFinite(Number(value))) {
return fallback;
}
return Math.max(min, Math.min(max, Math.trunc(Number(value))));
}
function normalizeVswrPath(value, dataDir, fallback) {
const raw = String(value || "").trim();
if (!raw) {
return String(fallback || "");
}
if (path.isAbsolute(raw)) {
return raw;
}
const cleaned = raw.replace(/^\.\//, "");
if (cleaned.startsWith("data/")) {
return path.join(dataDir, cleaned.slice("data/".length));
}
return path.join(dataDir, cleaned);
}
function deriveVswrImagesBaseUrl() {
const overview = String(process.env.SWR_OVERVIEW_URL || "").trim();
if (!overview) {
return "";
}
return `${overview.replace(/\/$/, "")}/images`;
}
async function forceLogoutAllUsers() {
for (const user of runtime.users) {
if (user.role === "admin") {
continue;
}
runtime.authState.tokenVersionByUser[user.id] = (runtime.authState.tokenVersionByUser[user.id] || 0) + 1;
}
for (const token of runtime.authState.refreshTokens) {
const tokenUser = runtime.users.find((user) => user.id === token.userId);
if (tokenUser && tokenUser.role === "admin") {
continue;
}
if (!token.revokedAt) {
token.revokedAt = new Date().toISOString();
}
}
await saveAuthState();
}
function listPublicAuthMethods() {
const methods = [];
for (const plugin of runtime.plugins.values()) {
const def = plugin && plugin.manifest && plugin.manifest.authMethod;
if (!def || !def.id || !def.type) {
continue;
}
if (!runtime.pluginState.enabled[plugin.manifest.id]) {
continue;
}
methods.push({
id: String(def.id),
type: String(def.type),
label: String(def.label || def.id),
pluginId: plugin.manifest.id
});
}
methods.sort((a, b) => {
if (a.id === "smtp-link" && b.id !== "smtp-link") return -1;
if (b.id === "smtp-link" && a.id !== "smtp-link") return 1;
return a.id.localeCompare(b.id);
});
return methods;
}
function preferredAuthMethodId(methods) {
const list = Array.isArray(methods) ? methods : [];
if (list.some((entry) => entry.id === "smtp-link")) {
return "smtp-link";
}
return list[0] ? list[0].id : null;
}
function resolveAuthMethodForUser(user, requestedMethodId) {
const all = listPublicAuthMethods();
const enabledSet = new Set(Array.isArray(user.enabledAuthMethods) ? user.enabledAuthMethods : []);
const allowed = all.filter((entry) => enabledSet.has(entry.id));
if (allowed.length === 0) {
return null;
}
if (requestedMethodId) {
return allowed.find((entry) => entry.id === requestedMethodId) || null;
}
if (user.primaryAuthMethod) {
const primary = allowed.find((entry) => entry.id === user.primaryAuthMethod);
if (primary) return primary;
}
return allowed[0] || null;
}
async function dispatchAuthChallenge(req, user, method, payload) {
const plugin = runtime.plugins.get(method.pluginId);
if (!plugin || typeof plugin.instance.execute !== "function") {
throw new Error(`Auth method plugin missing: ${method.id}`);
}
await appendMailOutboxEntry({
at: new Date().toISOString(),
via: method.pluginId,
to: user.email,
subject: String(payload && payload.subject ? payload.subject : ""),
text: String(payload && payload.text ? payload.text : ""),
html: String(payload && payload.html ? payload.html : ""),
challengeType: String(payload && payload.type ? payload.type : method.type || "unknown"),
source: "server-prelog"
});
await plugin.instance.execute("send_challenge", {
methodId: method.id,
methodType: method.type,
user: sanitizeUser(user),
recipient: user.email,
origin: publicBaseUrlFor(req),
payload
}, { user });
}
async function issueOtpChallenge(userId, purpose) {
const code = String(Math.floor(100000 + Math.random() * 900000));
runtime.authState.otpChallenges.push({
id: crypto.randomUUID(),
userId,
purpose,
codeHash: sha256(code),
createdAt: new Date().toISOString(),
expiresAtMs: Date.now() + 10 * 60 * 1000,
consumedAt: null,
failedAttempts: 0
});
pruneOtpChallenges();
await saveAuthState();
return code;
}
function consumeOtpChallenge(userId, code) {
const now = Date.now();
const record = runtime.authState.otpChallenges
.filter((entry) => entry.userId === userId && !entry.consumedAt && now <= entry.expiresAtMs)
.sort((a, b) => b.expiresAtMs - a.expiresAtMs)[0];
if (!record) {
return { ok: false, message: "Kein gueltiger OTP-Code" };
}
if (!safeEquals(record.codeHash, sha256(code))) {
record.failedAttempts = Number(record.failedAttempts || 0) + 1;
if (record.failedAttempts >= 5) {
record.consumedAt = new Date().toISOString();
}
saveAuthState().catch(() => {});
return { ok: false, message: "OTP-Code ungueltig" };
}
record.consumedAt = new Date().toISOString();
saveAuthState().catch(() => {});
return { ok: true, userId, purpose: record.purpose };
}
function pruneOtpChallenges() {
const now = Date.now();
runtime.authState.otpChallenges = runtime.authState.otpChallenges.filter((entry) => {
if (entry.consumedAt && now - new Date(entry.consumedAt).getTime() > 24 * 60 * 60 * 1000) {
return false;
}
return entry.expiresAtMs > now - 24 * 60 * 60 * 1000;
});
}
async function createApprovalRequest(user, req) {
const existing = runtime.approvalRequests.find((entry) => entry.userId === user.id && entry.status === "pending");
if (existing) {
return existing;
}
const approval = {
id: `apr_${crypto.randomUUID()}`,
userId: user.id,
email: user.email,
status: "pending",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
updatedBy: user.email,
decidedAt: null,
decidedBy: null,
notes: `Externes Domain-Konto (${domainForEmail(user.email)})`
};
runtime.approvalRequests.push(approval);
await saveApprovalRequests();
const recipients = runtime.users
.filter((entry) => entry.status === "active" && (entry.role === "admin" || entry.role === "approver"))
.map((entry) => entry.email);
const approvalsUrl = `${publicBaseUrlFor(req)}/rms/freigaben`;
for (const recipient of recipients) {
await sendEmailMessage(
recipient,
"Neue Freigabe-Anfrage",
`Neue Freigabe fuer ${user.email}. Bitte pruefen: ${approvalsUrl}`
);
}
return approval;
}
async function issueEmailToken(userId, purpose) {
const token = crypto.randomBytes(32).toString("base64url");
const ttlMs = purpose === "login" ? 15 * 60 * 1000 : 24 * 60 * 60 * 1000;
runtime.authState.emailTokens.push({
tokenHash: sha256(token),
userId,
purpose,
createdAt: new Date().toISOString(),
expiresAtMs: Date.now() + ttlMs,
consumedAt: null
});
pruneEmailTokens();
await saveAuthState();
return token;
}
function consumeEmailToken(rawToken) {
const tokenHash = sha256(rawToken);
const entry = runtime.authState.emailTokens.find((token) => token.tokenHash === tokenHash);
if (!entry) {
return { ok: false, message: "Token nicht gefunden" };
}
if (entry.consumedAt) {
return { ok: false, message: "Token bereits verwendet" };
}
if (Date.now() > entry.expiresAtMs) {
return { ok: false, message: "Token abgelaufen" };
}
entry.consumedAt = new Date().toISOString();
saveAuthState().catch(() => {});
return { ok: true, userId: entry.userId, purpose: entry.purpose };
}
function pruneEmailTokens() {
const now = Date.now();
runtime.authState.emailTokens = runtime.authState.emailTokens.filter((entry) => {
if (entry.consumedAt && now - new Date(entry.consumedAt).getTime() > 24 * 60 * 60 * 1000) {
return false;
}
return entry.expiresAtMs > now - 24 * 60 * 60 * 1000;
});
}
function publicBaseUrlFor(req) {
if (config.publicBaseUrl) {
return config.publicBaseUrl.replace(/\/+$/, "");
}
const proto = String(req.headers["x-forwarded-proto"] || "http").split(",")[0].trim();
const host = String(req.headers["x-forwarded-host"] || req.headers.host || `localhost:${config.port}`).split(",")[0].trim();
return `${proto}://${host}`;
}
function serializeMailOutboxEntry(entry) {
return `${JSON.stringify(entry)}\n`;
}
async function appendMailOutboxEntry(entry) {
const line = serializeMailOutboxEntry(entry);
await storage.appendText(files.mailOutbox, line);
// With SQLite storage, logs are persisted in DB tables.
// Mirror mail outbox additionally to a plain file for ops visibility.
if (storage && storage.id === "sqlite") {
await fsp.mkdir(path.dirname(files.mailOutbox), { recursive: true });
await fsp.appendFile(files.mailOutbox, line, "utf8");
}
// In dev mode, always mirror into local ./data/mail-outbox.log
// so developers can inspect logs independent of DATA_DIR overrides.
if (config.execMode === "dev") {
const localMirror = path.join(rootDir, "data", "mail-outbox.log");
if (localMirror !== files.mailOutbox) {
await fsp.mkdir(path.dirname(localMirror), { recursive: true });
await fsp.appendFile(localMirror, line, "utf8");
}
}
}
async function ensureMailOutboxInitialized() {
if (!(await storage.exists(files.mailOutbox))) {
await storage.writeText(files.mailOutbox, "");
}
if (storage && storage.id === "sqlite" && !fs.existsSync(files.mailOutbox)) {
await fsp.mkdir(path.dirname(files.mailOutbox), { recursive: true });
await fsp.writeFile(files.mailOutbox, "", "utf8");
}
if (config.execMode === "dev") {
const localMirror = path.join(rootDir, "data", "mail-outbox.log");
if (!fs.existsSync(localMirror)) {
await fsp.mkdir(path.dirname(localMirror), { recursive: true });
await fsp.writeFile(localMirror, "", "utf8");
}
}
}
async function sendEmailMessage(to, subject, text, html = "") {
const smtpMethod = listPublicAuthMethods().find((entry) => entry.id === "smtp-link");
if (smtpMethod) {
const plugin = runtime.plugins.get(smtpMethod.pluginId);
if (plugin && typeof plugin.instance.execute === "function") {
await plugin.instance.execute("send_challenge", {
methodId: smtpMethod.id,
methodType: smtpMethod.type,
user: null,
recipient: to,
origin: config.publicBaseUrl || "",
payload: {
type: "link",
subject,
text,
html: String(html || "")
}
}, { user: null });
return;
}
}
const entry = {
at: new Date().toISOString(),
to,
subject,
text,
html: String(html || "")
};
await appendMailOutboxEntry(entry);
}
function buildAuthEmailMessage(req, options) {
const subject = String(options && options.subject ? options.subject : "ARCG Login");
const text = String(options && options.text ? options.text : "");
const user = options && options.user ? options.user : null;
const actionLink = options && options.actionLink ? String(options.actionLink) : "";
const actionLabel = options && options.actionLabel ? String(options.actionLabel) : "Aktion ausfuehren";
const code = options && options.code ? String(options.code) : "";
const baseUrl = publicBaseUrlFor(req);
const branding = runtime.systemState && runtime.systemState.branding ? runtime.systemState.branding : {};
const logoCandidate = branding.logoLightUrl || branding.logoDarkUrl || "";
const logoUrl = toAbsoluteUrl(baseUrl, logoCandidate);
const stationName = config.stationName || "ARCG RemoteStation";
const safeSubject = escapeHtml(subject);
const safeStationName = escapeHtml(stationName);
const safeTextHtml = escapeHtml(text).replace(/\n/g, "<br>");
const safeActionLabel = escapeHtml(actionLabel);
const safeActionLink = escapeHtml(actionLink);
const safeCode = escapeHtml(code);
const html = [
"<!doctype html>",
'<html lang="de">',
'<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' + safeSubject + "</title></head>",
'<body style="margin:0;padding:0;background:#f5f7fb;color:#10253f;font-family:Arial,Helvetica,sans-serif;">',
'<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="padding:28px 12px;">',
'<tr><td align="center">',
'<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:620px;background:#ffffff;border:1px solid #e3e8ef;border-radius:14px;overflow:hidden;">',
'<tr><td style="padding:20px 24px;border-bottom:1px solid #edf1f6;">',
(logoUrl ? '<img src="' + escapeHtml(logoUrl) + '" alt="Logo" style="max-height:54px;max-width:220px;display:block;margin:0 0 12px 0;">' : ""),
'<div style="font-size:12px;color:#5d6f85;letter-spacing:0.06em;text-transform:uppercase;">ARCG RemoteStation</div>',
'<h1 style="margin:6px 0 0 0;font-size:22px;line-height:1.3;color:#10253f;">' + safeSubject + "</h1>",
"</td></tr>",
'<tr><td style="padding:24px;">',
'<p style="margin:0 0 12px 0;font-size:15px;line-height:1.6;">Hallo' + (user && user.email ? " " + escapeHtml(user.email) : "") + ",</p>",
'<p style="margin:0 0 16px 0;font-size:15px;line-height:1.65;color:#233b57;">' + safeTextHtml + "</p>",
(code ? '<div style="margin:0 0 16px 0;padding:14px 16px;border:1px dashed #9fb1c8;border-radius:10px;background:#f8fbff;"><span style="font-size:12px;color:#5d6f85;display:block;margin-bottom:6px;">Dein Code</span><strong style="font-size:26px;letter-spacing:0.08em;color:#10253f;">' + safeCode + "</strong></div>" : ""),
(actionLink ? '<p style="margin:0 0 18px 0;"><a href="' + safeActionLink + '" style="display:inline-block;background:#0b4fa8;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:8px;font-weight:700;">' + safeActionLabel + "</a></p>" : ""),
(actionLink ? '<p style="margin:0;font-size:12px;line-height:1.5;color:#5d6f85;word-break:break-all;">Falls der Button nicht funktioniert, oeffne diesen Link im Browser:<br><a href="' + safeActionLink + '" style="color:#0b4fa8;">' + safeActionLink + "</a></p>" : ""),
"</td></tr>",
'<tr><td style="padding:16px 24px;background:#f8fafc;border-top:1px solid #edf1f6;font-size:12px;line-height:1.6;color:#5d6f85;">',
"Diese Nachricht wurde automatisch von " + safeStationName + " gesendet.",
"</td></tr>",
"</table>",
"</td></tr>",
"</table>",
"</body>",
"</html>"
].join("");
return { subject, text, html };
}
function toAbsoluteUrl(baseUrl, maybeRelative) {
const value = String(maybeRelative || "").trim();
if (!value) {
return "";
}
if (/^https?:\/\//i.test(value)) {
return value;
}
const base = String(baseUrl || "").trim();
if (!base) {
return value;
}
const baseTrimmed = base.replace(/\/$/, "");
return `${baseTrimmed}/${value.replace(/^\/+/, "")}`;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function domainForEmail(email) {
const at = email.indexOf("@");
return at === -1 ? "" : email.slice(at + 1).toLowerCase();
}
function isPrimaryDomainEmail(email) {
return domainForEmail(email) === config.primaryEmailDomain;
}
function stationUsageDurationMs() {
return Math.max(1, Number(config.stationMaxUsageSec || 3600)) * 1000;
}
function computeLeaseEndIso(startedAtIso) {
const startedMs = new Date(startedAtIso || Date.now()).getTime();
return new Date(startedMs + stationUsageDurationMs()).toISOString();
}
function parseIsoMs(value) {
const ms = Date.parse(String(value || ""));
return Number.isFinite(ms) ? ms : null;
}
function normalizeStationReservations(value, options = {}) {
const slotMs = stationUsageDurationMs();
const nowMs = Number.isFinite(Number(options.nowMs)) ? Number(options.nowMs) : Date.now();
const baseStart = Number.isFinite(Number(options.slotStartBaseMs)) ? Math.floor(Number(options.slotStartBaseMs)) : null;
let cursorMs = baseStart;
const source = Array.isArray(value) ? value : [];
const out = [];
const seenUserIds = new Set();
for (const entry of source) {
if (!entry || typeof entry !== "object") {
continue;
}
const userId = String(entry.userId || "").trim();
const email = String(entry.email || "").trim();
if (!userId || !email || seenUserIds.has(userId)) {
continue;
}
seenUserIds.add(userId);
let fromMs = parseIsoMs(entry.from);
let toMs = parseIsoMs(entry.to);
if (!(Number.isFinite(fromMs) && Number.isFinite(toMs) && toMs > fromMs)) {
if (Number.isFinite(cursorMs)) {
fromMs = cursorMs;
toMs = fromMs + slotMs;
} else {
fromMs = nowMs;
toMs = fromMs + slotMs;
}
}
cursorMs = toMs;
out.push({
userId,
email,
from: new Date(fromMs).toISOString(),
to: new Date(toMs).toISOString(),
createdAt: entry.createdAt ? String(entry.createdAt) : new Date(nowMs).toISOString()
});
}
out.sort((a, b) => {
const aFrom = parseIsoMs(a.from) || 0;
const bFrom = parseIsoMs(b.from) || 0;
return aFrom - bFrom;
});
return out;
}
function removeReservationByUserId(reservations, userId) {
const targetId = String(userId || "");
if (!targetId) {
return { changed: false, reservations: normalizeStationReservations(reservations) };
}
const normalized = normalizeStationReservations(reservations);
const next = normalized.filter((entry) => String(entry.userId) !== targetId);
return {
changed: next.length !== normalized.length,
reservations: next
};
}
function getFirstFutureReservationStartMs(reservations, nowMs) {
let minStart = null;
for (const entry of reservations) {
const fromMs = parseIsoMs(entry.from);
if (!Number.isFinite(fromMs) || fromMs <= nowMs) {
continue;
}
if (minStart === null || fromMs < minStart) {
minStart = fromMs;
}
}
return minStart;
}
function findActiveReservationEntry(reservations, nowMs = Date.now()) {
for (const entry of reservations) {
const fromMs = parseIsoMs(entry.from);
const toMs = parseIsoMs(entry.to);
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) {
continue;
}
if (fromMs <= nowMs && nowMs < toMs) {
return entry;
}
}
return null;
}
function pruneExpiredReservations(reservations, nowMs = Date.now()) {
const next = [];
for (const entry of reservations) {
const toMs = parseIsoMs(entry.to);
if (!Number.isFinite(toMs) || toMs <= nowMs) {
continue;
}
next.push(entry);
}
return next;
}
function getReservationBaseStartMs(nowMs = Date.now()) {
const currentEndsAtMs = parseIsoMs(runtime.station && runtime.station.endsAt);
if (runtime.station && runtime.station.isInUse && Number.isFinite(currentEndsAtMs) && currentEndsAtMs > nowMs) {
return currentEndsAtMs;
}
return nowMs + stationUsageDurationMs();
}
function getNormalizedStationReservations(nowMs = Date.now()) {
let normalized = normalizeStationReservations(runtime.station && runtime.station.reservations, {
nowMs,
slotStartBaseMs: getReservationBaseStartMs(nowMs)
});
normalized = pruneExpiredReservations(normalized, nowMs);
if (runtime.station && runtime.station.isInUse && runtime.station.activeByUserId && runtime.station.activeByEmail) {
const activeEntry = findActiveReservationEntry(normalized, nowMs);
if (!activeEntry || String(activeEntry.userId || "") !== String(runtime.station.activeByUserId || "")) {
const withoutOwner = removeReservationByUserId(normalized, runtime.station.activeByUserId).reservations;
const fromMs = parseIsoMs(runtime.station.startedAt) || nowMs;
let toMs = parseIsoMs(runtime.station.endsAt) || (fromMs + stationUsageDurationMs());
if (toMs <= fromMs) {
toMs = fromMs + 1000;
}
normalized = normalizeStationReservations([
{
userId: String(runtime.station.activeByUserId || ""),
email: String(runtime.station.activeByEmail || "unknown"),
from: new Date(fromMs).toISOString(),
to: new Date(toMs).toISOString(),
createdAt: runtime.station.startedAt || new Date(nowMs).toISOString()
},
...withoutOwner
], {
nowMs,
slotStartBaseMs: getReservationBaseStartMs(nowMs)
});
}
}
return normalized;
}
function getReservationAccessLock(nowMs = Date.now()) {
const reservations = getNormalizedStationReservations(nowMs);
const activeEntry = findActiveReservationEntry(reservations, nowMs);
return {
reservations,
activeEntry,
locked: Boolean(activeEntry)
};
}
function hasStationReservationAccess(user, lockInfo) {
if (!lockInfo || !lockInfo.activeEntry) {
return true;
}
if (user && user.role === "admin") {
return true;
}
return String(lockInfo.activeEntry.userId || "") === String(user && user.id ? user.id : "");
}
function buildCurrentSlotForUser(user, reservations, nowMs = Date.now()) {
const cleaned = removeReservationByUserId(reservations, user.id).reservations;
const nextStartMs = getFirstFutureReservationStartMs(cleaned, nowMs);
let toMs = nowMs + stationUsageDurationMs();
if (Number.isFinite(nextStartMs)) {
toMs = Math.min(toMs, nextStartMs);
}
if (toMs <= nowMs) {
toMs = nowMs + 1000;
}
const current = {
userId: String(user.id || ""),
email: String(user.email || "unknown"),
from: new Date(nowMs).toISOString(),
to: new Date(toMs).toISOString(),
createdAt: new Date(nowMs).toISOString()
};
return normalizeStationReservations([current, ...cleaned], {
nowMs,
slotStartBaseMs: getReservationBaseStartMs(nowMs)
});
}
async function persistStationReservationsIfChanged(nextReservations, meta = {}) {
const currentRaw = Array.isArray(runtime.station && runtime.station.reservations)
? runtime.station.reservations
: [];
const changed = JSON.stringify(currentRaw) !== JSON.stringify(nextReservations);
if (!changed) {
return false;
}
runtime.station = {
...runtime.station,
reservations: nextReservations,
updatedAt: new Date().toISOString(),
lastAction: meta.lastAction || runtime.station.lastAction
};
await writeJson(files.station, runtime.station);
return true;
}
async function autoActivateReservationSlotIfNeeded() {
return withLock(async () => autoActivateReservationSlotIfNeededUnlocked({}));
}
async function autoActivateReservationSlotIfNeededUnlocked(options = {}) {
if (runtime.currentActivationJobId || (runtime.station && runtime.station.isInUse)) {
return false;
}
const excludeUserId = options && options.excludeUserId ? String(options.excludeUserId) : "";
const nowMs = Date.now();
const reservations = getNormalizedStationReservations(nowMs);
await persistStationReservationsIfChanged(reservations, { lastAction: "reserve-sync" });
const activeEntry = findActiveReservationEntry(reservations, nowMs);
if (!activeEntry) {
return false;
}
if (excludeUserId && String(activeEntry.userId || "") === excludeUserId) {
return false;
}
const user = runtime.users.find((entry) => String(entry.id || "") === String(activeEntry.userId || ""));
if (!user) {
return false;
}
try {
await executeCapability("station.activate", "activate", { userEmail: user.email }, { user, skipTxSafety: true });
} catch (error) {
await appendAudit("station.activate.auto_slot.failed", user, {
error: String(error && error.message ? error.message : error)
});
return false;
}
runtime.station = {
...runtime.station,
isInUse: true,
activeByUserId: user.id,
activeByEmail: user.email,
startedAt: activeEntry.from,
endsAt: activeEntry.to,
reservations,
updatedAt: new Date().toISOString(),
lastAction: "activate-auto-slot"
};
await writeJson(files.station, runtime.station);
await syncStationAccessPolicyOwner(user, user.email);
await appendAudit("station.activate.auto_slot", user, {
from: activeEntry.from,
to: activeEntry.to
});
broadcastEvent("station.status.changed", buildStationStatusView());
return true;
}
async function reconcileStationLeaseOnStartup() {
if (!runtime.station) {
return;
}
let changed = false;
const normalizedReservations = getNormalizedStationReservations(Date.now());
const reservationsChanged = !Array.isArray(runtime.station.reservations)
|| JSON.stringify(normalizedReservations) !== JSON.stringify(runtime.station.reservations);
if (reservationsChanged) {
runtime.station.reservations = normalizedReservations;
changed = true;
}
if (!("endsAt" in runtime.station)) {
runtime.station.endsAt = null;
changed = true;
}
if (runtime.station.isInUse && runtime.station.startedAt && !runtime.station.endsAt) {
runtime.station.endsAt = computeLeaseEndIso(runtime.station.startedAt);
changed = true;
}
if (!runtime.station.isInUse && runtime.station.endsAt) {
runtime.station.endsAt = null;
changed = true;
}
if (changed) {
runtime.station.updatedAt = new Date().toISOString();
await writeJson(files.station, runtime.station);
}
await enforceStationLeaseTimeout();
}
async function enforceStationLeaseTimeout() {
if (!runtime.station) {
return;
}
if (!runtime.station.isInUse || runtime.currentActivationJobId) {
await autoActivateReservationSlotIfNeeded();
return;
}
if (!runtime.station.endsAt && runtime.station.startedAt) {
runtime.station.endsAt = computeLeaseEndIso(runtime.station.startedAt);
runtime.station.updatedAt = new Date().toISOString();
await writeJson(files.station, runtime.station);
broadcastEvent("station.status.changed", buildStationStatusView());
}
if (!runtime.station.endsAt) {
return;
}
if (Date.now() < new Date(runtime.station.endsAt).getTime()) {
return;
}
await releaseStationByTimeout();
}
async function releaseStationByTimeout() {
return withLock(async () => {
if (!runtime.station.isInUse || runtime.currentActivationJobId) {
return;
}
const activeEmail = runtime.station.activeByEmail || "unknown";
const activeUser = runtime.users.find((entry) => entry.id === runtime.station.activeByUserId)
|| { id: null, email: activeEmail, role: "operator" };
let deactivateError = null;
try {
await safeShutdownStationSession(activeUser, "lease-timeout");
} catch (error) {
deactivateError = String(error && error.message ? error.message : error);
await appendAudit("station.deactivate.timeout.failed", activeUser, { deactivateError });
return;
}
const keptReservations = getNormalizedStationReservations(Date.now());
runtime.station = {
...runtime.station,
isInUse: false,
activeByUserId: null,
activeByEmail: null,
startedAt: null,
endsAt: null,
reservations: keptReservations,
updatedAt: new Date().toISOString(),
lastAction: deactivateError ? "deactivate-timeout-forced" : "deactivate-timeout"
};
await writeJson(files.station, runtime.station);
await appendAudit("station.deactivate.timeout", activeUser, {
maxUsageSec: config.stationMaxUsageSec,
deactivateError
});
broadcastEvent("station.deactivate.timeout", {
email: activeEmail,
deactivateError,
maxUsageSec: config.stationMaxUsageSec
});
const autoActivated = await autoActivateReservationSlotIfNeededUnlocked({
excludeUserId: activeUser && activeUser.id ? String(activeUser.id) : ""
});
if (!autoActivated) {
broadcastEvent("station.status.changed", buildStationStatusView());
}
});
}
async function handleActivationStart(res, user) {
return withLock(async () => {
const nowMs = Date.now();
const lockInfo = getReservationAccessLock(nowMs);
if (!hasStationReservationAccess(user, lockInfo)) {
return sendError(res, 403, "station.slot_owner_only", "Nur der reservierte Benutzer darf diesen Slot aktivieren/deaktivieren");
}
if (runtime.swrRun && runtime.swrRun.running) {
return sendError(res, 409, "swr.running", "Stationaktivierung ist waehrend laufendem SWR-Check gesperrt");
}
if (runtime.currentActivationJobId) {
const current = runtime.jobs.get(runtime.currentActivationJobId);
if (current && current.startedBy && String(current.startedBy) !== String(user.email || "")) {
return sendError(res, 409, "station.in_use", "Stationaktivierung laeuft bereits fuer einen anderen Benutzer");
}
return sendJson(res, 202, { ok: true, pending: true, job: current });
}
if (!runtime.station.stationOnline) {
return sendError(res, 409, "station.offline", "Station ist offline");
}
if (runtime.station.isInUse) {
return sendError(res, 409, "station.in_use", "Station wird bereits verwendet");
}
const activeReservation = lockInfo.activeEntry;
let reservations = lockInfo.reservations;
if (activeReservation) {
reservations = normalizeStationReservations(reservations.map((entry) => {
if (String(entry.userId) !== String(user.id || "")) {
return entry;
}
return {
...entry,
email: String(user.email || entry.email || "unknown")
};
}), { nowMs, slotStartBaseMs: getReservationBaseStartMs(nowMs) });
} else {
reservations = buildCurrentSlotForUser(user, reservations, nowMs);
}
await persistStationReservationsIfChanged(reservations, { lastAction: "reserve-current" });
const selectedSlot = findActiveReservationEntry(reservations, nowMs);
if (!selectedSlot || String(selectedSlot.userId || "") !== String(user.id || "")) {
return sendError(res, 409, "station.slot_unavailable", "Aktueller Slot ist nicht verfuegbar");
}
let txState = await readTransmitState(user);
if (txState.txActive && shouldAutoDisableTxBeforeActivation()) {
try {
await executeCapability("tx.control", "disableTx", { source: "activation-precheck" }, { user, skipTxSafety: true });
await appendAudit("tx.disable", user, { source: "activation-precheck", auto: true });
txState = await readTransmitState(user);
} catch (error) {
await appendAudit("tx.disable.failed", user, {
source: "activation-precheck",
error: String(error && error.message ? error.message : error)
});
}
}
if (txState.txActive) {
return sendError(res, 409, "tx.switch_locked", "Umschalten ist waehrend aktivem Senden gesperrt", {
...txState,
hint: "TX muss zuerst deaktiviert werden"
});
}
const job = {
id: `job_${crypto.randomUUID()}`,
type: "station.activate",
status: "running",
phase: "swr-check",
percent: 0,
etaSec: null,
startedAt: new Date().toISOString(),
startedBy: user.email,
startedByUserId: user.id,
slotFrom: selectedSlot.from,
slotTo: selectedSlot.to,
finishedAt: null,
error: null
};
runtime.currentActivationJobId = job.id;
runtime.jobs.set(job.id, job);
broadcastEvent("station.activation.started", { job });
appendAudit("station.activate.start", user, { jobId: job.id }).catch(() => {});
runActivationJob(job, user).catch((error) => {
console.error("Activation job failed", error);
});
return sendJson(res, 202, { ok: true, pending: true, job });
});
}
async function runActivationJob(job, user) {
let progressTimer = null;
try {
const expectedDurationMs = Number(getPluginSetting("rms.vswr.nanovna", "expectedDurationMs", process.env.SWR_CHECK_DURATION_MS || 54000));
const startedMs = Date.now();
progressTimer = setInterval(() => {
const elapsed = Date.now() - startedMs;
const pct = Math.max(0, Math.min(99, Math.floor((elapsed / Math.max(1, expectedDurationMs)) * 100)));
job.percent = pct;
job.etaSec = Math.max(0, Math.ceil((expectedDurationMs - elapsed) / 1000));
broadcastEvent("station.activation.progress", {
jobId: job.id,
phase: job.phase,
percent: job.percent,
etaSec: job.etaSec
});
}, 1000);
let swrRunError = null;
let swrReport = null;
const swrToken = await startGlobalSwrRun(user, {
source: "station-activation",
phase: "swr-check",
expectedDurationMs
});
try {
await executeCapability("vswr.run", "runCheck", {}, { user });
swrReport = await buildSwrReportView(user);
broadcastEvent("swr.report.changed", {
source: "station-activation",
generatedAt: swrReport && swrReport.generatedAt ? swrReport.generatedAt : null,
overallStatus: swrReport && swrReport.overallStatus ? swrReport.overallStatus : null
});
} catch (error) {
swrRunError = String(error && error.message ? error.message : error);
await appendAudit("station.activate.swr_failed_continue", user, {
jobId: job.id,
error: swrRunError
});
} finally {
await finishGlobalSwrRun(swrToken, {
status: swrRunError ? "failed" : "succeeded",
error: swrRunError
});
}
job.phase = "switch-to-transceiver";
await executeCapability("station.activate", "activate", { userEmail: user.email }, { user, skipTxSafety: true });
await withLock(async () => {
const nowMs = Date.now();
const startedAt = job.slotFrom || new Date(nowMs).toISOString();
const endsAt = job.slotTo || computeLeaseEndIso(startedAt);
const reservationsAfterActivate = normalizeStationReservations(runtime.station.reservations, {
nowMs,
slotStartBaseMs: getReservationBaseStartMs(nowMs)
});
runtime.station = {
...runtime.station,
isInUse: true,
activeByUserId: user.id,
activeByEmail: user.email,
startedAt,
endsAt,
reservations: reservationsAfterActivate,
updatedAt: new Date().toISOString(),
lastAction: "activate"
};
await writeJson(files.station, runtime.station);
runtime.currentActivationJobId = null;
});
await syncStationAccessPolicyOwner(user, user.email);
job.status = "succeeded";
job.percent = 100;
job.etaSec = 0;
job.finishedAt = new Date().toISOString();
broadcastEvent("station.activation.completed", { jobId: job.id, swrReport });
broadcastEvent("station.status.changed", buildStationStatusView());
await appendAudit("station.activate.done", user, {
jobId: job.id,
swrFailed: Boolean(swrRunError),
swrError: swrRunError
});
} catch (error) {
try {
await withLock(async () => {
runtime.currentActivationJobId = null;
runtime.station = {
...runtime.station,
updatedAt: new Date().toISOString(),
lastAction: "activate-failed"
};
await writeJson(files.station, runtime.station);
});
} catch {
runtime.currentActivationJobId = null;
}
job.status = "failed";
job.finishedAt = new Date().toISOString();
job.error = String(error && error.message ? error.message : error);
broadcastEvent("station.activation.failed", { jobId: job.id, error: job.error });
await appendAudit("station.activate.failed", user, { jobId: job.id, error: job.error });
} finally {
if (progressTimer) {
clearInterval(progressTimer);
}
if (runtime.currentActivationJobId === job.id) {
runtime.currentActivationJobId = null;
}
}
}
async function runSwrCheckAndBuildReport(user) {
const swrToken = await startGlobalSwrRun(user, {
source: "manual",
phase: "swr-check",
expectedDurationMs: getExpectedSWRDurationMs()
});
let runError = null;
try {
const result = await executeCapability("vswr.run", "runCheck", {}, { user });
const report = await buildSwrReportView(user);
return { result, report };
} catch (error) {
runError = error;
throw error;
} finally {
await finishGlobalSwrRun(swrToken, {
status: runError ? "failed" : "succeeded",
error: runError ? String(runError && runError.message ? runError.message : runError) : null
});
}
}
async function handleStationRelease(res, user) {
return withLock(async () => {
const lockInfo = getReservationAccessLock(Date.now());
if (!hasStationReservationAccess(user, lockInfo)) {
return sendError(res, 403, "station.slot_owner_only", "Nur der reservierte Benutzer darf diesen Slot aktivieren/deaktivieren");
}
if (runtime.currentActivationJobId) {
return sendError(res, 423, "station.activation_running", "Waehrend SWR-Check nicht moeglich");
}
if (!runtime.station.isInUse) {
return sendJson(res, 200, { ok: true, status: buildStationStatusView() });
}
if (runtime.station.activeByUserId !== user.id && user.role !== "admin") {
return sendError(res, 403, "station.not_owner", "Nur aktiver Benutzer oder Admin darf freigeben");
}
try {
await safeShutdownStationSession(user, "manual-release");
} catch (error) {
if (error && error.code === "TX_DISABLE_FAILED") {
return sendError(res, 409, "tx.disable_failed", String(error.message || error), error.details || null);
}
throw error;
}
runtime.station = {
...runtime.station,
isInUse: false,
activeByUserId: null,
activeByEmail: null,
startedAt: null,
endsAt: null,
reservations: lockInfo.reservations,
updatedAt: new Date().toISOString(),
lastAction: "deactivate"
};
await writeJson(files.station, runtime.station);
await appendAudit("station.deactivate", user, null);
const autoActivated = await autoActivateReservationSlotIfNeededUnlocked({
excludeUserId: user && user.id ? String(user.id) : ""
});
if (!autoActivated) {
broadcastEvent("station.status.changed", buildStationStatusView());
}
return sendJson(res, 200, { ok: true, status: buildStationStatusView() });
});
}
async function handleReserveNextSlot(res, user) {
return withLock(async () => {
const nowMs = Date.now();
const activation = getCurrentActivationView();
const stationOccupied = Boolean(runtime.station.isInUse || activation.running);
if (!stationOccupied) {
return sendError(res, 409, "station.not_occupied", "Reservierung nur moeglich solange die Station belegt ist");
}
const reservations = getNormalizedStationReservations(nowMs);
const existing = reservations.find((entry) => String(entry.userId) === String(user.id || ""));
if (existing) {
return sendJson(res, 200, { ok: true, alreadyReserved: true, status: buildStationStatusView() });
}
const lastToMs = reservations.reduce((max, entry) => {
const toMs = parseIsoMs(entry.to);
return Number.isFinite(toMs) ? Math.max(max, toMs) : max;
}, 0);
const startMs = lastToMs > 0 ? lastToMs : getReservationBaseStartMs(nowMs);
const endMs = startMs + stationUsageDurationMs();
const nextReservations = [
...reservations,
{
userId: String(user.id || ""),
email: String(user.email || "unknown"),
from: new Date(startMs).toISOString(),
to: new Date(endMs).toISOString(),
createdAt: new Date(nowMs).toISOString()
}
];
runtime.station = {
...runtime.station,
reservations: nextReservations,
updatedAt: new Date().toISOString(),
lastAction: "reserve-next"
};
await writeJson(files.station, runtime.station);
await appendAudit("station.reserve.next", user, { queueLength: nextReservations.length });
broadcastEvent("station.status.changed", buildStationStatusView());
return sendJson(res, 200, { ok: true, status: buildStationStatusView() });
});
}
async function handleDeleteOwnReservation(res, user) {
return withLock(async () => {
const nowMs = Date.now();
const reservations = getNormalizedStationReservations(nowMs);
const ownEntry = reservations.find((entry) => String(entry.userId || "") === String(user.id || ""));
if (!ownEntry) {
return sendError(res, 404, "station.reservation.not_found", "Keine eigene Reservierung gefunden");
}
const removed = removeReservationByUserId(reservations, user.id);
if (!removed.changed) {
return sendError(res, 404, "station.reservation.not_found", "Keine eigene Reservierung gefunden");
}
const ownFromMs = parseIsoMs(ownEntry.from);
const ownToMs = parseIsoMs(ownEntry.to);
const deletingActiveOwnSlot = Number.isFinite(ownFromMs)
&& Number.isFinite(ownToMs)
&& ownFromMs <= nowMs
&& nowMs < ownToMs;
if (deletingActiveOwnSlot && runtime.station.isInUse && String(runtime.station.activeByUserId || "") === String(user.id || "")) {
try {
await safeShutdownStationSession(user, "reservation-delete-current");
} catch (error) {
if (error && error.code === "TX_DISABLE_FAILED") {
return sendError(res, 409, "tx.disable_failed", String(error.message || error), error.details || null);
}
throw error;
}
runtime.station = {
...runtime.station,
isInUse: false,
activeByUserId: null,
activeByEmail: null,
startedAt: null,
endsAt: null,
reservations: removed.reservations,
updatedAt: new Date().toISOString(),
lastAction: "reserve-remove-current"
};
await writeJson(files.station, runtime.station);
await appendAudit("station.reserve.remove", user, { queueLength: removed.reservations.length, currentSlot: true });
const autoActivated = await autoActivateReservationSlotIfNeededUnlocked({
excludeUserId: user && user.id ? String(user.id) : ""
});
if (!autoActivated) {
broadcastEvent("station.status.changed", buildStationStatusView());
}
return sendJson(res, 200, { ok: true, status: buildStationStatusView() });
}
runtime.station = {
...runtime.station,
reservations: removed.reservations,
updatedAt: new Date().toISOString(),
lastAction: "reserve-remove"
};
await writeJson(files.station, runtime.station);
await appendAudit("station.reserve.remove", user, { queueLength: removed.reservations.length });
broadcastEvent("station.status.changed", buildStationStatusView());
return sendJson(res, 200, { ok: true, status: buildStationStatusView() });
});
}
async function safeShutdownStationSession(user, reason) {
if (runtime.pttActive) {
try {
await executeCapability("microham.ptt", "pttUp", {}, { user, skipTxSafety: true });
} catch {
// best effort
}
try {
await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user, skipTxSafety: true, allowPttOverride: true });
} catch {
// best effort
}
runtime.pttActive = false;
}
const txStateBefore = await readTransmitState(user);
if (txStateBefore.txActive) {
try {
await executeCapability("tx.control", "disableTx", { reason }, { user, skipTxSafety: true });
await appendAudit("tx.disable", user, { reason, source: "session-shutdown" });
} catch (error) {
const wrapped = new Error("TX konnte vor Session-Ende nicht deaktiviert werden");
wrapped.code = "TX_DISABLE_FAILED";
wrapped.details = {
reason,
error: String(error && error.message ? error.message : error)
};
throw wrapped;
}
}
await executeCapability("tx.audio", "audioDisconnect", { reason: `session-shutdown:${reason}` }, { user, skipTxSafety: true }).catch(() => {});
await revokeOpenWebRxAccess(user, reason);
await executeCapability("station.deactivate", "deactivate", { userEmail: user.email }, { user });
}
async function revokeOpenWebRxAccess(user, reason) {
const provider = runtime.pluginState.providers["openwebrx.access.issue"];
if (!provider) {
return;
}
try {
await executeCapability("openwebrx.access.issue", "revokeOwner", {
ownerUserId: runtime.station.activeByUserId,
reason
}, { user, skipTxSafety: true });
} catch {
// optional capability
}
try {
await executeCapability("openwebrx.service.control", "serviceStop", { reason }, { user, skipTxSafety: true });
} catch {
// optional capability
}
clearOpenWebRxSession();
await clearStationAccessPolicyOwner(user);
}
async function handleOpenWebRxSessionIssue(res, user) {
if (!hasRole(user, ["operator", "approver", "admin"])) {
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
}
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX nur fuer den aktiven Stationsbenutzer");
}
const hasProvider = runtime.pluginState.providers["openwebrx.access.issue"];
if (!hasProvider) {
return sendError(res, 409, "openwebrx.not_configured", "OpenWebRX Provider nicht konfiguriert");
}
await ensureOpenWebRxSdrPath(user, { force: true });
const result = await executeCapability("openwebrx.access.issue", "issueAccess", {
userId: user.id,
userEmail: user.email,
ownerUserId: runtime.station.activeByUserId,
stationEndsAt: runtime.station.endsAt
}, { user, skipTxSafety: true });
await syncStationAccessPolicyOwner(user, user.email);
try {
await executeCapability("openwebrx.service.control", "serviceStart", { reason: "session-issue" }, { user, skipTxSafety: true });
} catch {
// optional capability
}
markOpenWebRxSession(user, result);
await ensureOpenWebRxInitialAntennaRoute(user);
return sendJson(res, 200, {
ok: true,
session: result
});
}
async function ensureOpenWebRxInitialAntennaRoute(user) {
if (runtime.pttActive) {
return;
}
const rfrouteProvider = runtime.pluginState.providers["rfroute.set"];
if (!rfrouteProvider) {
return;
}
const bandState = await readOpenWebRxBandState(user);
const antennaRoute = normalizeAntennaRoute(bandState && bandState.antennaRoute ? bandState.antennaRoute : "");
if (!antennaRoute) {
return;
}
try {
await executeCapability("rfroute.set", "setRoute", { route: antennaRoute }, { user, skipTxSafety: true });
runtime.openWebRxAntennaRoute = antennaRoute;
runtime.txFollowRoute = antennaRoute;
} catch {
// keep session issue resilient; antenna follow is best effort
}
}
async function handleOpenWebRxBands(res, user) {
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX Baender nur fuer aktiven Stationsbenutzer");
}
const provider = runtime.pluginState.providers["openwebrx.band.read"];
if (!provider) {
return sendJson(res, 200, { ok: true, bands: [], selectedBand: null });
}
const result = await executeCapability("openwebrx.band.read", "getBands", {}, { user, skipTxSafety: true });
return sendJson(res, 200, { ok: true, ...result });
}
async function handleOpenWebRxBandSelect(res, user, body) {
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX Bandwechsel nur fuer aktiven Stationsbenutzer");
}
const provider = runtime.pluginState.providers["openwebrx.band.set"];
if (!provider) {
return sendError(res, 409, "openwebrx.band.not_configured", "OpenWebRX Band Plugin nicht konfiguriert");
}
const band = String(body && body.band ? body.band : "").trim();
if (!band) {
return sendError(res, 400, "openwebrx.band.missing", "Band fehlt");
}
if (runtime.pttActive) {
return sendError(res, 409, "openwebrx.ptt.active", "Bandwechsel waehrend aktivem PTT ist gesperrt");
}
try {
const result = await executeCapability("openwebrx.band.set", "setBand", { band }, { user, skipTxSafety: true });
const antennaRoute = normalizeAntennaRoute(result && result.antennaRoute);
if (!antennaRoute) {
return sendError(res, 409, "band.route_missing", `Keine antennaRoute fuer Band ${band} konfiguriert`);
}
const rfrouteProvider = runtime.pluginState.providers["rfroute.set"];
if (!rfrouteProvider) {
return sendError(res, 409, "rfroute.not_configured", "RFROUTE Plugin nicht konfiguriert");
}
await executeCapability("rfroute.set", "setRoute", { route: antennaRoute }, { user, skipTxSafety: true });
runtime.txFollowRoute = antennaRoute;
runtime.openWebRxAntennaRoute = antennaRoute;
await appendAudit("openwebrx.band.set", user, { band, result: { centerFreqHz: result.centerFreqHz || null, skipped: Boolean(result.skipped) } });
return sendJson(res, 200, { ok: true, result });
} catch (error) {
const message = String(error && error.message ? error.message : error);
if ((error && error.code === "ENOENT") || message.toLowerCase().includes("config_webrx.py")) {
return sendError(res, 409, "openwebrx.band.config_missing", "Bandwechsel-Config fehlt (OPENWEBRX_CONFIG_PATH/SET_CMD pruefen)");
}
return sendError(res, 409, "openwebrx.band.set_failed", message);
}
}
async function handleOpenWebRxTxEnable(res, user) {
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX TX nur fuer aktiven Stationsbenutzer");
}
try {
const result = await executeCapability("tx.control", "enableTx", { source: "openwebrx" }, { user, skipTxSafety: true });
if (!runtime.pttActive && runtime.pluginState.providers["rfroute.set"]) {
await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user, skipTxSafety: true, allowPttOverride: true });
runtime.txFollowRoute = "rx";
}
await appendAudit("tx.enable", user, { source: "openwebrx" });
return sendJson(res, 200, { ok: true, result });
} catch (error) {
return sendError(res, 409, "tx.enable_failed", String(error && error.message ? error.message : error));
}
}
async function handleOpenWebRxTxDisable(res, user) {
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX TX nur fuer aktiven Stationsbenutzer");
}
if (runtime.pttActive && runtime.pluginState.providers["rfroute.set"]) {
try {
await executeCapability("microham.ptt", "pttUp", {}, { user, skipTxSafety: true });
} catch (error) {
return sendError(res, 409, "openwebrx.ptt.command_failed", String(error && error.message ? error.message : error));
}
await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user, skipTxSafety: true, allowPttOverride: true });
runtime.pttActive = false;
runtime.txFollowRoute = "rx";
}
try {
const result = await executeCapability("tx.control", "disableTx", { source: "openwebrx-ptt" }, { user, skipTxSafety: true });
await appendAudit("tx.disable", user, { source: "openwebrx-ptt" });
return sendJson(res, 200, { ok: true, result });
} catch (error) {
return sendError(res, 409, "tx.disable_failed", String(error && error.message ? error.message : error));
}
}
async function handleOpenWebRxPttDown(res, user, input = {}) {
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX PTT nur fuer aktiven Stationsbenutzer");
}
const provider = runtime.pluginState.providers["rfroute.set"];
if (!provider) {
return sendError(res, 409, "rfroute.not_configured", "RFROUTE Plugin nicht konfiguriert");
}
if (runtime.pttActive) {
return sendJson(res, 200, { ok: true, result: { message: "PTT bereits aktiv" } });
}
const txState = await executeCapability("tx.state.read", "getTxState", {}, { user, skipTxSafety: true });
if (!txState || !txState.txActive) {
return sendError(res, 409, "tx.not_enabled", "TX ist nicht aktiviert");
}
const bandState = await readOpenWebRxBandState(user);
const selectedBandConfigId = String(bandState && (bandState.selectedBandConfigId || bandState.selectedBand) ? (bandState.selectedBandConfigId || bandState.selectedBand) : "").trim();
if (isPttBlockedForBandConfigId(selectedBandConfigId)) {
return sendError(res, 409, "openwebrx.ptt.band_config_blocked", "PTT fuer dieses Band ist gesperrt");
}
const clientLiveState = parseOpenWebRxLiveState(input || {});
if (!clientLiveState.frequencyHz || !clientLiveState.mode) {
return sendError(res, 409, "openwebrx.ptt.live_state_missing", "PTT gesperrt: Live-Frequenz/Mode fehlt");
}
const serverLiveState = getOpenWebRxLiveStateForUser(user.id);
if (!serverLiveState) {
return sendError(res, 409, "openwebrx.ptt.live_state_missing", "PTT gesperrt: Server-Live-State fehlt");
}
if (!openWebRxLiveStatesMatch(clientLiveState, serverLiveState)) {
return sendError(res, 409, "openwebrx.ptt.live_state_mismatch", "PTT gesperrt: Live-Daten stimmen nicht ueberein");
}
bandState.centerFreqHz = clientLiveState.frequencyHz;
bandState.startMod = clientLiveState.mode;
const antennaRoute = normalizeAntennaRoute(bandState.antennaRoute);
if (!antennaRoute) {
return sendError(res, 409, "band.route_missing", "Antenne fuer aktuelles Band ist nicht konfiguriert");
}
await executeCapability("rfroute.set", "setRoute", { route: antennaRoute }, { user, skipTxSafety: true });
runtime.openWebRxAntennaRoute = antennaRoute;
await executeCapability("rfroute.set", "setRoute", { route: "tx" }, { user, skipTxSafety: true });
let result;
try {
result = await executeCapability("microham.ptt", "pttDown", {
bandState
}, { user, skipTxSafety: true });
} catch (error) {
return sendError(res, 409, "openwebrx.ptt.command_failed", String(error && error.message ? error.message : error));
}
runtime.pttActive = true;
runtime.txFollowRoute = "tx";
await appendAudit("openwebrx.ptt.down", user, { result: result && result.direction ? result.direction : "down" });
return sendJson(res, 200, { ok: true, result });
}
async function handleOpenWebRxPttUp(res, user) {
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX PTT nur fuer aktiven Stationsbenutzer");
}
const provider = runtime.pluginState.providers["rfroute.set"];
if (!provider) {
return sendError(res, 409, "rfroute.not_configured", "RFROUTE Plugin nicht konfiguriert");
}
if (!runtime.pttActive) {
return sendJson(res, 200, { ok: true, result: { message: "PTT bereits inaktiv" } });
}
try {
await executeCapability("microham.ptt", "pttUp", {}, { user, skipTxSafety: true });
} catch (error) {
return sendError(res, 409, "openwebrx.ptt.command_failed", String(error && error.message ? error.message : error));
}
const result = await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user, skipTxSafety: true, allowPttOverride: true });
runtime.pttActive = false;
runtime.txFollowRoute = "rx";
await appendAudit("openwebrx.ptt.up", user, { result: result && result.message ? result.message : null });
return sendJson(res, 200, { ok: true, result });
}
async function handleOpenWebRxTxStatus(res, user) {
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX TX Status nur fuer aktiven Stationsbenutzer");
}
const state = await executeCapability("tx.state.read", "getTxState", {}, { user, skipTxSafety: true });
await maybeFollowTxRoute(user, Boolean(state && state.txActive));
let pttConfigured = false;
try {
const pttState = await executeCapability("microham.ptt", "pttStatus", {}, { user, skipTxSafety: true });
pttConfigured = Boolean(pttState && pttState.commandConfigured);
} catch {
pttConfigured = false;
}
return sendJson(res, 200, {
ok: true,
txActive: Boolean(state && state.txActive),
powerCommandConfigured: isPowerControlConfigured(),
pttCommandConfigured: pttConfigured,
state: state || null
});
}
async function handleOpenWebRxRotorStatus(res, user) {
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX Rotorstatus nur fuer aktiven Stationsbenutzer");
}
return sendJson(res, 200, {
ok: true,
rotor: normalizeRotorPayload(runtime.rotor)
});
}
async function handleOpenWebRxRotorSet(res, user, body, source = "api") {
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX Rotorsteuerung nur fuer aktiven Stationsbenutzer");
}
if (!rotorSetEnabled()) {
return sendError(res, 409, "rotor.set_disabled", "Rotorsteuerung ist derzeit deaktiviert");
}
const trigger = String(body && body.trigger ? body.trigger : "").trim().toLowerCase();
if (trigger !== "user") {
return sendError(res, 409, "rotor.set_guard", "Rotor-Set nur nach expliziter Benutzeraktion erlaubt");
}
const targetRaw = body && (body.target ?? body.azimuth ?? body.deg);
if (targetRaw === "" || targetRaw === null || targetRaw === undefined) {
return sendError(res, 400, "rotor.target.invalid", "target muss zwischen 0 und 360 sein");
}
const target = Number(targetRaw);
if (!Number.isFinite(target) || target < 0 || target > 360) {
return sendError(res, 400, "rotor.target.invalid", "target muss zwischen 0 und 360 sein");
}
const enqueueResult = enqueueRotorTarget(target, user, source);
void processRotorQueue();
await appendAudit("openwebrx.rotor.set.requested", user, {
source,
target: enqueueResult.pendingTarget,
queued: enqueueResult.queued,
replacedPending: enqueueResult.replacedPending
});
return sendJson(res, 200, {
ok: true,
accepted: true,
queued: enqueueResult.queued,
replacedPending: enqueueResult.replacedPending,
result: {
message: enqueueResult.queued ? "Rotorziel angenommen (queued)" : "Rotorziel angenommen"
},
rotor: normalizeRotorPayload(runtime.rotor)
});
}
function enqueueRotorTarget(target, user, source) {
const normalizedTarget = normalizeRotorAzimuth(target);
if (normalizedTarget === null) {
throw new Error("target muss zwischen 0 und 360 sein");
}
const hadPending = getPendingRotorTarget() !== null;
runtime.rotor.pendingTargetAzimuth = normalizedTarget;
runtime.rotor.pendingRequestedByUserId = user && user.id ? String(user.id) : null;
runtime.rotor.pendingSource = String(source || "api");
runtime.rotor.phase = runtime.rotor.commandInProgress ? "queued" : "accepted";
runtime.rotor.updatedAt = new Date().toISOString();
return {
queued: Boolean(runtime.rotor.commandInProgress || runtime.rotor.queueWorkerActive),
replacedPending: hadPending,
pendingTarget: normalizedTarget
};
}
async function processRotorQueue() {
if (runtime.rotor.queueWorkerActive) {
return;
}
runtime.rotor.queueWorkerActive = true;
try {
while (getPendingRotorTarget() !== null) {
const target = Number(getPendingRotorTarget());
const requestedByUserId = runtime.rotor.pendingRequestedByUserId;
const source = runtime.rotor.pendingSource || "api";
runtime.rotor.pendingTargetAzimuth = null;
runtime.rotor.pendingRequestedByUserId = null;
runtime.rotor.pendingSource = null;
runtime.rotor.phase = "executing";
try {
const result = await setNativeRotorAzimuth(target);
runtime.rotor.lastResult = result && result.message ? String(result.message) : `Rotor auf ${Math.round(target)} Grad gesetzt`;
runtime.rotor.lastError = null;
const auditUser = requestedByUserId
? (runtime.users.find((entry) => String(entry.id) === String(requestedByUserId)) || { id: String(requestedByUserId), email: "openwebrx-owner", role: "operator" })
: null;
await appendAudit("openwebrx.rotor.set", auditUser, { source, target, result: runtime.rotor.lastResult });
} catch (error) {
runtime.rotor.lastError = String(error && error.message ? error.message : error);
runtime.rotor.lastResult = null;
}
runtime.rotor.updatedAt = new Date().toISOString();
runtime.rotor.phase = getPendingRotorTarget() !== null ? "queued" : "idle";
}
} finally {
runtime.rotor.queueWorkerActive = false;
if (!runtime.rotor.commandInProgress && getPendingRotorTarget() === null) {
runtime.rotor.phase = "idle";
}
}
}
async function readOpenWebRxBandState(user) {
const provider = runtime.pluginState.providers["openwebrx.band.read"];
if (!provider) {
return { selectedBandConfigId: null, selectedBand: null, antennaRoute: "", centerFreqHz: null, startMod: null };
}
try {
const state = await executeCapability("openwebrx.band.read", "getState", {}, { user, skipTxSafety: true });
if (state && typeof state === "object") {
return {
selectedBandConfigId: String(state.selectedBand || "").trim() || null,
selectedBand: String(state.selectedBand || "").trim() || null,
antennaRoute: String(state.antennaRoute || "").trim().toLowerCase(),
centerFreqHz: Number.isFinite(Number(state.centerFreqHz)) ? Number(state.centerFreqHz) : null,
startMod: String(state.startMod || "").trim().toLowerCase() || null
};
}
} catch {
// fallback to getBands
}
const result = await executeCapability("openwebrx.band.read", "getBands", {}, { user, skipTxSafety: true });
const selectedBandConfigId = String(result && result.selectedBand ? result.selectedBand : "").trim() || null;
let antennaRoute = "";
let centerFreqHz = null;
let startMod = null;
const bands = Array.isArray(result && result.bands) ? result.bands : [];
if (selectedBandConfigId) {
const selected = bands.find((entry) => {
if (!entry || typeof entry !== "object") {
return false;
}
return String(entry.band || entry.id || "").trim() === selectedBandConfigId;
});
if (selected && typeof selected === "object") {
antennaRoute = String(selected.antennaRoute || "").trim().toLowerCase();
centerFreqHz = Number.isFinite(Number(selected.centerFreqHz)) ? Number(selected.centerFreqHz) : null;
startMod = String(selected.startMod || "").trim().toLowerCase() || null;
}
}
return { selectedBandConfigId, selectedBand: selectedBandConfigId, antennaRoute, centerFreqHz, startMod };
}
function normalizeAntennaRoute(value) {
const route = String(value || "").trim().toLowerCase();
if (!route) {
return "";
}
const allowed = new Set(["draht", "beam", "wrtc"]);
return allowed.has(route) ? route : "";
}
function normalizeLiveMode(value) {
const raw = String(value || "").trim().toLowerCase();
if (!raw) {
return "";
}
const map = {
usb: "usb",
lsb: "lsb",
am: "am",
fm: "fm",
nfm: "fm",
wfm: "fm",
cw: "cw",
cwr: "cwr"
};
return map[raw] || "";
}
function parseOpenWebRxLiveState(input) {
const frequencyHz = Number(input && input.liveFrequencyHz);
const normalizedFrequency = Number.isFinite(frequencyHz) && frequencyHz > 0
? Math.floor(frequencyHz)
: null;
const mode = normalizeLiveMode(input && input.liveMode);
return {
frequencyHz: normalizedFrequency,
mode
};
}
function setOpenWebRxLiveStateForUser(userId, state) {
const key = String(userId || "").trim();
if (!key) {
return;
}
runtime.openWebRxLiveStateByUserId[key] = {
frequencyHz: Number.isFinite(Number(state && state.frequencyHz)) ? Math.floor(Number(state.frequencyHz)) : null,
mode: normalizeLiveMode(state && state.mode),
updatedAtMs: Number.isFinite(Number(state && state.updatedAtMs)) ? Number(state.updatedAtMs) : Date.now(),
source: String(state && state.source ? state.source : "unknown")
};
}
function getOpenWebRxLiveStateForUser(userId) {
const key = String(userId || "").trim();
if (!key) {
return null;
}
const state = runtime.openWebRxLiveStateByUserId[key];
if (!state || typeof state !== "object") {
return null;
}
const now = Date.now();
const ttlMs = Number.isFinite(config.openWebRxLiveStateTtlMs) ? Math.max(1000, config.openWebRxLiveStateTtlMs) : 10000;
if (!Number.isFinite(Number(state.updatedAtMs)) || now - Number(state.updatedAtMs) > ttlMs) {
delete runtime.openWebRxLiveStateByUserId[key];
return null;
}
const frequencyHz = Number.isFinite(Number(state.frequencyHz)) ? Math.floor(Number(state.frequencyHz)) : null;
const mode = normalizeLiveMode(state.mode);
if (!frequencyHz || !mode) {
return null;
}
return {
frequencyHz,
mode,
updatedAtMs: Number(state.updatedAtMs),
source: String(state.source || "unknown")
};
}
function openWebRxLiveStatesMatch(clientState, serverState) {
if (!clientState || !serverState) {
return false;
}
const clientMode = normalizeLiveMode(clientState.mode);
const serverMode = normalizeLiveMode(serverState.mode);
if (!clientMode || !serverMode || clientMode !== serverMode) {
return false;
}
const a = Number(clientState.frequencyHz);
const b = Number(serverState.frequencyHz);
if (!Number.isFinite(a) || !Number.isFinite(b)) {
return false;
}
const tolerance = Number.isFinite(config.openWebRxPttLiveFreqToleranceHz)
? Math.max(0, Math.floor(config.openWebRxPttLiveFreqToleranceHz))
: 50;
return Math.abs(Math.floor(a) - Math.floor(b)) <= tolerance;
}
function openWebRxPttCommandsEnabled() {
return String(config.openWebRxPttCommandsEnabled || "false").trim().toLowerCase() === "true";
}
function rotorSetEnabled() {
return String(config.rotorSetEnabled || "true").trim().toLowerCase() !== "false";
}
function renderOpenWebRxPttCommand(template) {
const raw = String(template || "").trim();
if (!raw) {
return "";
}
const pttDevice = String(config.openWebRxPttDevice || process.env.RMS_MICROHAM_DEV || "/dev/rms-microham-u3").trim() || "/dev/rms-microham-u3";
if (!/^\/dev\/[A-Za-z0-9._\/-]+$/.test(pttDevice)) {
throw new Error(`invalid ptt device path: ${pttDevice}`);
}
return raw.replaceAll("{pttDevice}", pttDevice);
}
async function runConfiguredOpenWebRxPttCommand(direction) {
if (!openWebRxPttCommandsEnabled()) {
throw new Error("OPENWEBRX_PTT_COMMANDS_ENABLED must be true");
}
const normalized = String(direction || "").trim().toLowerCase();
if (normalized !== "down" && normalized !== "up") {
throw new Error(`invalid ptt direction: ${normalized}`);
}
const commandTemplate = normalized === "down" ? config.openWebRxPttDownCmd : config.openWebRxPttUpCmd;
const command = renderOpenWebRxPttCommand(commandTemplate);
if (!command) {
throw new Error(normalized === "down"
? "OPENWEBRX_PTT_DOWN_CMD missing"
: "OPENWEBRX_PTT_UP_CMD missing");
}
const result = await runCommand(command, {
timeoutMs: Number.isFinite(Number(config.openWebRxPttTimeoutMs)) ? Number(config.openWebRxPttTimeoutMs) : 5000
});
if (!result.ok) {
throw new Error(result.stderr || result.error || `ptt ${normalized} command failed`);
}
return { ok: true, skipped: false, direction: normalized };
}
function normalizeRotorPayload(result) {
const pendingTarget = getPendingRotorTarget();
const activeTarget = (runtime.rotor.targetAzimuth === null || runtime.rotor.targetAzimuth === undefined)
? null
: (Number.isFinite(Number(runtime.rotor.targetAzimuth)) ? Number(runtime.rotor.targetAzimuth) : null);
const queueDepth = runtime.rotor.commandInProgress
? (pendingTarget !== null ? 2 : 1)
: (pendingTarget !== null ? 1 : 0);
return {
azimuth: (result && result.azimuth !== null && result.azimuth !== undefined && result.azimuth !== "" && Number.isFinite(Number(result.azimuth)))
? Number(result.azimuth)
: null,
rawAzimuth: (result && result.rawAzimuth !== null && result.rawAzimuth !== undefined && result.rawAzimuth !== "" && Number.isFinite(Number(result.rawAzimuth)))
? Number(result.rawAzimuth)
: null,
moving: Boolean(result && result.moving),
stale: Boolean(result && result.stale),
phase: String(runtime.rotor.phase || "idle"),
queueDepth,
activeTarget,
pendingTarget,
lastResult: runtime.rotor.lastResult ? String(runtime.rotor.lastResult) : null,
lastError: runtime.rotor.lastError ? String(runtime.rotor.lastError) : null,
updatedAt: result && result.updatedAt ? String(result.updatedAt) : null,
error: result && result.error ? String(result.error) : null,
min: Number.isFinite(Number(result && result.min)) ? Number(result.min) : 0,
max: Number.isFinite(Number(result && result.max)) ? Number(result.max) : 360
};
}
function getPendingRotorTarget() {
if (runtime.rotor.pendingTargetAzimuth === null
|| runtime.rotor.pendingTargetAzimuth === undefined
|| runtime.rotor.pendingTargetAzimuth === "") {
return null;
}
const pending = Number(runtime.rotor.pendingTargetAzimuth);
return Number.isFinite(pending) ? pending : null;
}
function normalizeRotorAzimuth(value) {
if (!Number.isFinite(Number(value))) {
return null;
}
let az = Number(value);
while (az < 0) az += 360;
while (az >= 360) az -= 360;
return az;
}
function circularAzimuthDistance(a, b) {
const left = normalizeRotorAzimuth(a);
const right = normalizeRotorAzimuth(b);
if (left === null || right === null) {
return Number.POSITIVE_INFINITY;
}
const diff = Math.abs(left - right);
return Math.min(diff, 360 - diff);
}
function waitMs(ms) {
const delay = Number.isFinite(Number(ms)) ? Math.max(0, Math.floor(Number(ms))) : 0;
return new Promise((resolve) => setTimeout(resolve, delay));
}
function isHardRotorSetFailure(result) {
if (!result || typeof result !== "object") {
return true;
}
if (Number(result.code) === -1) {
return true;
}
const msg = `${result.stderr || ""}\n${result.error || ""}`.toLowerCase();
if (!msg.trim()) {
return false;
}
const hardPatterns = [
"no such file",
"not found",
"permission denied",
"cannot open",
"failed to open",
"unable to open",
"device not found"
];
return hardPatterns.some((pattern) => msg.includes(pattern));
}
function rotorCommandBaseArgs() {
const model = Number.isFinite(Number(config.rotorModel)) ? Math.floor(Number(config.rotorModel)) : 902;
const device = resolveRotorDevicePath();
return `-m ${model} -r ${device} -Z --set-conf=post_write_delay=0`;
}
function resolveRotorDevicePath() {
const device = String(config.rotorDevice || "/dev/rms-ftdi-uart").trim() || "/dev/rms-ftdi-uart";
if (!/^\/dev\/[A-Za-z0-9._\/-]+$/.test(device)) {
throw new Error(`invalid rotor device path: ${device}`);
}
return device;
}
async function readNativeRotorStatus() {
if (config.simulateHardware) {
return {
azimuth: runtime.rotor.azimuth,
rawAzimuth: runtime.rotor.rawAzimuth,
moving: Boolean(runtime.rotor.moving),
min: 0,
max: 360,
simulated: true
};
}
const configuredAttempts = Number.isFinite(Number(config.rotorStatusRetryCount))
? Math.max(1, Math.floor(Number(config.rotorStatusRetryCount)))
: 1;
const attempts = (runtime.rotor.commandInProgress || runtime.rotor.queueWorkerActive)
? 1
: configuredAttempts;
const retryDelayMs = (runtime.rotor.commandInProgress || runtime.rotor.queueWorkerActive)
? 0
: (Number.isFinite(Number(config.rotorStatusRetryDelayMs))
? Math.max(0, Math.floor(Number(config.rotorStatusRetryDelayMs)))
: 0);
let lastError = "";
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const command = `rotctl ${rotorCommandBaseArgs()} p p`;
const result = await runCommand(command, {
timeoutMs: Number.isFinite(Number(config.rotorGetTimeoutMs)) ? Number(config.rotorGetTimeoutMs) : 10000
});
if (result.ok) {
const parsed = parseRotorAzimuthFromOutput(result.stdout || "");
const rawAzimuth = Number.isFinite(parsed) ? parsed : null;
const azimuth = normalizeRotorAzimuth(parsed);
if (azimuth !== null) {
runtime.rotor.rawAzimuth = rawAzimuth;
runtime.rotor.azimuth = azimuth;
runtime.rotor.updatedAt = new Date().toISOString();
return {
azimuth: runtime.rotor.azimuth,
rawAzimuth: runtime.rotor.rawAzimuth,
moving: Boolean(runtime.rotor.moving),
min: 0,
max: 360,
updatedAt: runtime.rotor.updatedAt
};
}
lastError = "rotctl get returned invalid azimuth";
} else {
lastError = result.stderr || result.error || "rotctl get failed";
}
if (attempt < attempts && retryDelayMs > 0) {
await waitMs(retryDelayMs);
}
}
if (runtime.rotor.azimuth !== null) {
return {
azimuth: runtime.rotor.azimuth,
rawAzimuth: runtime.rotor.rawAzimuth,
moving: Boolean(runtime.rotor.moving),
min: 0,
max: 360,
updatedAt: runtime.rotor.updatedAt,
stale: true,
error: lastError || "rotctl get unavailable"
};
}
throw new Error(lastError || "rotctl get failed");
}
async function refreshRotorStatusCache() {
if (config.simulateHardware) {
return;
}
if (runtime.rotor.commandInProgress || runtime.rotor.queueWorkerActive || runtime.rotor.statusRefreshInFlight) {
return;
}
runtime.rotor.statusRefreshInFlight = true;
try {
await readNativeRotorStatus();
} catch {
// best effort background refresh
} finally {
runtime.rotor.statusRefreshInFlight = false;
}
}
function parseRotorAzimuthFromOutput(stdout) {
const lines = String(stdout || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !/^rprt\b/i.test(line));
if (lines.length === 0) {
return Number.NaN;
}
for (const line of lines) {
const prefixed = line.match(/(?:azimuth|azi)\s*[:=]\s*([-+]?\d+(?:\.\d+)?)/i);
if (prefixed) {
const parsed = Number(prefixed[1]);
if (Number.isFinite(parsed) && parsed >= -360 && parsed <= 360) {
return parsed;
}
}
}
// For rotctl "p p", first numeric line is azimuth, second is elevation.
// If we only get one unlabeled numeric line (often elevation=0), do not accept it
// as azimuth to avoid false "0°" status updates.
const numericLines = lines.filter((line) => /^[-+]?\d+(?:\.\d+)?$/.test(line));
if (numericLines.length >= 2) {
const parsed = Number(numericLines[0]);
if (Number.isFinite(parsed) && parsed >= -360 && parsed <= 360) {
return parsed;
}
}
return Number.NaN;
}
async function waitForRotorStatusAfterSet(target, timeoutMs) {
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
let lastStatus = null;
let lastError = null;
do {
try {
const status = await readNativeRotorStatus();
lastStatus = status;
if (!status || status.stale !== true) {
return status;
}
const distance = circularAzimuthDistance(status.azimuth, target);
if (Number.isFinite(distance) && distance <= 5) {
return status;
}
} catch (error) {
lastError = error;
}
await waitMs(Math.max(100, Number(config.rotorStatusRetryDelayMs) || 300));
} while (Date.now() < deadline);
if (lastStatus) {
return lastStatus;
}
if (lastError) {
throw lastError;
}
throw new Error("rotor status unavailable after set");
}
async function setNativeRotorAzimuth(target) {
const normalizedTarget = normalizeRotorAzimuth(target);
if (normalizedTarget === null) {
throw new Error("target muss zwischen 0 und 360 sein");
}
if (runtime.rotor.commandInProgress) {
throw new Error("rotor command already in progress");
}
runtime.rotor.commandInProgress = true;
runtime.rotor.commandStartedAt = new Date().toISOString();
runtime.rotor.targetAzimuth = normalizedTarget;
runtime.rotor.moving = true;
runtime.rotor.phase = "setting";
runtime.rotor.lastChangeAt = Date.now();
if (config.simulateHardware) {
runtime.rotor.azimuth = normalizedTarget;
runtime.rotor.updatedAt = new Date().toISOString();
runtime.rotor.moving = false;
runtime.rotor.commandInProgress = false;
runtime.rotor.targetAzimuth = null;
runtime.rotor.commandStartedAt = null;
return {
ok: true,
message: `Rotor auf ${Math.round(normalizedTarget)} Grad simuliert`,
rotor: {
azimuth: runtime.rotor.azimuth,
moving: false,
min: 0,
max: 360
}
};
}
try {
let initialAzimuth = runtime.rotor.azimuth;
try {
const initialStatus = await readNativeRotorStatus();
if (Number.isFinite(Number(initialStatus && initialStatus.azimuth))) {
initialAzimuth = Number(initialStatus.azimuth);
}
} catch {
// continue with cached status
}
const setpoint = Number(normalizedTarget);
const command = `rotctl ${rotorCommandBaseArgs()} p p P ${Math.round(setpoint)} 0 p p`;
const result = await runCommand(command, {
timeoutMs: Number.isFinite(Number(config.rotorSetTimeoutMs)) ? Number(config.rotorSetTimeoutMs) : 20000
});
if (!result.ok && isHardRotorSetFailure(result)) {
throw new Error(result.stderr || result.error || "rotctl set failed");
}
runtime.rotor.phase = "monitoring";
const observed = await monitorRotorMoveToTarget(normalizedTarget, initialAzimuth);
return {
ok: true,
message: `Rotor auf ${Math.round(normalizedTarget)} Grad gesetzt`,
warning: result.ok ? null : (result.stderr || result.error || "rotctl set returned non-zero"),
rotor: normalizeRotorPayload(observed)
};
} finally {
runtime.rotor.moving = false;
runtime.rotor.commandInProgress = false;
runtime.rotor.targetAzimuth = null;
runtime.rotor.commandStartedAt = null;
if (getPendingRotorTarget() === null) {
runtime.rotor.phase = "idle";
}
}
}
async function monitorRotorMoveToTarget(targetAzimuth, initialAzimuth) {
const firstCheckDelayMs = 2000;
const pollMs = 1000;
const stallMs = 7000;
const configuredMaxMs = Number.isFinite(Number(config.rotorMonitorMaxMs))
? Math.max(15000, Number(config.rotorMonitorMaxMs))
: 120000;
const estimatedDistance = Number.isFinite(Number(initialAzimuth))
? circularAzimuthDistance(Number(initialAzimuth), targetAzimuth)
: Number.POSITIVE_INFINITY;
const distanceBudgetMs = Number.isFinite(estimatedDistance)
? Math.max(15000, Math.round(estimatedDistance * 450) + 12000)
: 30000;
const maxTotalMs = Math.max(configuredMaxMs, distanceBudgetMs);
await waitMs(firstCheckDelayMs);
const startAt = Date.now();
let lastAzimuth = Number.isFinite(Number(initialAzimuth)) ? Number(initialAzimuth) : null;
let movementDetected = false;
let lastMovementAt = Date.now();
let lastStatus = null;
let lastReadError = null;
while ((Date.now() - startAt) <= maxTotalMs) {
try {
const status = await readNativeRotorStatus();
lastStatus = status;
lastReadError = null;
const current = Number.isFinite(Number(status && status.azimuth)) ? Number(status.azimuth) : null;
if (current !== null) {
const toTarget = circularAzimuthDistance(current, targetAzimuth);
if (Number.isFinite(toTarget) && toTarget <= 5) {
runtime.rotor.azimuth = current;
runtime.rotor.updatedAt = new Date().toISOString();
return {
...status,
moving: false
};
}
if (lastAzimuth === null || circularAzimuthDistance(lastAzimuth, current) >= 1) {
movementDetected = true;
lastMovementAt = Date.now();
lastAzimuth = current;
}
runtime.rotor.azimuth = current;
runtime.rotor.updatedAt = new Date().toISOString();
}
} catch (error) {
lastReadError = error;
}
if (!movementDetected && (Date.now() - startAt) >= stallMs) {
throw new Error("rotor did not start moving after command");
}
if (movementDetected && (Date.now() - lastMovementAt) >= stallMs) {
throw new Error("rotor movement stalled before reaching target");
}
await waitMs(pollMs);
}
if (lastStatus) {
throw new Error("rotor did not reach target within timeout");
}
if (lastReadError) {
throw new Error(String(lastReadError && lastReadError.message ? lastReadError.message : lastReadError));
}
throw new Error("rotor status unavailable while monitoring movement");
}
async function maybeFollowTxRoute(user, txActive) {
if (runtime.pttActive) {
return;
}
if (txActive) {
return;
}
const provider = runtime.pluginState.providers["rfroute.set"];
if (!provider) {
return;
}
if (runtime.txFollowRoute !== "tx") {
return;
}
const desiredRoute = "rx";
if (runtime.txFollowRoute === desiredRoute) {
return;
}
try {
await executeCapability("rfroute.set", "setRoute", { route: desiredRoute }, { user, skipTxSafety: true });
runtime.txFollowRoute = desiredRoute;
} catch {
// rfroute follow is best effort
}
}
async function enforceOpenWebRxPttConnectionWatchdog() {
if (!runtime.pttActive) {
return;
}
const activeOwnerId = String(runtime.station && runtime.station.activeByUserId ? runtime.station.activeByUserId : "");
if (!activeOwnerId) {
await forceOpenWebRxPttUpForConnectionLoss(null, "missing-owner");
return;
}
const liveState = getOpenWebRxLiveStateForUser(activeOwnerId);
if (!liveState) {
await forceOpenWebRxPttUpForConnectionLoss(activeOwnerId, "missing-live-state");
return;
}
const staleAfterMs = Math.max(1500, Number(config.openWebRxLiveStateTtlMs || 10000));
const lastUpdatedMs = Number(liveState.updatedAtMs || 0);
if (!Number.isFinite(lastUpdatedMs) || (Date.now() - lastUpdatedMs) > staleAfterMs) {
await forceOpenWebRxPttUpForConnectionLoss(activeOwnerId, "stale-live-state");
}
}
async function forceOpenWebRxPttUpForConnectionLoss(ownerUserId, reason) {
return withLock(async () => {
if (!runtime.pttActive) {
return;
}
const actingUser = runtime.users.find((entry) => String(entry && entry.id ? entry.id : "") === String(ownerUserId || "")) || null;
try {
await executeCapability("microham.ptt", "pttUp", {}, { user: actingUser, skipTxSafety: true });
} catch {
// best effort
}
try {
await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user: actingUser, skipTxSafety: true, allowPttOverride: true });
} catch {
// best effort
}
runtime.pttActive = false;
runtime.txFollowRoute = "rx";
await appendAudit("openwebrx.ptt.auto_up", actingUser, { reason: reason || "connection-lost" });
broadcastEvent("station.status.changed", buildStationStatusView());
});
}
async function handleOpenWebRxSessionClose(res, user) {
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
return sendError(res, 403, "openwebrx.not_owner", "Nur aktiver Stationsbenutzer darf schliessen");
}
try {
if (runtime.pttActive && runtime.pluginState.providers["rfroute.set"]) {
try {
await executeCapability("microham.ptt", "pttUp", {}, { user, skipTxSafety: true });
} catch {
// best effort
}
try {
await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user, skipTxSafety: true, allowPttOverride: true });
} catch {
// best effort
}
runtime.pttActive = false;
runtime.txFollowRoute = "rx";
}
await executeCapability("tx.control", "disableTx", { source: "openwebrx-close" }, { user, skipTxSafety: true });
await executeCapability("tx.audio", "audioDisconnect", { reason: "openwebrx-close" }, { user, skipTxSafety: true }).catch(() => {});
await appendAudit("tx.disable", user, { source: "openwebrx-close" });
} catch (error) {
return sendError(res, 409, "tx.disable_failed", String(error && error.message ? error.message : error));
}
await revokeOpenWebRxAccess(user, "openwebrx-close");
clearOpenWebRxSession();
return sendJson(res, 200, { ok: true });
}
async function handleOpenWebRxPluginState(req, res, url) {
const auth = await requireOpenWebRxTicketUser(req, res, url);
if (!auth) {
return;
}
const user = auth.user;
const payload = {
ok: true,
txActive: false,
pttActive: Boolean(runtime.pttActive),
power: {
active: false,
commandConfigured: isPowerControlConfigured()
},
ptt: {
active: Boolean(runtime.pttActive),
commandConfigured: false,
providerAvailable: Boolean(runtime.pluginState.providers["microham.ptt"]),
providerId: runtime.pluginState.providers["microham.ptt"] || null,
providerEnabled: null,
liveStateReady: false,
liveState: null,
error: null,
blockedBandConfigIds: Array.isArray(config.openWebRxPttBlockedBandConfigIds)
? [...config.openWebRxPttBlockedBandConfigIds]
: [],
blockedByBand: false
},
rfPath: {
current: null,
commandConfigured: isRfrouteTxRxConfigured()
},
antenna: {
current: null,
commandConfigured: isAntennaRoutingConfigured()
},
stationEndsAt: runtime.station && runtime.station.endsAt ? runtime.station.endsAt : null,
remainingUsageSec: runtime.station && runtime.station.isInUse && runtime.station.endsAt
? Math.max(0, Math.ceil((new Date(runtime.station.endsAt).getTime() - Date.now()) / 1000))
: 0,
rfroute: {
current: null,
options: []
},
txAudio: {
enabled: isLikelyMicrohamAudioEnabledFromEnv(),
state: isLikelyMicrohamAudioEnabledFromEnv() ? "disconnected" : "disabled",
running: false,
clients: 0,
ownerUserId: null,
ownerMatchesCaller: false,
startedAt: null,
lastError: null,
lastExit: null,
ffmpegPath: null,
alsaDevice: null,
chunkMs: 100,
wsPath: "/v1/openwebrx/plugin/audio/ws"
},
bands: [],
selectedBand: null,
selectedBandConfigId: null,
antennaRoute: null,
rotor: {
azimuth: null,
moving: false,
min: 0,
max: 360
}
};
try {
const txState = await withTimeout(
executeCapability("tx.state.read", "getTxState", {}, { user, skipTxSafety: true }),
1200,
"tx.state.read timeout"
);
payload.txActive = Boolean(txState && txState.txActive);
payload.power.active = payload.txActive;
payload.txState = txState || null;
} catch {
payload.txState = null;
}
const pttProviderId = runtime.pluginState.providers["microham.ptt"] || null;
payload.ptt.providerEnabled = pttProviderId ? Boolean(runtime.pluginState.enabled[pttProviderId]) : null;
let pttStatus = null;
let pttError = null;
try {
pttStatus = await executeCapability("microham.ptt", "pttStatus", {}, { user, skipTxSafety: true });
} catch (error) {
pttError = String(error && error.message ? error.message : error);
const likelyProviderIssue = /No provider for capability microham\.ptt|Provider plugin .* missing|Provider plugin .* disabled/i.test(pttError);
if (likelyProviderIssue) {
try {
await healMicrohamProviders();
pttStatus = await executeCapability("microham.ptt", "pttStatus", {}, { user, skipTxSafety: true });
pttError = null;
} catch (retryError) {
pttError = String(retryError && retryError.message ? retryError.message : retryError);
}
}
}
if (pttStatus) {
payload.ptt.commandConfigured = Boolean(pttStatus && pttStatus.commandConfigured);
payload.ptt.active = Boolean(pttStatus && pttStatus.active);
payload.pttActive = payload.ptt.active;
} else if (pttError) {
payload.ptt.error = pttError;
payload.ptt.commandConfigured = isLikelyPttConfiguredFromEnv();
console.warn("openwebrx.plugin.state pttStatus failed:", {
error: payload.ptt.error,
providerId: payload.ptt.providerId,
providerEnabled: payload.ptt.providerEnabled,
userId: user && user.id ? String(user.id) : null
});
}
const liveState = getOpenWebRxLiveStateForUser(user && user.id ? user.id : "");
payload.ptt.liveState = liveState;
payload.ptt.liveStateReady = Boolean(payload.ptt.commandConfigured && liveState && liveState.frequencyHz && liveState.mode);
try {
const audioStatus = await executeCapability("tx.audio", "audioStatus", { userId: user && user.id ? String(user.id) : "" }, { user, skipTxSafety: true });
if (audioStatus && typeof audioStatus === "object") {
payload.txAudio = audioStatus;
}
} catch (error) {
if (isLikelyMicrohamProviderIssue(error)) {
try {
await healMicrohamProviders();
const audioStatus = await executeCapability("tx.audio", "audioStatus", { userId: user && user.id ? String(user.id) : "" }, { user, skipTxSafety: true });
if (audioStatus && typeof audioStatus === "object") {
payload.txAudio = audioStatus;
}
} catch {
payload.txAudio.lastError = String(error && error.message ? error.message : error);
}
} else {
payload.txAudio.lastError = String(error && error.message ? error.message : error);
}
}
try {
const providerId = runtime.pluginState.providers["rfroute.set"];
if (providerId) {
const plugin = runtime.plugins.get(providerId);
if (plugin && plugin.instance && typeof plugin.instance.getStatus === "function") {
const status = await withTimeout(safeStatus(plugin.instance), 1000, "rfroute status timeout");
payload.rfroute = {
current: status && status.current ? String(status.current) : null,
options: Array.isArray(status && status.options) ? status.options.map((entry) => String(entry)) : []
};
}
}
} catch {
payload.rfroute = { current: null, options: [] };
}
const rfRouteAsAntenna = normalizeAntennaRoute(payload.rfroute && payload.rfroute.current ? payload.rfroute.current : "");
payload.rfPath.current = payload.pttActive ? "tx" : "rx";
if (rfRouteAsAntenna) {
payload.antennaRoute = rfRouteAsAntenna;
payload.antenna.current = rfRouteAsAntenna;
}
if (!payload.antennaRoute) {
payload.antennaRoute = normalizeAntennaRoute(runtime.openWebRxAntennaRoute || "") || null;
payload.antenna.current = payload.antennaRoute;
}
try {
const provider = runtime.pluginState.providers["openwebrx.band.read"];
if (provider) {
try {
const stateResult = await withTimeout(
executeCapability("openwebrx.band.read", "getState", {}, { user, skipTxSafety: true }),
1000,
"band state timeout"
);
if (stateResult && stateResult.selectedBand) {
payload.selectedBand = stateResult.selectedBand;
payload.selectedBandConfigId = stateResult.selectedBand;
}
const directRoute = normalizeAntennaRoute(stateResult && stateResult.antennaRoute ? stateResult.antennaRoute : "");
if (directRoute) {
payload.antennaRoute = directRoute;
payload.antenna.current = directRoute;
}
} catch {
// fallback to getBands only
}
const result = await withTimeout(
executeCapability("openwebrx.band.read", "getBands", {}, { user, skipTxSafety: true }),
1200,
"bands read timeout"
);
payload.bands = Array.isArray(result && result.bands) ? result.bands : [];
if (!payload.selectedBand) {
payload.selectedBand = result && result.selectedBand ? result.selectedBand : null;
}
if (!payload.selectedBandConfigId) {
payload.selectedBandConfigId = payload.selectedBand;
}
if (!payload.antennaRoute) {
const selectedBand = String(payload.selectedBand || "").trim();
if (selectedBand) {
const selected = payload.bands.find((entry) => {
if (!entry || typeof entry !== "object") {
return false;
}
return String(entry.band || entry.id || "").trim() === selectedBand;
});
if (selected && typeof selected === "object") {
payload.antennaRoute = normalizeAntennaRoute(selected.antennaRoute || "") || null;
payload.antenna.current = payload.antennaRoute;
}
}
}
}
} catch {
payload.bands = [];
payload.selectedBand = null;
payload.selectedBandConfigId = null;
}
const selectedBandConfigId = String(payload.selectedBandConfigId || payload.selectedBand || "").trim();
payload.ptt.blockedByBand = isPttBlockedForBandConfigId(selectedBandConfigId);
payload.rotor = normalizeRotorPayload(runtime.rotor);
return sendJson(res, 200, payload);
}
function isPowerControlConfigured() {
const enable = String(process.env.TX_ENABLE_REAL_CMD || process.env.POWER_ON_CMD || "").trim();
const disable = String(process.env.TX_DISABLE_REAL_CMD || process.env.POWER_OFF_CMD || "").trim();
return Boolean(enable && disable);
}
function isRfroutePttConfigured() {
const enabled = openWebRxPttCommandsEnabled();
const down = String(config.openWebRxPttDownCmd || "").trim();
const up = String(config.openWebRxPttUpCmd || "").trim();
return Boolean(enabled && down && up);
}
function isLikelyPttConfiguredFromEnv() {
const enabled = String(process.env.MICROHAM_PTT_COMMANDS_ENABLED || "false").trim().toLowerCase();
if (!(enabled === "true" || enabled === "1" || enabled === "yes" || enabled === "on")) {
return false;
}
const down = String(process.env.MICROHAM_PTT_DOWN_CMD || "").trim();
const up = String(process.env.MICROHAM_PTT_UP_CMD || "").trim();
return Boolean(down && up);
}
function isLikelyMicrohamProviderIssue(error) {
const message = String(error && error.message ? error.message : error || "");
return /No provider for capability (microham\.|tx\.audio)|Provider plugin .* missing|Provider plugin .* disabled/i.test(message);
}
function isLikelyMicrohamAudioEnabledFromEnv() {
const raw = String(process.env.MICROHAM_AUDIO_ENABLED || "true").trim().toLowerCase();
if (!raw) {
return true;
}
return raw === "true" || raw === "1" || raw === "yes" || raw === "on";
}
async function healMicrohamProviders() {
ensureDefaultProviders();
await savePluginState();
}
function isRfrouteTxRxConfigured() {
const tx = String(process.env.RFROUTE_CMD_TX || "").trim();
const rx = String(process.env.RFROUTE_CMD_RX || "").trim();
return Boolean(tx && rx);
}
function isAntennaRoutingConfigured() {
const draht = String(process.env.RFROUTE_CMD_DRAHT || "").trim();
const beam = String(process.env.RFROUTE_CMD_BEAM || "").trim();
const wrtc = String(process.env.RFROUTE_CMD_WRTC || "").trim();
return Boolean(draht && beam && wrtc);
}
function withTimeout(promise, timeoutMs, message) {
const ms = Number.isFinite(Number(timeoutMs)) ? Math.max(50, Number(timeoutMs)) : 1000;
let timer = null;
return Promise.race([
Promise.resolve(promise),
new Promise((_, reject) => {
timer = setTimeout(() => {
reject(new Error(String(message || "timeout")));
}, ms);
if (timer && typeof timer.unref === "function") {
timer.unref();
}
})
]).finally(() => {
if (timer) {
clearTimeout(timer);
}
});
}
async function handleOpenWebRxPluginTx(req, res, url, enable) {
const auth = await requireOpenWebRxTicketUser(req, res, url);
if (!auth) {
return;
}
const user = auth.user;
return enable ? handleOpenWebRxTxEnable(res, user) : handleOpenWebRxTxDisable(res, user);
}
async function handleOpenWebRxPluginPtt(req, res, url, down) {
const auth = await requireOpenWebRxTicketUser(req, res, url);
if (!auth) {
return;
}
const body = down ? await readJsonBody(req) : {};
return down ? handleOpenWebRxPttDown(res, auth.user, body || {}) : handleOpenWebRxPttUp(res, auth.user);
}
async function handleOpenWebRxPluginLiveState(req, res, url, body) {
const auth = await requireOpenWebRxTicketUser(req, res, url);
if (!auth) {
return;
}
const user = auth.user;
if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) {
return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX Live-State nur fuer aktiven Stationsbenutzer");
}
const parsed = parseOpenWebRxLiveState(body || {});
if (!parsed.frequencyHz || !parsed.mode) {
return sendError(res, 409, "openwebrx.live_state_invalid", "Live-Frequenz oder Mode fehlt");
}
setOpenWebRxLiveStateForUser(user.id, {
frequencyHz: parsed.frequencyHz,
mode: parsed.mode,
updatedAtMs: Date.now(),
source: "plugin"
});
return sendJson(res, 200, {
ok: true,
liveState: {
frequencyHz: parsed.frequencyHz,
mode: parsed.mode
}
});
}
async function handleOpenWebRxPluginAudioStatus(req, res, url) {
const auth = await requireOpenWebRxTicketUser(req, res, url);
if (!auth) {
return;
}
try {
const audio = await executeCapability("tx.audio", "audioStatus", { userId: String(auth.user.id || "") }, { user: auth.user, skipTxSafety: true });
return sendJson(res, 200, { ok: true, audio });
} catch (error) {
if (isLikelyMicrohamProviderIssue(error)) {
await healMicrohamProviders();
try {
const audio = await executeCapability("tx.audio", "audioStatus", { userId: String(auth.user.id || "") }, { user: auth.user, skipTxSafety: true });
return sendJson(res, 200, { ok: true, audio });
} catch {
// fall through
}
}
return sendError(res, 409, "tx.audio.status_failed", String(error && error.message ? error.message : error));
}
}
async function handleOpenWebRxPluginAudioConnect(req, res, url) {
const auth = await requireOpenWebRxTicketUser(req, res, url);
if (!auth) {
return;
}
try {
await executeCapability("tx.audio", "audioConnect", {
userId: String(auth.user.id || ""),
reason: "plugin-connect"
}, { user: auth.user, skipTxSafety: true });
const audio = await executeCapability("tx.audio", "audioStatus", { userId: String(auth.user.id || "") }, { user: auth.user, skipTxSafety: true });
return sendJson(res, 200, {
ok: true,
audio
});
} catch (error) {
if (isLikelyMicrohamProviderIssue(error)) {
await healMicrohamProviders();
try {
await executeCapability("tx.audio", "audioConnect", {
userId: String(auth.user.id || ""),
reason: "plugin-connect"
}, { user: auth.user, skipTxSafety: true });
const audio = await executeCapability("tx.audio", "audioStatus", { userId: String(auth.user.id || "") }, { user: auth.user, skipTxSafety: true });
return sendJson(res, 200, { ok: true, audio });
} catch {
// fall through
}
}
return sendError(res, 409, "tx.audio.start_failed", String(error && error.message ? error.message : error));
}
}
async function handleOpenWebRxPluginAudioDisconnect(req, res, url) {
const auth = await requireOpenWebRxTicketUser(req, res, url);
if (!auth) {
return;
}
try {
await executeCapability("tx.audio", "audioDisconnect", {
userId: String(auth.user.id || ""),
reason: "plugin-disconnect"
}, { user: auth.user, skipTxSafety: true });
} catch {
// best effort
}
let audio = null;
try {
audio = await executeCapability("tx.audio", "audioStatus", { userId: String(auth.user.id || "") }, { user: auth.user, skipTxSafety: true });
} catch {
audio = null;
}
return sendJson(res, 200, {
ok: true,
audio
});
}
async function handleOpenWebRxPluginBandSelect(req, res, url, body) {
const auth = await requireOpenWebRxTicketUser(req, res, url);
if (!auth) {
return;
}
return handleOpenWebRxBandSelect(res, auth.user, body);
}
async function handleOpenWebRxPluginRotorSet(req, res, url, body) {
const auth = await requireOpenWebRxTicketUser(req, res, url);
if (!auth) {
return;
}
return handleOpenWebRxRotorSet(res, auth.user, body, "owrx-plugin");
}
function txAudioEnabled() {
return String(config.txAudioEnabled || "true").trim().toLowerCase() !== "false";
}
function txAudioStopOnDisconnect() {
return String(config.txAudioStopOnDisconnect || "true").trim().toLowerCase() !== "false";
}
function txAudioInputFormatArg() {
const value = String(config.txAudioInputMime || "webm").trim().toLowerCase();
if (value === "ogg") {
return "ogg";
}
return "webm";
}
function txAudioChunkMs() {
const value = Number(config.txAudioChunkMs);
if (!Number.isFinite(value)) {
return 100;
}
return Math.max(40, Math.min(2000, Math.floor(value)));
}
function buildTxAudioStatusPayload(user) {
const owner = runtime.txAudio.ownerUserId || null;
const currentUserId = user && user.id ? String(user.id) : null;
const enabled = txAudioEnabled();
let state = "disconnected";
if (!enabled) {
state = "disabled";
} else if (runtime.txAudio.running) {
state = "running";
} else if (runtime.txAudio.lastError) {
state = "error";
}
return {
enabled,
state,
running: Boolean(runtime.txAudio.running),
clients: runtime.txAudio.clients ? runtime.txAudio.clients.size : 0,
ownerUserId: owner,
ownerMatchesCaller: Boolean(owner && currentUserId && owner === currentUserId),
startedAt: runtime.txAudio.startedAt || null,
lastError: runtime.txAudio.lastError || null,
lastExit: runtime.txAudio.lastExit || null,
ffmpegPath: resolveTxAudioFfmpegPath() || null,
alsaDevice: runtime.txAudio.alsaDevice || (String(config.txAudioAlsaDevice || "plughw:CARD=CODEC,DEV=0").trim() || "plughw:CARD=CODEC,DEV=0"),
chunkMs: txAudioChunkMs(),
wsPath: "/v1/openwebrx/plugin/audio/ws"
};
}
function clearTxAudioIdleTimer() {
if (!runtime.txAudio.idleTimer) {
return;
}
clearTimeout(runtime.txAudio.idleTimer);
runtime.txAudio.idleTimer = null;
}
function scheduleTxAudioIdleStop() {
clearTxAudioIdleTimer();
const timeoutMs = Number.isFinite(Number(config.txAudioSessionTimeoutMs))
? Math.max(1000, Math.floor(Number(config.txAudioSessionTimeoutMs)))
: 120000;
runtime.txAudio.idleTimer = setTimeout(() => {
stopTxAudioBridge("idle-timeout", null).catch(() => {});
}, timeoutMs);
if (runtime.txAudio.idleTimer && typeof runtime.txAudio.idleTimer.unref === "function") {
runtime.txAudio.idleTimer.unref();
}
}
function spawnTxAudioFfmpeg(alsaDeviceOverride = null) {
const ffmpegPath = resolveTxAudioFfmpegPath();
if (!ffmpegPath) {
throw new Error("ffmpeg binary not found (set TX_AUDIO_FFMPEG_PATH)");
}
const alsaDevice = String(alsaDeviceOverride || config.txAudioAlsaDevice || "plughw:CARD=CODEC,DEV=0").trim() || "plughw:CARD=CODEC,DEV=0";
const args = [
"-hide_banner",
"-loglevel", "warning",
"-fflags", "+nobuffer",
"-flags", "low_delay",
"-thread_queue_size", "1024",
"-f", txAudioInputFormatArg(),
"-i", "pipe:0",
"-ac", "2",
"-f", "alsa",
alsaDevice
];
const extra = splitCommand(String(config.txAudioFfmpegExtraArgs || ""));
if (extra.length > 0) {
args.splice(args.length - 2, 0, ...extra);
}
const proc = spawn(ffmpegPath, args, {
stdio: ["pipe", "pipe", "pipe"],
cwd: process.cwd(),
env: process.env
});
return proc;
}
function txAudioAlsaCandidates() {
const configured = String(config.txAudioAlsaDevice || "plughw:CARD=CODEC,DEV=0").trim() || "plughw:CARD=CODEC,DEV=0";
const candidates = [configured, "default", "plughw:0,0"];
return [...new Set(candidates.map((entry) => String(entry || "").trim()).filter(Boolean))];
}
function resolveTxAudioFfmpegPath() {
const configured = String(config.txAudioFfmpegPath || "").trim();
const linuxCandidates = [
"/usr/bin/ffmpeg",
"/usr/local/bin/ffmpeg",
"/bin/ffmpeg"
];
if (configured) {
if (configured.includes(path.sep) || configured.includes("/")) {
if (fs.existsSync(configured)) {
return configured;
}
const fallbackName = path.basename(configured) || "ffmpeg";
for (const candidate of linuxCandidates) {
if (fs.existsSync(candidate) && path.basename(candidate) === fallbackName) {
return candidate;
}
}
return fallbackName;
}
if (process.platform === "linux") {
for (const candidate of linuxCandidates) {
if (fs.existsSync(candidate) && path.basename(candidate) === configured) {
return candidate;
}
}
}
return configured;
}
if (process.platform === "linux") {
for (const candidate of linuxCandidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
}
return "ffmpeg";
}
async function ensureTxAudioBridgeForOwner(user, reason) {
if (!txAudioEnabled()) {
return;
}
if (!user || !user.id) {
throw new Error("no user context for tx audio");
}
const ownerUserId = String(user.id);
if (runtime.txAudio.running) {
if (runtime.txAudio.ownerUserId && runtime.txAudio.ownerUserId !== ownerUserId) {
const hasClients = runtime.txAudio.clients && runtime.txAudio.clients.size > 0;
if (!hasClients) {
await stopTxAudioBridge("owner-handover", null).catch(() => {});
await waitMs(100);
}
}
if (runtime.txAudio.running && runtime.txAudio.ownerUserId && runtime.txAudio.ownerUserId !== ownerUserId) {
throw new Error("TX Audio wird bereits von einem anderen Benutzer verwendet");
}
runtime.txAudio.ownerUserId = ownerUserId;
clearTxAudioIdleTimer();
return;
}
clearTxAudioIdleTimer();
const startupErrors = [];
for (const candidateDevice of txAudioAlsaCandidates()) {
runtime.txAudio.lastError = null;
let proc;
try {
proc = spawnTxAudioFfmpeg(candidateDevice);
} catch (error) {
startupErrors.push(`${candidateDevice}: ${String(error && error.message ? error.message : error)}`);
continue;
}
runtime.txAudio.ffmpeg = proc;
runtime.txAudio.running = true;
runtime.txAudio.startedAt = new Date().toISOString();
runtime.txAudio.ownerUserId = ownerUserId;
runtime.txAudio.alsaDevice = candidateDevice;
runtime.txAudio.stopRequested = false;
let stderrBuffer = "";
if (proc.stderr) {
proc.stderr.on("data", (chunk) => {
const text = String(chunk || "");
stderrBuffer = `${stderrBuffer}${text}`.slice(-4000);
if (!runtime.txAudio.stopRequested && text.trim()) {
runtime.txAudio.lastError = text.trim();
}
});
}
proc.on("error", (error) => {
if (!runtime.txAudio.stopRequested) {
runtime.txAudio.lastError = String(error && error.message ? error.message : error);
}
});
proc.on("close", (code, signal) => {
runtime.txAudio.lastExit = {
at: new Date().toISOString(),
code: Number.isFinite(Number(code)) ? Number(code) : null,
signal: signal || null,
stderr: stderrBuffer || null
};
runtime.txAudio.running = false;
runtime.txAudio.ffmpeg = null;
runtime.txAudio.startedAt = null;
runtime.txAudio.ownerUserId = null;
runtime.txAudio.alsaDevice = null;
runtime.txAudio.stopRequested = false;
clearTxAudioIdleTimer();
for (const ws of runtime.txAudio.clients) {
try {
ws.close(1011, "audio backend closed");
} catch {
// ignore
}
}
runtime.txAudio.clients.clear();
});
await waitMs(180);
if (runtime.txAudio.running) {
await appendAudit("openwebrx.audio.start", user, { reason, alsaDevice: candidateDevice });
return;
}
startupErrors.push(`${candidateDevice}: ${runtime.txAudio.lastError || "start failed"}`);
}
runtime.txAudio.alsaDevice = null;
throw new Error(startupErrors.length > 0 ? startupErrors.join(" | ") : "TX Audio Backend konnte nicht gestartet werden");
}
async function stopTxAudioBridge(reason, user) {
clearTxAudioIdleTimer();
const proc = runtime.txAudio.ffmpeg;
runtime.txAudio.stopRequested = true;
if (!proc || !runtime.txAudio.running) {
runtime.txAudio.running = false;
runtime.txAudio.ffmpeg = null;
runtime.txAudio.ownerUserId = null;
runtime.txAudio.alsaDevice = null;
runtime.txAudio.stopRequested = false;
runtime.txAudio.lastError = null;
for (const ws of runtime.txAudio.clients) {
try {
ws.close(1000, "audio disconnected");
} catch {
// ignore
}
}
runtime.txAudio.clients.clear();
return;
}
for (const ws of runtime.txAudio.clients) {
try {
ws.close(1000, "audio disconnected");
} catch {
// ignore
}
}
runtime.txAudio.clients.clear();
try {
if (proc.stdin && !proc.stdin.destroyed) {
proc.stdin.end();
}
} catch {
// ignore
}
await waitMs(150);
if (runtime.txAudio.running && !proc.killed) {
try {
proc.kill("SIGTERM");
} catch {
// ignore
}
}
if (user) {
await appendAudit("openwebrx.audio.stop", user, { reason, alsaDevice: runtime.txAudio.alsaDevice || null }).catch(() => {});
}
runtime.txAudio.lastError = null;
}
function initTxAudioWebSocket(server) {
const wss = new WebSocketServer({ noServer: true });
runtime.txAudio.wsServer = wss;
server.on("upgrade", (req, socket, head) => {
let url;
try {
url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
} catch {
socket.destroy();
return;
}
if (url.pathname !== "/v1/openwebrx/plugin/audio/ws") {
socket.destroy();
return;
}
resolveOpenWebRxTicketAccess(req, url).then((access) => {
if (!access || !access.ok || !access.user) {
socket.destroy();
return;
}
if (denyByStationOwnerForUser(access.user)) {
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
ws._rmsUser = access.user;
wss.emit("connection", ws, req);
});
}).catch(() => {
socket.destroy();
});
});
wss.on("connection", async (ws) => {
const user = ws._rmsUser;
const userId = String(user && user.id ? user.id : "");
try {
await executeCapability("tx.audio", "audioRegisterClient", {
ws,
userId,
reason: "ws-connect"
}, { user, skipTxSafety: true });
} catch (error) {
try {
ws.close(1011, String(error && error.message ? error.message : error));
} catch {
// ignore
}
return;
}
ws.on("message", async (message, isBinary) => {
if (!isBinary) {
return;
}
try {
await executeCapability("tx.audio", "audioWriteChunk", {
ws,
userId,
chunk: Buffer.isBuffer(message) ? message : Buffer.from(message)
}, { user, skipTxSafety: true });
} catch {
// ignore chunk failure
}
});
ws.on("close", () => {
executeCapability("tx.audio", "audioUnregisterClient", {
ws,
userId,
reason: "ws-disconnect"
}, { user, skipTxSafety: true }).catch(() => {});
});
ws.on("error", () => {
executeCapability("tx.audio", "audioUnregisterClient", {
ws,
userId,
reason: "ws-error"
}, { user, skipTxSafety: true }).catch(() => {});
});
});
}
function denyByStationOwnerForUser(user) {
if (!runtime.station || !runtime.station.isInUse || !runtime.station.activeByUserId) {
return false;
}
return String(user && user.id ? user.id : "") !== String(runtime.station.activeByUserId || "");
}
async function syncStationAccessPolicyOwner(user, ownerEmail) {
const provider = runtime.pluginState.providers["admin.station.access.policy.write"];
if (!provider) {
return;
}
try {
await executeCapability("admin.station.access.policy.write", "syncOwner", { ownerEmail }, { user, skipTxSafety: true });
} catch {
// optional capability
}
}
async function clearStationAccessPolicyOwner(user) {
const provider = runtime.pluginState.providers["admin.station.access.policy.write"];
if (!provider) {
return;
}
try {
await executeCapability("admin.station.access.policy.write", "clearOwner", {}, { user, skipTxSafety: true });
} catch {
// optional capability
}
}
async function requireOpenWebRxTicketUser(req, res, url) {
try {
const result = await resolveOpenWebRxTicketAccess(req, url);
if (!result || !result.ok || !result.user) {
res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ ok: false, error: "openwebrx.forbidden", message: "OpenWebRX Zugriff verweigert" }));
return null;
}
return { user: result.user };
} catch {
res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ ok: false, error: "openwebrx.forbidden", message: "OpenWebRX Zugriff verweigert" }));
return null;
}
}
async function resolveOpenWebRxTicketAccess(req, url) {
if (canAuthorizeOpenWebRxByActiveSession()) {
const activeUser = runtime.users.find((entry) => String(entry.id) === String(runtime.station.activeByUserId || ""));
if (activeUser) {
return { ok: true, user: activeUser };
}
}
const queryTicket = String(url && url.searchParams ? (url.searchParams.get("ticket") || "") : "").trim();
const cookieTicket = readCookie(req.headers && req.headers.cookie, "rms_owrx_ticket");
const originalUriHeader = req.headers ? (req.headers["x-original-uri"] || req.headers["x-rewrite-uri"] || "") : "";
const originalUriTicket = extractTicketFromUri(originalUriHeader);
const refererHeader = req.headers ? (req.headers.referer || req.headers.referrer || "") : "";
const refererTicket = extractTicketFromUri(refererHeader);
const tickets = [];
if (queryTicket) tickets.push(queryTicket);
if (cookieTicket && cookieTicket !== queryTicket) tickets.push(cookieTicket);
if (originalUriTicket && !tickets.includes(originalUriTicket)) tickets.push(originalUriTicket);
if (refererTicket && !tickets.includes(refererTicket)) tickets.push(refererTicket);
if (tickets.length === 0) {
const fallbackUser = resolveOpenWebRxOwnerFromRequestContext(req, url);
if (fallbackUser) {
return { ok: true, user: fallbackUser };
}
return { ok: false };
}
const provider = runtime.pluginState.providers["openwebrx.access.verify"];
if (!provider) {
return { ok: false };
}
let verified = null;
for (const ticket of tickets) {
const candidate = await executeCapability("openwebrx.access.verify", "verifyAccess", { ticket }, { skipTxSafety: true });
if (candidate && candidate.ok) {
verified = candidate;
break;
}
}
const ownerUserId = runtime.station.activeByUserId;
const allowed = Boolean(
verified &&
verified.ok &&
runtime.station.isInUse &&
ownerUserId &&
String(verified.userId) === String(ownerUserId)
);
if (!allowed) {
const fallbackUser = resolveOpenWebRxOwnerFromRequestContext(req, url);
if (fallbackUser) {
return { ok: true, user: fallbackUser };
}
return { ok: false };
}
let user = runtime.users.find((entry) => String(entry.id) === String(verified.userId));
if (!user) {
user = {
id: String(verified.userId),
email: String(runtime.station.activeByEmail || "openwebrx-owner"),
role: "operator"
};
}
markOpenWebRxSession(user, verified);
return { ok: true, user };
}
function resolveOpenWebRxOwnerFromRequestContext(req, url) {
if (!runtime.station || !runtime.station.isInUse || !runtime.station.activeByUserId) {
return null;
}
const openWebRxPath = String(config.openWebRxPath || "/sdr/").trim() || "/sdr/";
const pathname = String(url && url.pathname ? url.pathname : "");
const originalUri = String(req && req.headers ? (req.headers["x-original-uri"] || req.headers["x-rewrite-uri"] || "") : "");
const referer = String(req && req.headers ? (req.headers.referer || req.headers.referrer || "") : "");
const looksLikeOpenWebRxContext = pathname.startsWith("/v1/openwebrx/plugin/")
|| originalUri.includes(openWebRxPath)
|| referer.includes(openWebRxPath);
if (!looksLikeOpenWebRxContext) {
return null;
}
return runtime.users.find((entry) => String(entry.id) === String(runtime.station.activeByUserId || "")) || null;
}
async function handleOpenWebRxAuthorize(req, res, url) {
if (canAuthorizeOpenWebRxByActiveSession()) {
await ensureOpenWebRxSdrPath(null, { force: false, minIntervalMs: 3000 });
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end("ok");
return;
}
const queryTicket = String(url.searchParams.get("ticket") || "").trim();
const cookieTicket = readCookie(req.headers && req.headers.cookie, "rms_owrx_ticket");
const originalUriHeader = req.headers ? (req.headers["x-original-uri"] || req.headers["x-rewrite-uri"] || "") : "";
const originalUriTicket = extractTicketFromUri(originalUriHeader);
const refererHeader = req.headers ? (req.headers.referer || req.headers.referrer || "") : "";
const refererTicket = extractTicketFromUri(refererHeader);
const tickets = [];
if (queryTicket) {
tickets.push(queryTicket);
}
if (cookieTicket && cookieTicket !== queryTicket) {
tickets.push(cookieTicket);
}
if (originalUriTicket && !tickets.includes(originalUriTicket)) {
tickets.push(originalUriTicket);
}
if (refererTicket && !tickets.includes(refererTicket)) {
tickets.push(refererTicket);
}
if (tickets.length === 0) {
if (canAuthorizeOpenWebRxWebSocketWithoutTicket(req, originalUriHeader)) {
await ensureOpenWebRxSdrPath(null, { force: false, minIntervalMs: 3000 });
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end("ok");
return;
}
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
res.end("forbidden");
return;
}
const provider = runtime.pluginState.providers["openwebrx.access.verify"];
if (!provider) {
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
res.end("forbidden");
return;
}
try {
let result = null;
for (const ticket of tickets) {
const candidate = await executeCapability("openwebrx.access.verify", "verifyAccess", { ticket }, { skipTxSafety: true });
if (candidate && candidate.ok) {
result = candidate;
break;
}
}
const ownerUserId = runtime.station.activeByUserId;
const allowed = Boolean(
result &&
result.ok &&
runtime.station.isInUse &&
ownerUserId &&
result.userId === ownerUserId
);
if (!allowed) {
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
res.end("forbidden");
return;
}
markOpenWebRxSession({ id: result.userId }, result);
await ensureOpenWebRxSdrPath(null, { force: false, minIntervalMs: 3000 });
res.writeHead(200, {
"Content-Type": "text/plain; charset=utf-8",
"X-RMS-User-Id": String(result.userId)
});
res.end("ok");
} catch {
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
res.end("forbidden");
}
}
function readCookie(cookieHeader, name) {
const target = String(name || "").trim();
if (!target) {
return "";
}
const raw = String(cookieHeader || "");
if (!raw) {
return "";
}
const pairs = raw.split(";");
let lastValue = "";
for (const pair of pairs) {
const idx = pair.indexOf("=");
if (idx === -1) {
continue;
}
const key = pair.slice(0, idx).trim();
if (key !== target) {
continue;
}
const value = pair.slice(idx + 1).trim();
if (!value) {
continue;
}
try {
lastValue = decodeURIComponent(value);
} catch {
lastValue = value;
}
}
return lastValue;
}
function extractTicketFromUri(rawUri) {
const input = String(rawUri || "").trim();
if (!input) {
return "";
}
const queryIndex = input.indexOf("?");
if (queryIndex === -1) {
return "";
}
const query = input.slice(queryIndex + 1);
const params = new URLSearchParams(query);
return String(params.get("ticket") || "").trim();
}
function canAuthorizeOpenWebRxWebSocketWithoutTicket(req, originalUriHeader) {
const headers = req && req.headers ? req.headers : {};
const upgrade = String(headers.upgrade || "").toLowerCase();
const hasWebSocketKey = Boolean(headers["sec-websocket-key"]);
const isWebSocket = upgrade === "websocket" || hasWebSocketKey;
if (!isWebSocket) {
return false;
}
if (!runtime.station || !runtime.station.isInUse || !runtime.station.activeByUserId) {
return false;
}
return true;
}
function canAuthorizeOpenWebRxByActiveSession() {
if (!runtime.station || !runtime.station.isInUse || !runtime.station.activeByUserId) {
return false;
}
return true;
}
async function handleHelpContent(res, user) {
if (!hasRole(user, ["operator", "approver", "admin"])) {
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
}
const provider = runtime.pluginState.providers["help.content.read"];
if (!provider) {
return sendJson(res, 200, {
content: {
version: 1,
title: "RMS Hilfe",
quickStart: {
title: "Schnellstart",
steps: ["Plugin 'rms.help.basic' aktivieren, um Hilfetexte anzuzeigen."]
},
sections: []
}
});
}
const content = await executeCapability("help.content.read", "getContent", {}, { user, skipTxSafety: true });
return sendJson(res, 200, { content });
}
async function handleDebugCollect(req, res, url, scope = "owrx") {
const auth = authorizeDebugRequest(req, res, url);
if (!auth) {
return;
}
const settings = getDebugRemoteSettings();
if (!settings.enabled) {
return sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert");
}
const requestedLines = Number(url.searchParams.get("lines") || "");
const lineOverride = Number.isFinite(requestedLines) ? Math.max(50, Math.min(4000, Math.trunc(requestedLines))) : null;
const result = await collectDebugLogs(scope, settings, lineOverride);
return sendJson(res, 200, {
ok: true,
scope,
collectedAt: result.collectedAt,
totalLines: result.totalLines,
keptLines: result.keptLines,
outputPath: result.outputPath,
snapshotPath: result.snapshotPath
});
}
async function handleDebugLogs(req, res, url, scope = "owrx") {
const auth = authorizeDebugRequest(req, res, url);
if (!auth) {
return;
}
const settings = getDebugRemoteSettings();
if (!settings.enabled) {
return sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert");
}
const paths = resolveDebugPaths(scope);
let raw = "";
try {
raw = await fsp.readFile(paths.logFilePath, "utf8");
} catch {
raw = "";
}
const requestedLines = Number(url.searchParams.get("lines") || settings.collectLines);
const lineLimit = Math.max(50, Math.min(4000, Number.isFinite(requestedLines) ? Math.trunc(requestedLines) : settings.collectLines));
const lines = raw.split(/\r?\n/).filter(Boolean);
const tail = lines.slice(Math.max(0, lines.length - lineLimit));
const snapshot = await readDebugSnapshot(scope);
return sendJson(res, 200, {
ok: true,
scope,
lines: tail,
total: lines.length,
limit: lineLimit,
collectedAt: snapshot && snapshot.collectedAt ? snapshot.collectedAt : null
});
}
async function handleDebugSnapshot(req, res, url, scope = "owrx") {
const auth = authorizeDebugRequest(req, res, url);
if (!auth) {
return;
}
const settings = getDebugRemoteSettings();
if (!settings.enabled) {
return sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert");
}
let snapshot = buildDebugRuntimeSnapshot(scope);
const saved = await readDebugSnapshot(scope);
if (saved && typeof saved === "object") {
snapshot = {
...snapshot,
...saved,
ok: true
};
}
return sendJson(res, 200, snapshot);
}
async function handleDebugClear(req, res, url, scope = "owrx") {
const auth = authorizeDebugRequest(req, res, url);
if (!auth) {
return;
}
const settings = getDebugRemoteSettings();
if (!settings.enabled) {
return sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert");
}
const paths = resolveDebugPaths(scope);
await fsp.mkdir(paths.debugDir, { recursive: true });
await fsp.writeFile(paths.logFilePath, "", "utf8");
await fsp.writeFile(paths.snapshotFilePath, JSON.stringify({ scope, collectedAt: new Date().toISOString(), cleared: true }, null, 2), "utf8");
return sendJson(res, 200, { ok: true, scope, cleared: true });
}
async function handleDebugWhich(req, res, url) {
const auth = authorizeDebugRequest(req, res, url);
if (!auth) {
return;
}
const settings = getDebugRemoteSettings();
if (!settings.enabled) {
return sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert");
}
const name = String(url.searchParams.get("bin") || url.searchParams.get("name") || "ffmpeg").trim().toLowerCase();
if (!/^[a-z0-9._+-]{1,64}$/.test(name)) {
return sendError(res, 400, "debug.which.invalid", "ungueltiger binary name");
}
const found = await resolveBinaryPath(name);
const collectedAt = new Date().toISOString();
const paths = resolveDebugPaths("which");
await fsp.mkdir(paths.debugDir, { recursive: true });
const line = `${collectedAt} ${name} => ${found.path || "NOT_FOUND"}`;
await fsp.appendFile(paths.logFilePath, `${line}\n`, "utf8");
const snapshot = {
ok: true,
scope: "which",
collectedAt,
name,
found: found.found,
path: found.path || null
};
await fsp.writeFile(paths.snapshotFilePath, JSON.stringify(snapshot, null, 2), "utf8");
return sendJson(res, 200, snapshot);
}
async function resolveBinaryPath(name) {
if (process.platform === "win32") {
const whereResult = await runCommand(`where ${name}`, { timeoutMs: 4000 });
const pathLine = whereResult.ok ? firstOutputLine(whereResult.stdout || "") : "";
return { found: Boolean(pathLine), path: pathLine || "" };
}
const commandResult = await runCommand(`command -v ${name}`, { timeoutMs: 4000 });
let pathLine = commandResult.ok ? firstOutputLine(commandResult.stdout || "") : "";
if (!pathLine) {
const whichResult = await runCommand(`which ${name}`, { timeoutMs: 4000 });
pathLine = whichResult.ok ? firstOutputLine(whichResult.stdout || "") : "";
}
return { found: Boolean(pathLine), path: pathLine || "" };
}
function firstOutputLine(value) {
const lines = String(value || "").split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean);
return lines.length > 0 ? lines[0] : "";
}
function authorizeDebugRequest(req, res, url) {
if (!DEBUG_REMOTE_INTERFACE_ENABLED) {
sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert");
return null;
}
const settings = getDebugRemoteSettings();
const token = readDebugBearerToken(req);
if (token && token === settings.remoteToken) {
return { via: "token" };
}
const auth = requireAuth(req, res);
if (!auth) {
return null;
}
if (!hasRole(auth.user, ["admin"])) {
sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
return null;
}
return { via: "auth", user: auth.user };
}
function readDebugBearerToken(req) {
const header = String(req && req.headers ? (req.headers.authorization || "") : "").trim();
if (!header) {
return "";
}
const match = /^Bearer\s+(.+)$/i.exec(header);
return match ? String(match[1] || "").trim() : "";
}
function getDebugRemoteSettings() {
const pluginId = "rms.debug.remote";
const current = runtime.pluginState && runtime.pluginState.settings && runtime.pluginState.settings[pluginId]
? runtime.pluginState.settings[pluginId]
: {};
const collectLinesRaw = Number(current.collectLines);
const collectLines = Number.isFinite(collectLinesRaw) ? Math.max(100, Math.min(4000, Math.trunc(collectLinesRaw))) : 800;
const unitName = String(current.unitName || "remotestation-arcg").trim() || "remotestation-arcg";
const enabled = current.enabled !== false;
const redactSensitive = current.redactSensitive !== false;
const includePatternsRaw = String(current.includePatterns || "").trim();
const includePatterns = includePatternsRaw
? includePatternsRaw.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean)
: defaultDebugIncludePatterns("owrx");
const includePatternsUsbRaw = String(current.includePatternsUsb || "").trim();
const includePatternsUsb = includePatternsUsbRaw
? includePatternsUsbRaw.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean)
: defaultDebugIncludePatterns("usb");
const includePatternsAlsaRaw = String(current.includePatternsAlsa || "").trim();
const includePatternsAlsa = includePatternsAlsaRaw
? includePatternsAlsaRaw.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean)
: defaultDebugIncludePatterns("alsa");
const includePatternsSoapyRaw = String(current.includePatternsSoapy || "").trim();
const includePatternsSoapy = includePatternsSoapyRaw
? includePatternsSoapyRaw.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean)
: defaultDebugIncludePatterns("soapy");
return {
enabled,
remoteToken: String(current.remoteToken || DEBUG_REMOTE_DEFAULT_TOKEN).trim(),
collectLines,
includePatterns,
includePatternsUsb,
includePatternsAlsa,
includePatternsSoapy,
redactSensitive,
unitName
};
}
function defaultDebugIncludePatterns(scope) {
if (scope === "usb" || scope === "which" || scope === "alsa" || scope === "soapy") {
return [];
}
return [
"openwebrx/plugin/state",
"openwebrx/plugin/bands/select",
"openwebrx/plugin/audio/connect",
"openwebrx/rotor/status",
"upstream timed out",
"connection refused",
"prematurely closed"
];
}
function resolveDebugPaths(scope = "owrx") {
const normalizedScope = String(scope || "owrx").toLowerCase();
const safeScope = normalizedScope === "usb"
? "usb"
: (normalizedScope === "which"
? "which"
: (normalizedScope === "alsa" ? "alsa" : (normalizedScope === "soapy" ? "soapy" : "owrx")));
const debugDir = path.join(config.dataDir, "debug");
return {
debugDir,
logFilePath: path.join(debugDir, `${safeScope}-debug.log`),
snapshotFilePath: path.join(debugDir, `${safeScope}-debug-snapshot.json`)
};
}
async function collectDebugLogs(scope, settings, lineOverride = null) {
const normalizedScope = String(scope || "owrx").toLowerCase();
const safeScope = normalizedScope === "usb"
? "usb"
: (normalizedScope === "alsa" ? "alsa" : (normalizedScope === "soapy" ? "soapy" : "owrx"));
const paths = resolveDebugPaths(safeScope);
await fsp.mkdir(paths.debugDir, { recursive: true });
const collectLines = Number.isFinite(Number(lineOverride))
? Math.max(50, Math.min(4000, Math.trunc(Number(lineOverride))))
: settings.collectLines;
let collection;
if (safeScope === "usb") {
collection = await collectUsbDebugLogs(collectLines);
} else if (safeScope === "alsa") {
collection = await collectAlsaDebugLogs();
} else if (safeScope === "soapy") {
collection = await collectSoapyDebugLogs();
} else {
collection = await collectOpenWebRxDebugLogs(settings.unitName, collectLines);
}
const allLines = collection.lines;
const kept = filterDebugLines(
allLines,
safeScope === "usb"
? settings.includePatternsUsb
: (safeScope === "alsa"
? settings.includePatternsAlsa
: (safeScope === "soapy" ? settings.includePatternsSoapy : settings.includePatterns)),
settings.redactSensitive
);
const collectedAt = new Date().toISOString();
await fsp.writeFile(paths.logFilePath, kept.join("\n") + (kept.length ? "\n" : ""), "utf8");
const snapshot = safeScope === "usb"
? {
...buildDebugRuntimeSnapshot("usb"),
collectedAt,
scope: "usb",
collectLines,
totalLines: allLines.length,
keptLines: kept.length,
commands: collection.commands
}
: (safeScope === "alsa"
? {
...buildDebugRuntimeSnapshot("alsa"),
collectedAt,
scope: "alsa",
collectLines,
totalLines: allLines.length,
keptLines: kept.length,
commands: collection.commands
}
: (safeScope === "soapy"
? {
...buildDebugRuntimeSnapshot("soapy"),
collectedAt,
scope: "soapy",
collectLines,
totalLines: allLines.length,
keptLines: kept.length,
commands: collection.commands
}
: {
...buildDebugRuntimeSnapshot("owrx"),
collectedAt,
scope: "owrx",
collectLines,
totalLines: allLines.length,
keptLines: kept.length,
unitName: settings.unitName
}));
await fsp.writeFile(paths.snapshotFilePath, JSON.stringify(snapshot, null, 2), "utf8");
return {
scope: safeScope,
collectedAt,
totalLines: allLines.length,
keptLines: kept.length,
outputPath: paths.logFilePath,
snapshotPath: paths.snapshotFilePath
};
}
async function collectOpenWebRxDebugLogs(unitName, collectLines) {
const cmd = `sudo -n journalctl -u ${unitName} -n ${collectLines} --no-pager -o short-iso || journalctl -u ${unitName} -n ${collectLines} --no-pager -o short-iso`;
const result = await runCommand(cmd, { timeoutMs: 12000 });
const raw = String(result.stdout || result.stderr || "");
return {
lines: raw.split(/\r?\n/).filter(Boolean),
commands: [
{
command: cmd,
ok: Boolean(result.ok),
code: result.code
}
]
};
}
async function collectUsbDebugLogs(collectLines) {
const commands = [
"id",
"whoami",
"groups",
"command -v usbrelay",
"command -v rigctl",
"usbrelay --help",
"rigctl --version",
"ls -l /dev/rms-*",
"ls -l /dev/ttyUSB*",
"ls -l /dev/serial/by-id",
"lsusb",
"lsusb -t",
`sudo -n journalctl -k -n ${collectLines} --no-pager -o short-iso || journalctl -k -n ${collectLines} --no-pager -o short-iso`,
"sudo -n dmesg -T | tail -n 300 || dmesg -T | tail -n 300",
"sudo -n journalctl -u remotestation-arcg -n 300 --no-pager -o short-iso || journalctl -u remotestation-arcg -n 300 --no-pager -o short-iso"
];
const all = [];
const meta = [];
for (const command of commands) {
const result = await runCommand(command, { timeoutMs: 12000 });
const body = String(result.stdout || result.stderr || "").trim();
all.push(`[cmd] ${command}`);
all.push(`[ok=${result.ok ? "true" : "false"} code=${result.code}]`);
if (body) {
for (const line of body.split(/\r?\n/)) {
all.push(line);
}
}
all.push("");
meta.push({ command, ok: Boolean(result.ok), code: result.code });
}
return {
lines: all.filter((line, idx, arr) => line || (idx > 0 && arr[idx - 1])),
commands: meta
};
}
async function collectAlsaDebugLogs() {
const commands = [
"aplay -l",
"aplay -L",
"arecord -l",
"arecord -L",
"cat /proc/asound/cards",
"cat /proc/asound/devices"
];
const all = [];
const meta = [];
for (const command of commands) {
const result = await runCommand(command, { timeoutMs: 12000 });
const body = String(result.stdout || result.stderr || "").trim();
all.push(`[cmd] ${command}`);
all.push(`[ok=${result.ok ? "true" : "false"} code=${result.code}]`);
if (body) {
for (const line of body.split(/\r?\n/)) {
all.push(line);
}
}
all.push("");
meta.push({ command, ok: Boolean(result.ok), code: result.code });
}
return {
lines: all.filter((line, idx, arr) => line || (idx > 0 && arr[idx - 1])),
commands: meta
};
}
async function collectSoapyDebugLogs() {
const commands = [
"id",
"groups",
"lsusb -d 03eb:800c",
"soapy_connector --listdrivers",
"soapy_connector --listmodules",
"SoapySDRUtil --find=\"driver=airspyhf\"",
"SoapySDRUtil --find",
"ls -l /dev/bus/usb/*/*"
];
const all = [];
const meta = [];
for (const command of commands) {
const result = await runCommand(command, { timeoutMs: 12000 });
const body = String(result.stdout || result.stderr || "").trim();
all.push(`[cmd] ${command}`);
all.push(`[ok=${result.ok ? "true" : "false"} code=${result.code}]`);
if (body) {
for (const line of body.split(/\r?\n/)) {
all.push(line);
}
}
all.push("");
meta.push({ command, ok: Boolean(result.ok), code: result.code });
}
return {
lines: all.filter((line, idx, arr) => line || (idx > 0 && arr[idx - 1])),
commands: meta
};
}
function buildDebugRuntimeSnapshot(scope) {
if (scope === "usb") {
return {
ok: true,
scope: "usb",
collectedAt: null,
station: buildStationStatusView(),
usb: {
note: "USB snapshot from command dump"
}
};
}
if (scope === "which") {
return {
ok: true,
scope: "which",
collectedAt: null,
station: buildStationStatusView(),
which: {
note: "binary path checks"
}
};
}
if (scope === "alsa") {
return {
ok: true,
scope: "alsa",
collectedAt: null,
station: buildStationStatusView(),
alsa: {
note: "ALSA playback/capture device dump"
}
};
}
if (scope === "soapy") {
return {
ok: true,
scope: "soapy",
collectedAt: null,
station: buildStationStatusView(),
soapy: {
note: "SoapySDR and USB access diagnostics"
}
};
}
return {
ok: true,
scope: "owrx",
collectedAt: null,
station: buildStationStatusView(),
openwebrx: {
pttActive: runtime.pttActive,
antennaRoute: runtime.openWebRxAntennaRoute || null,
session: runtime.openWebRxSession || null
},
txAudio: {
running: Boolean(runtime.txAudio && runtime.txAudio.running),
clients: runtime.txAudio && runtime.txAudio.clients ? runtime.txAudio.clients.size : 0,
lastError: runtime.txAudio ? runtime.txAudio.lastError || null : null,
lastExit: runtime.txAudio ? runtime.txAudio.lastExit || null : null
}
};
}
async function readDebugSnapshot(scope = "owrx") {
const paths = resolveDebugPaths(scope);
try {
const raw = await fsp.readFile(paths.snapshotFilePath, "utf8");
return JSON.parse(raw);
} catch {
return null;
}
}
function filterDebugLines(lines, includePatterns, redactSensitive) {
const patterns = Array.isArray(includePatterns)
? includePatterns.map((entry) => String(entry || "").toLowerCase()).filter(Boolean)
: [];
const out = [];
for (const line of lines) {
const normalized = String(line || "");
const lower = normalized.toLowerCase();
if (patterns.length > 0 && !patterns.some((entry) => lower.includes(entry))) {
continue;
}
out.push(redactSensitive ? redactDebugLine(normalized) : normalized);
}
return out;
}
function redactDebugLine(line) {
let next = String(line || "");
next = next.replace(/(accessToken=)[^\s&]+/gi, "$1[redacted]");
next = next.replace(/(ticket=)[^\s&]+/gi, "$1[redacted]");
next = next.replace(/(Authorization:\s*Bearer\s+)[A-Za-z0-9_\-.]+/gi, "$1[redacted]");
next = next.replace(/(eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.)[A-Za-z0-9_\-]+/g, "$1[redacted]");
return next;
}
async function handleUiControlAction(req, res, user, pathname, body) {
const parts = pathname.split("/").filter(Boolean);
const controlsIdx = parts.indexOf("controls");
const actionsIdx = parts.indexOf("actions");
if (controlsIdx === -1 || actionsIdx === -1 || actionsIdx <= controlsIdx + 1) {
return sendError(res, 404, "ui.control.not_found", "Control nicht gefunden");
}
const controlId = decodeURIComponent(parts[controlsIdx + 1]);
const action = decodeURIComponent(parts[actionsIdx + 1] || "");
const input = body && typeof body.input === "object" ? body.input : {};
if (controlId === "station-main") {
if (!hasRole(user, ["operator", "approver", "admin"])) {
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
}
if (action === "activate") {
return handleActivationStart(res, user);
}
if (action === "release") {
return handleStationRelease(res, user);
}
return sendError(res, 404, "ui.action.not_found", "Action nicht gefunden");
}
const controls = await buildUiControls(user);
const control = controls.find((entry) => entry.controlId === controlId);
if (!control) {
return sendError(res, 404, "ui.control.not_found", "Control nicht gefunden");
}
const descriptor = control.actions.find((entry) => entry.name === action);
if (!descriptor) {
return sendError(res, 404, "ui.action.not_found", "Action nicht gefunden");
}
if (!control.capabilityProvider || !control.capabilityProvider.capability) {
return sendError(res, 500, "ui.provider.missing", "Kein Plugin Provider zugewiesen");
}
if (!hasCapability(user, control.capabilityProvider.capability)) {
return sendError(res, 403, "auth.forbidden", "Nicht berechtigt");
}
try {
const result = await executeCapability(control.capabilityProvider.capability, action, input, { user });
return sendJson(res, 200, {
ok: true,
result: {
accepted: true,
message: result && result.message ? result.message : "Action ausgefuehrt",
...result
}
});
} catch (error) {
if (error && error.code === "TX_SWITCH_LOCK") {
return sendError(res, 409, "tx.switch_locked", String(error.message || error), error.details || null);
}
if (error && error.code === "VSWR_WHILE_STATION_ACTIVE") {
return sendError(res, 409, "vswr.unsafe_station_active", String(error.message || error));
}
return sendError(res, 500, "plugin.execute_failed", String(error.message || error));
}
}
async function handlePluginToggle(req, res, user, pathname) {
const match = pathname.match(/^\/v1\/plugins\/([^/]+)\/(enable|disable)$/);
if (!match) {
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
}
const pluginId = decodeURIComponent(match[1]);
const action = match[2];
const plugin = runtime.plugins.get(pluginId);
if (!plugin) {
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
}
if (action === "enable") {
runtime.pluginState.enabled[pluginId] = true;
if (plugin.instance.start) {
await plugin.instance.start();
}
} else {
runtime.pluginState.enabled[pluginId] = false;
if (plugin.instance.stop) {
await plugin.instance.stop();
}
}
await savePluginState();
await appendAudit(`plugin.${action}`, user, { pluginId });
broadcastEvent("plugin.enabled.changed", { pluginId, enabled: runtime.pluginState.enabled[pluginId] });
return sendJson(res, 200, { ok: true, plugin: describePlugin(plugin) });
}
function handlePluginSettingsSchema(res, pathname) {
const match = pathname.match(/^\/v1\/plugins\/([^/]+)\/settings-schema$/);
if (!match) {
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
}
const pluginId = decodeURIComponent(match[1]);
const plugin = runtime.plugins.get(pluginId);
if (!plugin) {
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
}
return sendJson(res, 200, {
pluginId,
settingsSchema: plugin.manifest.settingsSchema || { type: "object", properties: {} }
});
}
function handlePluginSettingsGet(res, pathname) {
const match = pathname.match(/^\/v1\/plugins\/([^/]+)\/settings$/);
if (!match) {
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
}
const pluginId = decodeURIComponent(match[1]);
const plugin = runtime.plugins.get(pluginId);
if (!plugin) {
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
}
return sendJson(res, 200, {
pluginId,
settings: runtime.pluginState.settings[pluginId] || {}
});
}
async function handlePluginSettingsPut(res, user, pathname, body) {
const match = pathname.match(/^\/v1\/plugins\/([^/]+)\/settings$/);
if (!match) {
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
}
const pluginId = decodeURIComponent(match[1]);
const plugin = runtime.plugins.get(pluginId);
if (!plugin) {
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
}
const nextSettings = body && typeof body.settings === "object" ? body.settings : null;
if (!nextSettings) {
return sendError(res, 400, "plugin.settings.invalid", "settings Objekt erforderlich");
}
const validation = validateSettings(plugin.manifest.settingsSchema || { type: "object", properties: {} }, nextSettings);
if (!validation.ok) {
return sendError(res, 400, "plugin.settings.invalid", validation.message);
}
runtime.pluginState.settings[pluginId] = nextSettings;
await savePluginState();
if (typeof plugin.instance.onSettingsUpdated === "function") {
await plugin.instance.onSettingsUpdated(nextSettings);
}
await appendAudit("plugin.settings.update", user, { pluginId });
broadcastEvent("plugin.settings.changed", { pluginId });
return sendJson(res, 200, { ok: true, pluginId, settings: nextSettings });
}
async function handleCapabilityProviderSwitch(res, user, pathname, body) {
const prefix = "/v1/admin/capabilities/";
const capability = decodeURIComponent(pathname.slice(prefix.length, pathname.length - "/provider".length));
const pluginId = typeof (body && body.pluginId) === "string" ? body.pluginId : "";
const dryRun = Boolean(body && body.dryRun);
const plugin = runtime.plugins.get(pluginId);
if (!capability || !pluginId) {
return sendError(res, 400, "provider.invalid_request", "capability und pluginId erforderlich");
}
if (!plugin) {
return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden");
}
if (!plugin.manifest.capabilities.includes(capability)) {
return sendError(res, 409, "provider.capability_mismatch", "Plugin unterstuetzt Capability nicht");
}
const health = await safePluginHealth(plugin);
if (dryRun) {
return sendJson(res, 200, {
capability,
activePluginId: runtime.pluginState.providers[capability] || null,
previousPluginId: runtime.pluginState.providers[capability] || null,
health,
switchedAt: new Date().toISOString()
});
}
const previous = runtime.pluginState.providers[capability] || null;
runtime.pluginState.providers[capability] = pluginId;
await savePluginState();
await appendAudit("provider.switch", user, { capability, pluginId, previous });
broadcastEvent("plugin.provider.changed", { capability, pluginId, previousPluginId: previous });
return sendJson(res, 200, {
capability,
activePluginId: pluginId,
previousPluginId: previous,
health,
switchedAt: new Date().toISOString()
});
}
function handleEventStream(req, res, url) {
const auth = requireAuth(req, res, { allowQueryToken: true, queryTokenUrl: url });
if (!auth) return;
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive"
});
res.write(`event: connected\n`);
res.write(`data: ${JSON.stringify({ ok: true, user: sanitizeUser(auth.user) })}\n\n`);
const client = { res, userId: auth.user.id };
runtime.sseClients.add(client);
req.on("close", () => {
runtime.sseClients.delete(client);
});
}
async function buildUiControls(user) {
const controls = [];
const activation = getCurrentActivationView();
if (user.role === "admin" || user.role === "approver" || user.role === "operator") {
controls.push({
controlId: "station-main",
controlType: "swr.progress",
title: "Stationssteuerung",
capabilityProvider: {
capability: "station.activate",
pluginId: runtime.pluginState.providers["station.activate"] || null
},
status: buildStationStatusView(),
viewSchema: {
showLinksAfterActivation: true
},
actions: [
{
name: "activate",
inputSchema: { type: "object", properties: {}, additionalProperties: false }
},
{
name: "release",
inputSchema: { type: "object", properties: {}, additionalProperties: false }
}
]
});
}
for (const plugin of runtime.plugins.values()) {
if (!runtime.pluginState.enabled[plugin.manifest.id]) {
continue;
}
const pluginControls = Array.isArray(plugin.manifest.uiControls) ? plugin.manifest.uiControls : [];
for (const control of pluginControls) {
const capability = control.capability || "";
const provider = runtime.pluginState.providers[capability] || null;
if (provider !== plugin.manifest.id) {
continue;
}
if (capability && !hasCapability(user, capability)) {
continue;
}
const pluginStatus = control.controlType === "swr.progress" ? activation : (plugin.instance.getStatus ? await safeStatus(plugin.instance) : {});
controls.push({
controlId: control.controlId,
controlType: control.controlType,
title: control.title,
capabilityProvider: {
capability,
pluginId: plugin.manifest.id
},
status: pluginStatus,
viewSchema: control.viewSchema || {},
actions: (control.actions || []).map((entry) => ({
name: entry.name,
inputSchema: entry.inputSchema || { type: "object", additionalProperties: true }
}))
});
}
}
return controls;
}
async function buildSwrReportView(user) {
let report = {
source: "none",
generatedAt: null,
overallStatus: "UNKNOWN",
bands: [],
overviewUrl: String(process.env.SWR_OVERVIEW_URL || "") || null
};
try {
const pluginReport = await executeCapability("vswr.report.read", "getReport", {}, { user });
if (pluginReport && typeof pluginReport === "object") {
report = {
...report,
...pluginReport,
bands: Array.isArray(pluginReport.bands) ? pluginReport.bands : []
};
}
} catch {
// optional capability
}
if (!report.generatedAt) {
report.generatedAt = runtime.station.updatedAt || null;
}
return report;
}
function buildStationStatusView() {
const activation = getCurrentActivationView();
const swrRun = getCurrentSwrRunView();
const links = {
swrOverview: String(process.env.SWR_OVERVIEW_URL || "") || null,
webSdr: String(process.env.WEBSDR_URL || "") || null,
rotorControl: String(process.env.ROTOR_CONTROL_URL || "") || null,
openWebRxPath: String(process.env.OPENWEBRX_PATH || "/sdr/") || "/sdr/"
};
const linksReady = runtime.station.isInUse && !activation.running;
const nowMs = Date.now();
const reservations = getNormalizedStationReservations(nowMs);
const activeReservation = findActiveReservationEntry(reservations, nowMs);
const activationReserved = Boolean(activation.running && !runtime.station.isInUse && activation.startedAt);
const effectiveStartedAt = runtime.station.isInUse
? (runtime.station.startedAt || null)
: (activeReservation ? activeReservation.from : (activationReserved ? String(activation.startedAt) : null));
const effectiveEndsAt = runtime.station.isInUse
? (runtime.station.endsAt || null)
: (activeReservation ? activeReservation.to : (activationReserved ? computeLeaseEndIso(effectiveStartedAt) : null));
const effectiveActiveByEmail = runtime.station.isInUse
? runtime.station.activeByEmail
: (activeReservation ? activeReservation.email : (activationReserved ? (activation.startedBy || null) : null));
const effectiveActiveByUserId = runtime.station.isInUse
? runtime.station.activeByUserId
: (activeReservation ? activeReservation.userId : (activationReserved ? (activation.startedByUserId || null) : null));
const remainingUsageSec = effectiveEndsAt
? Math.max(0, Math.ceil((new Date(effectiveEndsAt).getTime() - Date.now()) / 1000))
: 0;
const slotMs = stationUsageDurationMs();
const reservationEntries = reservations.map((entry, index) => {
return {
position: index + 1,
userId: entry.userId,
email: entry.email,
createdAt: entry.createdAt,
from: entry.from,
to: entry.to,
active: Boolean(activeReservation
&& String(activeReservation.userId || "") === String(entry.userId || "")
&& String(activeReservation.from || "") === String(entry.from || "")
&& String(activeReservation.to || "") === String(entry.to || ""))
};
});
const stationOccupied = Boolean(runtime.station.isInUse || activation.running);
const queueVisible = stationOccupied || reservationEntries.length > 0 || Boolean(activeReservation);
return {
stationName: runtime.station.stationName,
stationOnline: Boolean(runtime.station.stationOnline),
maintenanceMode: Boolean(runtime.systemState.maintenanceMode),
maintenanceMessage: runtime.systemState.maintenanceMessage,
isInUse: Boolean(runtime.station.isInUse),
activeByUserId: effectiveActiveByUserId,
activeByEmail: effectiveActiveByEmail,
startedAt: effectiveStartedAt,
endsAt: effectiveEndsAt,
remainingUsageSec,
updatedAt: runtime.station.updatedAt,
lastAction: runtime.station.lastAction,
execMode: config.execMode,
simulateHardware: config.simulateHardware,
openWebRxTxPollMs: Math.max(1000, Math.min(60000, Number(config.openWebRxTxPollMs || 4000))),
activation,
swrRun,
reservationQueue: {
slotDurationSec: Math.floor(slotMs / 1000),
canReserve: stationOccupied,
visible: queueVisible,
slotLockActive: Boolean(activeReservation),
activeEntry: activeReservation
? {
userId: activeReservation.userId,
email: activeReservation.email,
from: activeReservation.from,
to: activeReservation.to
}
: null,
entries: reservationEntries
},
links,
linksReady
};
}
function getExpectedSWRDurationMs() {
const configured = Number(getPluginSetting("rms.vswr.nanovna", "expectedDurationMs", process.env.SWR_CHECK_DURATION_MS || 54000));
if (!Number.isFinite(configured)) {
return 54000;
}
return Math.max(1000, Math.floor(configured));
}
function getCurrentSwrRunView() {
const current = runtime.swrRun || {};
if (!current.running) {
return {
running: false,
source: null,
phase: null,
percent: 0,
elapsedSec: 0,
remainingSec: 0,
startedAt: null,
startedBy: null,
lastStatus: current.lastStatus || null,
lastError: current.lastError || null,
lastFinishedAt: current.lastFinishedAt || null
};
}
const startedAtMs = Date.parse(String(current.startedAt || ""));
const elapsedMs = Number.isFinite(startedAtMs) ? Math.max(0, Date.now() - startedAtMs) : 0;
const expectedMs = Number.isFinite(Number(current.expectedDurationMs))
? Math.max(1, Math.floor(Number(current.expectedDurationMs)))
: getExpectedSWRDurationMs();
const percent = Math.max(0, Math.min(99, Math.floor((elapsedMs / Math.max(1, expectedMs)) * 100)));
return {
running: true,
source: current.source || null,
phase: current.phase || "swr-check",
percent,
elapsedSec: Math.floor(elapsedMs / 1000),
remainingSec: Math.max(0, Math.ceil((expectedMs - elapsedMs) / 1000)),
startedAt: current.startedAt || null,
startedBy: current.startedBy || null,
lastStatus: null,
lastError: null,
lastFinishedAt: null
};
}
function getCurrentActivationView() {
const job = runtime.currentActivationJobId ? runtime.jobs.get(runtime.currentActivationJobId) : null;
if (!job || job.status !== "running") {
const latest = getLatestActivationJob();
return {
running: false,
phase: null,
percent: 0,
elapsedSec: 0,
remainingSec: 0,
startedAt: null,
startedBy: null,
startedByUserId: null,
lastStatus: latest && latest.status ? String(latest.status) : null,
lastError: latest && latest.status === "failed" && latest.error ? String(latest.error) : null,
lastFinishedAt: latest && latest.finishedAt ? latest.finishedAt : null
};
}
const elapsedSec = Math.max(0, Math.floor((Date.now() - new Date(job.startedAt).getTime()) / 1000));
return {
running: true,
phase: job.phase,
percent: Number(job.percent || 0),
elapsedSec,
remainingSec: Number(job.etaSec || 0),
startedAt: job.startedAt || null,
startedBy: job.startedBy || null,
startedByUserId: job.startedByUserId || null,
lastStatus: String(job.status || "running"),
lastError: null,
lastFinishedAt: null
};
}
function getLatestActivationJob() {
let latest = null;
for (const candidate of runtime.jobs.values()) {
if (!candidate || candidate.type !== "station.activate") {
continue;
}
if (!latest) {
latest = candidate;
continue;
}
const candidateTime = Date.parse(candidate.finishedAt || candidate.startedAt || 0);
const latestTime = Date.parse(latest.finishedAt || latest.startedAt || 0);
if (Number.isFinite(candidateTime) && (!Number.isFinite(latestTime) || candidateTime >= latestTime)) {
latest = candidate;
}
}
return latest;
}
async function startGlobalSwrRun(user, options = {}) {
return withLock(async () => {
if (runtime.swrRun && runtime.swrRun.running) {
const error = new Error("SWR-Check laeuft bereits");
error.code = "SWR_ALREADY_RUNNING";
throw error;
}
const token = `swr_${crypto.randomUUID()}`;
runtime.swrRun = {
...runtime.swrRun,
running: true,
token,
source: String(options.source || "manual"),
phase: String(options.phase || "swr-check"),
startedAt: new Date().toISOString(),
expectedDurationMs: Number.isFinite(Number(options.expectedDurationMs))
? Math.max(1000, Math.floor(Number(options.expectedDurationMs)))
: getExpectedSWRDurationMs(),
startedBy: user && user.email ? String(user.email) : null,
lastStatus: "running",
lastError: null,
lastFinishedAt: null
};
broadcastEvent("swr.run.started", {
source: runtime.swrRun.source,
startedBy: runtime.swrRun.startedBy
});
broadcastEvent("station.status.changed", buildStationStatusView());
return token;
});
}
async function finishGlobalSwrRun(token, options = {}) {
return withLock(async () => {
if (!runtime.swrRun || !runtime.swrRun.running) {
return;
}
if (!token || String(runtime.swrRun.token || "") !== String(token)) {
return;
}
const status = String(options.status || "succeeded");
runtime.swrRun = {
...runtime.swrRun,
running: false,
token: null,
source: null,
phase: null,
startedAt: null,
expectedDurationMs: 0,
startedBy: null,
lastStatus: status,
lastError: options.error ? String(options.error) : null,
lastFinishedAt: new Date().toISOString()
};
broadcastEvent("swr.run.finished", {
status,
error: runtime.swrRun.lastError,
finishedAt: runtime.swrRun.lastFinishedAt
});
broadcastEvent("station.status.changed", buildStationStatusView());
});
}
async function loadPlugins() {
runtime.plugins.clear();
if (!fs.existsSync(config.pluginDir)) {
await fsp.mkdir(config.pluginDir, { recursive: true });
return;
}
const entries = await fsp.readdir(config.pluginDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const pluginPath = path.join(config.pluginDir, entry.name);
const manifestPath = path.join(pluginPath, "manifest.json");
const indexPath = path.join(pluginPath, "index.js");
if (!fs.existsSync(manifestPath) || !fs.existsSync(indexPath)) {
continue;
}
const manifest = JSON.parse(await fsp.readFile(manifestPath, "utf8"));
const factory = require(indexPath);
const createPlugin = typeof factory.createPlugin === "function" ? factory.createPlugin : factory;
const instance = await createPlugin({
manifest,
rootDir,
env: process.env,
execMode: config.execMode,
simulateHardware: config.simulateHardware,
getSetting: (key, fallback = null) => getPluginSetting(manifest.id, key, fallback),
commandRunner: runCommand,
executeCapability: async (capability, action, input = {}, meta = {}) => executeCapability(capability, action, input, meta),
emit: (type, data) => broadcastEvent(type, { pluginId: manifest.id, ...data }),
mailOutboxPath: files.mailOutbox,
appendMailOutbox: async (entry) => {
await appendMailOutboxEntry(entry);
}
});
runtime.plugins.set(manifest.id, {
manifest,
instance,
pluginPath
});
}
for (const [id, plugin] of runtime.plugins.entries()) {
if (!(id in runtime.pluginState.enabled)) {
runtime.pluginState.enabled[id] = true;
}
if (runtime.pluginState.enabled[id] && plugin.instance.start) {
await plugin.instance.start();
}
runtime.pluginHealth[id] = await safePluginHealth(plugin);
}
ensureDefaultProviders();
await savePluginState();
}
function ensureDefaultProviders() {
const byCapability = new Map();
for (const plugin of runtime.plugins.values()) {
for (const capability of plugin.manifest.capabilities || []) {
if (!byCapability.has(capability)) {
byCapability.set(capability, []);
}
byCapability.get(capability).push(plugin.manifest.id);
}
}
for (const [capability, pluginIds] of byCapability.entries()) {
const preferred = preferredProviderForCapability(capability, pluginIds);
if (preferred) {
runtime.pluginState.providers[capability] = preferred;
continue;
}
const current = runtime.pluginState.providers[capability];
if (current && pluginIds.includes(current)) {
continue;
}
runtime.pluginState.providers[capability] = pluginIds[0];
}
}
function preferredProviderForCapability(capability, pluginIds) {
if (!Array.isArray(pluginIds) || pluginIds.length === 0) {
return null;
}
if ((capability === "tx.control" || capability === "tx.state.read") && pluginIds.includes("rms.tx.control.native")) {
return "rms.tx.control.native";
}
if (capability === "tx.audio" && pluginIds.includes("rms.tx.audio.core")) {
return "rms.tx.audio.core";
}
if (capability === "tx.audio.backend" && pluginIds.includes("rms.microham")) {
return "rms.microham";
}
if ((capability === "vswr.run" || capability === "vswr.report.read") && pluginIds.includes("rms.vswr.native")) {
return "rms.vswr.native";
}
if ((capability === "openwebrx.access.issue" || capability === "openwebrx.access.verify" || capability === "openwebrx.service.control")
&& pluginIds.includes("rms.openwebrx.guard")) {
return "rms.openwebrx.guard";
}
if ((capability === "openwebrx.band.read" || capability === "openwebrx.band.set") && pluginIds.includes("rms.openwebrx.bandmap")) {
return "rms.openwebrx.bandmap";
}
if ((capability === "station.access.policy.read" || capability === "admin.station.access.policy.write")
&& pluginIds.includes("rms.station.access.policy")) {
return "rms.station.access.policy";
}
if (capability === "help.content.read" && pluginIds.includes("rms.help.basic")) {
return "rms.help.basic";
}
return null;
}
function describePlugin(plugin) {
return {
id: plugin.manifest.id,
name: plugin.manifest.name,
version: plugin.manifest.version,
capabilities: plugin.manifest.capabilities || [],
authMethod: plugin.manifest.authMethod || null,
settingsSchema: plugin.manifest.settingsSchema || { type: "object", properties: {} },
settings: runtime.pluginState.settings[plugin.manifest.id] || {},
enabled: Boolean(runtime.pluginState.enabled[plugin.manifest.id]),
health: runtime.pluginHealth[plugin.manifest.id] || "unknown",
providerFor: Object.entries(runtime.pluginState.providers)
.filter(([, pluginId]) => pluginId === plugin.manifest.id)
.map(([capability]) => capability)
};
}
async function listPlugins() {
await refreshPluginHealth();
return Array.from(runtime.plugins.values()).map((plugin) => describePlugin(plugin));
}
async function buildCapabilitiesMatrix() {
await refreshPluginHealth();
const map = new Map();
for (const plugin of runtime.plugins.values()) {
for (const capability of plugin.manifest.capabilities || []) {
if (!map.has(capability)) {
map.set(capability, {
capability,
activePluginId: runtime.pluginState.providers[capability] || null,
providers: []
});
}
map.get(capability).providers.push({
pluginId: plugin.manifest.id,
enabled: Boolean(runtime.pluginState.enabled[plugin.manifest.id]),
health: runtime.pluginHealth[plugin.manifest.id] || "unknown"
});
}
}
return Array.from(map.values()).sort((a, b) => a.capability.localeCompare(b.capability));
}
async function executeCapability(capability, action, input, meta) {
if (runtime.pttActive && capability === "rfroute.set" && action === "setRoute") {
const route = String(input && input.route ? input.route : "").toLowerCase();
if (route && route !== "tx" && !(meta && meta.allowPttOverride)) {
const error = new Error("PTT aktiv: Antennenweg bleibt auf TX gesperrt");
error.code = "PTT_ROUTE_LOCK";
throw error;
}
}
if (isSwitchingCapability(capability, action) && !(meta && meta.skipTxSafety)) {
const txState = await readTransmitState(meta && meta.user ? meta.user : null);
if (txState.txActive) {
const error = new Error("Umschalten ist waehrend aktivem Senden gesperrt");
error.code = "TX_SWITCH_LOCK";
error.details = txState;
throw error;
}
}
if (capability === "vswr.run" && isOpenWebRxSessionActive()) {
const error = new Error("SWR-Check ist gesperrt solange OpenWebRX aktiv genutzt wird");
error.code = "OPENWEBRX_SESSION_ACTIVE";
throw error;
}
if (capability === "vswr.run" && runtime.station && runtime.station.isInUse) {
const error = new Error("SWR-Check ist gesperrt solange die Station aktiv ist");
error.code = "VSWR_WHILE_STATION_ACTIVE";
throw error;
}
const pluginId = runtime.pluginState.providers[capability];
if (!pluginId) {
throw new Error(`No provider for capability ${capability}`);
}
const plugin = runtime.plugins.get(pluginId);
if (!plugin) {
throw new Error(`Provider plugin ${pluginId} missing`);
}
if (!runtime.pluginState.enabled[pluginId]) {
throw new Error(`Provider plugin ${pluginId} disabled`);
}
if (typeof plugin.instance.execute !== "function") {
throw new Error(`Provider plugin ${pluginId} has no execute()`);
}
return plugin.instance.execute(action, input || {}, meta || {});
}
function isOpenWebRxSessionActive() {
const session = runtime.openWebRxSession || {};
if (!session.activeOwnerUserId) {
return false;
}
if (!session.expiresAtMs || session.expiresAtMs <= Date.now()) {
if (runtime.station
&& runtime.station.isInUse
&& runtime.station.activeByUserId
&& String(runtime.station.activeByUserId) === String(session.activeOwnerUserId)) {
const ttlSec = Number(process.env.OPENWEBRX_TICKET_TTL_SEC || 3600);
const effectiveTtlSec = Number.isFinite(ttlSec) ? Math.max(60, ttlSec) : 120;
runtime.openWebRxSession.expiresAtMs = Date.now() + effectiveTtlSec * 1000;
return true;
}
clearOpenWebRxSession();
return false;
}
return true;
}
function markOpenWebRxSession(user, sessionResult) {
const ownerUserId = user && user.id ? String(user.id) : String(sessionResult && sessionResult.userId ? sessionResult.userId : "");
const expiresAtMs = Date.parse(String(sessionResult && sessionResult.expiresAt ? sessionResult.expiresAt : ""));
runtime.openWebRxSession = {
activeOwnerUserId: ownerUserId || null,
expiresAtMs: Number.isFinite(expiresAtMs) ? expiresAtMs : (Date.now() + 120000),
lastEnsureSdrAtMs: runtime.openWebRxSession && Number.isFinite(runtime.openWebRxSession.lastEnsureSdrAtMs)
? runtime.openWebRxSession.lastEnsureSdrAtMs
: 0
};
}
function clearOpenWebRxSession() {
const previousOwnerUserId = runtime.openWebRxSession && runtime.openWebRxSession.activeOwnerUserId
? String(runtime.openWebRxSession.activeOwnerUserId)
: "";
runtime.openWebRxSession = {
activeOwnerUserId: null,
expiresAtMs: 0,
lastEnsureSdrAtMs: 0
};
runtime.openWebRxAntennaRoute = null;
if (previousOwnerUserId) {
delete runtime.openWebRxLiveStateByUserId[previousOwnerUserId];
}
executeCapability("tx.audio", "audioDisconnect", { reason: "session-cleared" }, { skipTxSafety: true }).catch(() => {});
}
async function ensureOpenWebRxSdrPath(user, options = {}) {
const provider = runtime.pluginState.providers["openwebrx.service.control"];
if (!provider) {
return;
}
const force = Boolean(options.force);
const minIntervalMs = Number.isFinite(Number(options.minIntervalMs)) ? Number(options.minIntervalMs) : 3000;
const last = runtime.openWebRxSession && Number.isFinite(runtime.openWebRxSession.lastEnsureSdrAtMs)
? runtime.openWebRxSession.lastEnsureSdrAtMs
: 0;
if (!force && Date.now() - last < Math.max(0, minIntervalMs)) {
return;
}
await executeCapability("openwebrx.service.control", "ensureSdrPath", { reason: "openwebrx-session" }, { user, skipTxSafety: true });
runtime.openWebRxSession.lastEnsureSdrAtMs = Date.now();
}
function isSwitchingCapability(capability, action) {
if (capability === "rfroute.set") {
return action === "setRoute";
}
return capability === "station.activate"
|| capability === "station.deactivate"
|| capability === "vswr.run";
}
function shouldAutoDisableTxBeforeActivation() {
const value = String(config.autoDisableTxBeforeActivation || "false").trim().toLowerCase();
return value === "1" || value === "true" || value === "yes" || value === "on";
}
async function readTransmitState(user) {
const provider = runtime.pluginState.providers["tx.state.read"];
if (!provider) {
return { txActive: false, source: "none" };
}
try {
const state = await executeCapability("tx.state.read", "getTxState", {}, { user, skipTxSafety: true });
return {
txActive: Boolean(state && state.txActive),
source: state && state.source ? state.source : provider,
updatedAt: state && state.updatedAt ? state.updatedAt : null
};
} catch {
return { txActive: false, source: provider };
}
}
function requireAuth(req, res, options = {}) {
const token = getBearerToken(req) || (options.allowQueryToken && options.queryTokenUrl ? options.queryTokenUrl.searchParams.get("accessToken") : "");
if (!token) {
sendError(res, 401, "auth.missing_token", "Token fehlt");
return null;
}
const verified = verifyJwt(token, "access");
if (!verified.ok) {
sendError(res, 401, "auth.invalid_token", "Token ungueltig");
return null;
}
const payload = verified.payload;
const user = runtime.users.find((entry) => entry.id === payload.sub);
if (!user) {
sendError(res, 401, "auth.invalid_token", "Token ungueltig");
return null;
}
const tokenVersion = runtime.authState.tokenVersionByUser[user.id] || 0;
if (payload.tv !== tokenVersion) {
sendError(res, 401, "auth.invalid_token", "Token ungueltig");
return null;
}
if (user.status !== "active") {
sendError(res, 403, "auth.not_approved", "Benutzer ist nicht freigegeben");
return null;
}
if (runtime.systemState.maintenanceMode && user.role !== "admin") {
sendError(res, 503, "auth.maintenance", runtime.systemState.maintenanceMessage);
return null;
}
return { user, payload };
}
function getBearerToken(req) {
const header = req.headers.authorization || "";
const match = /^Bearer\s+(.+)$/i.exec(header);
return match ? match[1].trim() : "";
}
async function issueTokenPair(user, req, options = {}) {
const nowSec = Math.floor(Date.now() / 1000);
const sid = options.sid || crypto.randomUUID();
const rid = crypto.randomUUID();
const tokenVersion = runtime.authState.tokenVersionByUser[user.id] || 0;
const effectiveAccessTtlSec = getEffectiveAccessTokenTtlSec(user, nowSec);
const accessPayload = {
sub: user.id,
email: user.email,
role: user.role,
sid,
tv: tokenVersion,
typ: "access",
iat: nowSec,
exp: nowSec + effectiveAccessTtlSec,
iss: config.jwtIssuer,
aud: config.jwtAudience
};
const refreshPayload = {
sub: user.id,
sid,
rid,
tv: tokenVersion,
typ: "refresh",
iat: nowSec,
exp: nowSec + config.refreshTokenTtlSec,
iss: config.jwtIssuer,
aud: config.jwtAudience
};
const accessToken = signJwt(accessPayload);
const refreshToken = signJwt(refreshPayload);
runtime.authState.refreshTokens.push({
id: rid,
sid,
userId: user.id,
tokenHash: sha256(refreshToken),
createdAt: new Date().toISOString(),
expiresAtMs: refreshPayload.exp * 1000,
revokedAt: null,
rotatedFrom: options.rotatedFrom || null,
userAgent: req.headers["user-agent"] || "",
ip: clientIp(req)
});
pruneRefreshTokens();
await saveAuthState();
return {
sid,
rid,
accessToken,
refreshToken
};
}
function getEffectiveAccessTokenTtlSec(user, nowSec) {
return getEffectiveAccessTokenTtlDetails(user, nowSec).effectiveTtlSec;
}
function getEffectiveAccessTokenTtlDetails(user, nowSec) {
const minimumTtlSec = 3 * 60 * 60;
const configured = Number.isFinite(Number(config.accessTokenTtlSec)) ? Math.floor(Number(config.accessTokenTtlSec)) : minimumTtlSec;
let ttl = Math.max(minimumTtlSec, configured);
const station = runtime.station;
const isOwnerActive = Boolean(
station &&
station.isInUse &&
station.activeByUserId &&
user &&
String(station.activeByUserId) === String(user.id)
);
if (!isOwnerActive) {
return {
effectiveTtlSec: ttl,
minimumTtlSec,
configuredAccessTokenTtlSec: configured,
ownerSessionBoostApplied: false
};
}
const endsAtMs = Date.parse(String(station.endsAt || ""));
if (!Number.isFinite(endsAtMs)) {
return {
effectiveTtlSec: ttl,
minimumTtlSec,
configuredAccessTokenTtlSec: configured,
ownerSessionBoostApplied: false
};
}
const remainingSec = Math.max(0, Math.ceil((endsAtMs - (nowSec * 1000)) / 1000));
const safetyBufferSec = 15 * 60;
const maxTtlSec = 24 * 60 * 60;
const boostedTtl = Math.min(maxTtlSec, Math.max(ttl, remainingSec + safetyBufferSec));
return {
effectiveTtlSec: boostedTtl,
minimumTtlSec,
configuredAccessTokenTtlSec: configured,
ownerSessionBoostApplied: boostedTtl > ttl
};
}
function signJwt(payload) {
const header = {
alg: "HS256",
typ: "JWT"
};
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const body = `${encodedHeader}.${encodedPayload}`;
const signature = crypto.createHmac("sha256", config.jwtSecret).update(body).digest("base64url");
return `${body}.${signature}`;
}
function verifyJwt(token, expectedType) {
try {
const parts = token.split(".");
if (parts.length !== 3) {
return { ok: false };
}
const [encodedHeader, encodedPayload, signature] = parts;
const body = `${encodedHeader}.${encodedPayload}`;
const expectedSignature = crypto.createHmac("sha256", config.jwtSecret).update(body).digest("base64url");
if (!safeEquals(signature, expectedSignature)) {
return { ok: false };
}
const payload = JSON.parse(base64UrlDecode(encodedPayload));
const nowSec = Math.floor(Date.now() / 1000);
if (payload.exp && nowSec > payload.exp) {
return { ok: false };
}
if (payload.iss !== config.jwtIssuer || payload.aud !== config.jwtAudience) {
return { ok: false };
}
if (expectedType && payload.typ !== expectedType) {
return { ok: false };
}
return { ok: true, payload };
} catch {
return { ok: false };
}
}
function hasRole(user, roles) {
return roles.includes(user.role);
}
function denyIfStationOwnedByOther(res, user, actionLabel, options = {}) {
const allowAdmin = Boolean(options.allowAdmin);
if (!runtime.station || !runtime.station.isInUse || !runtime.station.activeByUserId) {
return false;
}
if (allowAdmin && user && user.role === "admin") {
return false;
}
const isOwner = user && String(user.id || "") === String(runtime.station.activeByUserId || "");
if (isOwner) {
return false;
}
sendError(res, 409, "station.in_use", `${String(actionLabel || "Aktion")} ist gesperrt: Station wird von einem anderen Benutzer verwendet`);
return true;
}
function hasCapability(user, capability) {
if (!capability) return true;
if (user.role === "admin") return true;
if (capability.startsWith("admin.")) return false;
return true;
}
function userCapabilities(user) {
const caps = new Set();
for (const [capability, pluginId] of Object.entries(runtime.pluginState.providers)) {
if (!pluginId) continue;
if (hasCapability(user, capability)) {
caps.add(capability);
}
}
return Array.from(caps).sort();
}
function broadcastEvent(type, data) {
const event = {
type,
ts: new Date().toISOString(),
...data
};
const id = `evt_${++runtime.eventSeq}`;
const payload = `event: ${type}\nid: ${id}\ndata: ${JSON.stringify(event)}\n\n`;
for (const client of runtime.sseClients) {
try {
client.res.write(payload);
} catch {
runtime.sseClients.delete(client);
}
}
}
async function runCommand(commandString, options = {}) {
const parsed = splitCommand(commandString);
if (parsed.length === 0) {
return { ok: false, code: -1, stderr: "empty command" };
}
const [command, ...args] = parsed;
const timeoutMs = Number(options.timeoutMs || 120000);
return new Promise((resolve) => {
const child = spawn(command, args, {
cwd: options.cwd || rootDir,
env: {
...process.env,
...(options.env || {})
},
shell: true
});
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
child.kill("SIGTERM");
}, timeoutMs);
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
if (stdout.length > 20000) stdout = stdout.slice(-20000);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
if (stderr.length > 20000) stderr = stderr.slice(-20000);
});
child.on("error", (error) => {
clearTimeout(timer);
resolve({ ok: false, code: -1, error: String(error.message || error), stdout: stdout.trim(), stderr: stderr.trim() });
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({ ok: code === 0, code, stdout: stdout.trim(), stderr: stderr.trim() });
});
});
}
async function ensureDataFiles() {
await fsp.mkdir(config.dataDir, { recursive: true });
if (!(await storage.exists(files.users))) {
await writeJson(files.users, []);
}
if (!(await storage.exists(files.station))) {
await writeJson(files.station, buildDefaultStationState());
}
if (!(await storage.exists(files.audit))) {
await storage.writeText(files.audit, "");
}
if (!(await storage.exists(files.auth))) {
await writeJson(files.auth, runtime.authState);
}
if (!(await storage.exists(files.plugins))) {
await writeJson(files.plugins, runtime.pluginState);
}
if (!(await storage.exists(files.approvals))) {
await writeJson(files.approvals, []);
}
if (!(await storage.exists(files.system))) {
await writeJson(files.system, runtime.systemState);
}
await ensureMailOutboxInitialized();
}
function buildDefaultStationState() {
return {
stationName: config.stationName,
stationOnline: true,
isInUse: false,
activeByUserId: null,
activeByEmail: null,
startedAt: null,
endsAt: null,
reservations: [],
updatedAt: new Date().toISOString(),
lastAction: "init"
};
}
async function applyAdminRoles() {
let changed = false;
if (!Array.isArray(runtime.authState.emailTokens)) {
runtime.authState.emailTokens = [];
changed = true;
}
if (!Array.isArray(runtime.authState.otpChallenges)) {
runtime.authState.otpChallenges = [];
changed = true;
}
const availableMethods = listPublicAuthMethods();
const availableMethodIds = availableMethods.map((entry) => entry.id);
const defaultMethodId = preferredAuthMethodId(availableMethods);
for (const user of runtime.users) {
const role = config.adminEmails.has(user.email)
? "admin"
: (config.approverEmails.has(user.email) ? "approver" : (user.role || "operator"));
if (user.role !== role) {
user.role = role;
changed = true;
}
if (!user.accountType) {
user.accountType = isPrimaryDomainEmail(user.email) ? "primary-domain" : "external-domain";
changed = true;
}
if (!Array.isArray(user.enabledAuthMethods) || user.enabledAuthMethods.length === 0) {
user.enabledAuthMethods = [...availableMethodIds];
changed = true;
}
const filteredMethods = user.enabledAuthMethods.filter((methodId) => availableMethodIds.includes(methodId));
if (filteredMethods.length !== user.enabledAuthMethods.length) {
user.enabledAuthMethods = filteredMethods;
changed = true;
}
if (!user.primaryAuthMethod || !user.enabledAuthMethods.includes(user.primaryAuthMethod)) {
user.primaryAuthMethod = defaultMethodId && user.enabledAuthMethods.includes(defaultMethodId)
? defaultMethodId
: (user.enabledAuthMethods[0] || null);
changed = true;
}
if (!user.status) {
user.status = "active";
changed = true;
}
if (!user.preferredLanguage || !["de", "en"].includes(String(user.preferredLanguage).toLowerCase())) {
user.preferredLanguage = "de";
changed = true;
}
if (user.role === "admin" && user.status !== "active") {
user.status = "active";
user.approvedAt = user.approvedAt || new Date().toISOString();
changed = true;
}
if (user.status === "active" && !user.emailVerifiedAt) {
user.emailVerifiedAt = user.createdAt || new Date().toISOString();
changed = true;
}
if (!(user.id in runtime.authState.tokenVersionByUser)) {
runtime.authState.tokenVersionByUser[user.id] = 0;
changed = true;
}
}
if (changed) {
await writeJson(files.users, runtime.users);
await saveAuthState();
}
}
async function appendAudit(action, user, details) {
const entry = {
at: new Date().toISOString(),
action,
userId: user ? user.id : null,
email: user ? user.email : null,
details: details || null
};
await storage.appendText(files.audit, `${JSON.stringify(entry)}\n`);
}
async function listStationActivityLog(limit = 200) {
const raw = await storage.readText(files.audit, "");
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const interesting = new Set([
"auth.request_access",
"station.activate.start",
"station.activate.done",
"station.activate.failed",
"station.deactivate",
"station.deactivate.timeout"
]);
const entries = [];
for (const line of lines) {
let entry;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (!entry || !interesting.has(entry.action)) {
continue;
}
entries.push({
at: entry.at,
action: entry.action,
email: entry.email || null,
details: entry.details || null,
message: activityMessageForEntry(entry)
});
}
entries.sort((a, b) => new Date(b.at || 0).getTime() - new Date(a.at || 0).getTime());
return entries.slice(0, limit);
}
function activityMessageForEntry(entry) {
const email = entry.email || "unbekannt";
if (entry.action === "auth.request_access") {
return `${email} hat einen Login-Link angefordert`;
}
if (entry.action === "station.activate.start") {
return `${email} hat die RMS-Aktivierung gestartet`;
}
if (entry.action === "station.activate.done") {
return `${email} hat die RMS aktiviert`;
}
if (entry.action === "station.activate.failed") {
return `${email} Aktivierung fehlgeschlagen`;
}
if (entry.action === "station.deactivate") {
return `${email} hat die RMS manuell beendet`;
}
if (entry.action === "station.deactivate.timeout") {
return `${email} RMS wurde automatisch wegen Zeitlimit beendet`;
}
return `${email} ${entry.action}`;
}
async function saveAuthState() {
await writeJson(files.auth, runtime.authState);
}
async function savePluginState() {
await writeJson(files.plugins, runtime.pluginState);
}
async function saveApprovalRequests() {
await writeJson(files.approvals, runtime.approvalRequests);
}
async function saveSystemState() {
await writeJson(files.system, runtime.systemState);
}
function pruneRefreshTokens() {
const now = Date.now();
runtime.authState.refreshTokens = runtime.authState.refreshTokens.filter((token) => {
if (token.expiresAtMs < now - 24 * 60 * 60 * 1000) {
return false;
}
return true;
});
}
async function revokeSessionFamily(sid) {
for (const token of runtime.authState.refreshTokens) {
if (token.sid === sid && !token.revokedAt) {
token.revokedAt = new Date().toISOString();
}
}
await saveAuthState();
}
function getPluginSetting(pluginId, key, fallback = null) {
const pluginSettings = runtime.pluginState.settings[pluginId] || {};
if (key in pluginSettings) {
return pluginSettings[key];
}
return fallback;
}
function validateSettings(schema, value) {
if (!schema || schema.type !== "object") {
return { ok: true };
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { ok: false, message: "settings muss ein Objekt sein" };
}
const properties = schema.properties || {};
const required = Array.isArray(schema.required) ? schema.required : [];
for (const key of required) {
if (!(key in value)) {
return { ok: false, message: `Pflichtfeld fehlt: ${key}` };
}
}
for (const [key, fieldValue] of Object.entries(value)) {
const fieldSchema = properties[key];
if (!fieldSchema) {
if (schema.additionalProperties === false) {
return { ok: false, message: `Unbekanntes Feld: ${key}` };
}
continue;
}
const type = fieldSchema.type;
if (type === "string" && typeof fieldValue !== "string") {
return { ok: false, message: `${key} muss string sein` };
}
if (type === "number" && typeof fieldValue !== "number") {
return { ok: false, message: `${key} muss number sein` };
}
if (type === "integer" && (!Number.isInteger(fieldValue))) {
return { ok: false, message: `${key} muss integer sein` };
}
if (type === "boolean" && typeof fieldValue !== "boolean") {
return { ok: false, message: `${key} muss boolean sein` };
}
if (Array.isArray(fieldSchema.enum) && !fieldSchema.enum.includes(fieldValue)) {
return { ok: false, message: `${key} hat ungueltigen Wert` };
}
if (typeof fieldValue === "number") {
if (fieldSchema.minimum !== undefined && fieldValue < fieldSchema.minimum) {
return { ok: false, message: `${key} ist kleiner als minimum` };
}
if (fieldSchema.maximum !== undefined && fieldValue > fieldSchema.maximum) {
return { ok: false, message: `${key} ist groesser als maximum` };
}
}
}
return { ok: true };
}
function enforceRateLimit(req, res, key, limit, windowMs) {
const now = Date.now();
const bucket = runtime.rateBuckets.get(key) || { count: 0, resetAt: now + windowMs };
if (now > bucket.resetAt) {
bucket.count = 0;
bucket.resetAt = now + windowMs;
}
bucket.count += 1;
runtime.rateBuckets.set(key, bucket);
if (bucket.count <= limit) {
return true;
}
const retryAfterSec = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000));
res.setHeader("Retry-After", String(retryAfterSec));
sendError(res, 429, "rate_limit.exceeded", "Zu viele Anfragen", { retryAfterSec });
return false;
}
function clientIp(req) {
const forwarded = req.headers["x-forwarded-for"];
if (typeof forwarded === "string" && forwarded.trim()) {
return forwarded.split(",")[0].trim();
}
return req.socket.remoteAddress || "unknown";
}
function sanitizeUser(user) {
return {
id: user.id,
email: user.email,
role: user.role || "operator",
status: user.status || "pending_verification",
accountType: user.accountType || (isPrimaryDomainEmail(user.email) ? "primary-domain" : "external-domain"),
enabledAuthMethods: Array.isArray(user.enabledAuthMethods) ? user.enabledAuthMethods : [],
primaryAuthMethod: user.primaryAuthMethod || null,
preferredLanguage: user.preferredLanguage || "de",
createdAt: user.createdAt,
emailVerifiedAt: user.emailVerifiedAt || null,
approvedAt: user.approvedAt || null,
deniedAt: user.deniedAt || null
};
}
function normalizeEmail(value) {
if (typeof value !== "string") {
return "";
}
return value.trim().toLowerCase();
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function hashPassword(password) {
const salt = crypto.randomBytes(16).toString("hex");
const hash = crypto.scryptSync(password, salt, 64).toString("hex");
return `${salt}:${hash}`;
}
function verifyPassword(password, packed) {
if (!packed || typeof packed !== "string" || !packed.includes(":")) {
return false;
}
const [salt, expectedHex] = packed.split(":");
const actualHex = crypto.scryptSync(password, salt, 64).toString("hex");
const expected = Buffer.from(expectedHex, "hex");
const actual = Buffer.from(actualHex, "hex");
if (expected.length !== actual.length) {
return false;
}
return crypto.timingSafeEqual(expected, actual);
}
function sha256(value) {
return crypto.createHash("sha256").update(value).digest("hex");
}
function base64UrlEncode(value) {
return Buffer.from(value, "utf8").toString("base64url");
}
function base64UrlDecode(value) {
return Buffer.from(value, "base64url").toString("utf8");
}
function safeEquals(a, b) {
const left = Buffer.from(String(a));
const right = Buffer.from(String(b));
if (left.length !== right.length) {
return false;
}
return crypto.timingSafeEqual(left, right);
}
function splitCommand(commandString) {
return commandString.match(/(?:[^\s\"]+|\"[^\"]*\")+/g)?.map((part) => part.replace(/^\"|\"$/g, "")) || [];
}
async function readJson(filePath, fallback) {
return storage.readJson(filePath, fallback);
}
async function writeJson(filePath, value) {
await storage.writeJson(filePath, value);
}
async function withLock(action) {
const run = mutex.then(action, action);
mutex = run.then(() => undefined, () => undefined);
return run;
}
async function readJsonBody(req) {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
if (chunks.length === 0) {
return {};
}
const text = Buffer.concat(chunks).toString("utf8");
try {
return JSON.parse(text);
} catch {
const err = new Error("invalid json");
err.code = "INVALID_JSON";
throw err;
}
}
function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload);
res.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store"
});
res.end(body);
}
function sendError(res, statusCode, code, message, details = null) {
return sendJson(res, statusCode, {
error: {
code,
message,
details
}
});
}
async function safePluginHealth(plugin) {
if (!plugin.instance.health) {
return "healthy";
}
try {
const result = await plugin.instance.health();
if (!result || result.ok === false) {
return "degraded";
}
return "healthy";
} catch {
return "failing";
}
}
async function refreshPluginHealth() {
for (const plugin of runtime.plugins.values()) {
const next = await safePluginHealth(plugin);
const pluginId = plugin.manifest.id;
const prev = runtime.pluginHealth[pluginId] || "unknown";
runtime.pluginHealth[pluginId] = next;
if (next !== prev) {
broadcastEvent("plugin.health.changed", {
pluginId,
previous: prev,
health: next
});
}
}
}
async function safeStatus(instance) {
try {
return await instance.getStatus();
} catch {
return {};
}
}
async function serveStaticFile(baseDir, req, res, pathname) {
if (pathname.startsWith("/uploads/")) {
const uploadsPath = pathname.slice("/uploads".length) || "/";
return serveFileFromBaseDir(config.brandingUploadsDir, req, res, uploadsPath, { cachePolicy: "no-store" });
}
if (pathname.startsWith("/vswr/images/")) {
const imagesPath = pathname.slice("/vswr/images".length) || "/";
return serveFileFromBaseDir(config.vswrImagesDir, req, res, imagesPath, { cachePolicy: "no-store" });
}
const publicDir = path.join(baseDir, "public");
return serveFileFromBaseDir(publicDir, req, res, pathname, { spaFallback: true, cachePolicy: "revalidate" });
}
async function serveFileFromBaseDir(baseDir, req, res, pathname, options = {}) {
const spaFallback = Boolean(options.spaFallback);
const cachePolicy = String(options.cachePolicy || "revalidate").trim().toLowerCase();
const resolvedBaseDir = path.resolve(baseDir);
const requestedPath = pathname === "/" ? "/index.html" : pathname;
const effectivePath = spaFallback && isSpaRoute(pathname) ? "/index.html" : requestedPath;
const safePath = path
.normalize(effectivePath)
.replace(/^[/\\]+/, "")
.replace(/^([.][.][/\\])+/, "");
const fullPath = path.resolve(resolvedBaseDir, safePath);
if (fullPath !== resolvedBaseDir && !fullPath.startsWith(resolvedBaseDir + path.sep)) {
return sendError(res, 403, "path.forbidden", "Unerlaubter Pfad");
}
try {
const stat = await fsp.stat(fullPath);
if (stat.isDirectory()) {
return sendError(res, 404, "file.not_found", "Nicht gefunden");
}
const content = await fsp.readFile(fullPath);
const ext = path.extname(fullPath).toLowerCase();
const contentType = mimeTypeFor(ext);
const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs).toString(16)}"`;
const lastModified = stat.mtime.toUTCString();
const ifNoneMatch = String(req.headers["if-none-match"] || "").trim();
if (cachePolicy !== "no-store" && ifNoneMatch && ifNoneMatch === etag) {
res.writeHead(304, {
ETag: etag,
"Last-Modified": lastModified,
"Cache-Control": cacheControlForStatic(cachePolicy, ext)
});
return res.end();
}
res.writeHead(200, {
"Content-Type": contentType,
"Cache-Control": cacheControlForStatic(cachePolicy, ext),
ETag: etag,
"Last-Modified": lastModified
});
if (req.method === "HEAD") {
return res.end();
}
return res.end(content);
} catch {
return sendError(res, 404, "file.not_found", "Nicht gefunden");
}
}
function cacheControlForStatic(cachePolicy, ext) {
if (cachePolicy === "no-store") {
return "no-store";
}
if (ext === ".html" || ext === ".js" || ext === ".css" || ext === ".json") {
return "no-cache, must-revalidate";
}
return "no-cache";
}
function isSpaRoute(pathname) {
if (!pathname || pathname === "/") {
return false;
}
if (pathname.startsWith("/v1/") || pathname.startsWith("/api/")) {
return false;
}
const ext = path.posix.extname(pathname);
return ext === "";
}
function mimeTypeFor(ext) {
if (ext === ".html") return "text/html; charset=utf-8";
if (ext === ".css") return "text/css; charset=utf-8";
if (ext === ".js") return "application/javascript; charset=utf-8";
if (ext === ".json") return "application/json; charset=utf-8";
if (ext === ".svg") return "image/svg+xml";
if (ext === ".png") return "image/png";
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".webp") return "image/webp";
return "application/octet-stream";
}
function resolveExecMode(explicitValue, lifecycleEvent) {
const lifecycle = String(lifecycleEvent || "").trim().toLowerCase();
if (lifecycle === "prod") {
return "prod";
}
if (lifecycle === "dev") {
return "dev";
}
const explicit = String(explicitValue || "").trim().toLowerCase();
if (explicit === "prod" || explicit === "production") return "prod";
if (explicit === "dev" || explicit === "development") return "dev";
return "dev";
}
function loadDotEnv(filePath) {
if (!fs.existsSync(filePath)) {
return;
}
const raw = fs.readFileSync(filePath, "utf8");
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const idx = trimmed.indexOf("=");
if (idx === -1) {
continue;
}
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (!(key in process.env)) {
process.env[key] = value;
}
}
}