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(), loginAllowedDomains: parseDomainList(process.env.ALLOWED_LOGIN_DOMAINS || "arcg.at,oevsv.at"), 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: [], oauthChallenges: [] }, approvalRequests: [], systemState: { maintenanceMode: false, maintenanceMessage: "Wartungsmodus aktiv. Login ist derzeit deaktiviert.", branding: { logoLightUrl: null, logoDarkUrl: null }, loginAllowedDomains: config.loginAllowedDomains, 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, allowedLoginDomains: Array.isArray(runtime.systemState.loginAllowedDomains) ? runtime.systemState.loginAllowedDomains : config.loginAllowedDomains, 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/auth/oauth/callback") { return handleOauthCallback(req, res, url); } 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 === "DELETE" && url.pathname.startsWith("/v1/station/reservations/")) { const auth = requireAuth(req, res); if (!auth) return; if (!hasRole(auth.user, ["admin"])) { return sendError(res, 403, "auth.forbidden", "Nicht berechtigt"); } const encodedUserId = decodeURIComponent(url.pathname.slice("/v1/station/reservations/".length) || "").trim(); if (!encodedUserId || encodedUserId === "next") { return sendError(res, 400, "station.reservation.user_missing", "Benutzer-ID fuer Reservierungsloeschung fehlt"); } if (!enforceRateLimit(req, res, `station:reserve-delete-admin:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) { return; } return handleDeleteReservationByUserId(res, auth.user, encodedUserId, true); } 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 === "DELETE" && url.pathname.match(/^\/v1\/approvals\/[^/]+$/)) { const auth = requireAuth(req, res); if (!auth) return; if (!hasRole(auth.user, ["admin"])) { return sendError(res, 403, "auth.forbidden", "Nur Admin"); } return handleApprovalDelete(res, auth.user, url.pathname); } 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 === "DELETE" && url.pathname.match(/^\/v1\/activity-log\/[^/]+$/)) { const auth = requireAuth(req, res); if (!auth) return; if (!hasRole(auth.user, ["admin"])) { return sendError(res, 403, "auth.forbidden", "Nur Admin"); } return handleActivityLogDelete(res, auth.user, url.pathname); } 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 === "GET" && url.pathname === "/v1/admin/login-domains") { 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, { ok: true, domains: Array.isArray(runtime.systemState.loginAllowedDomains) ? runtime.systemState.loginAllowedDomains : config.loginAllowedDomains, updatedAt: runtime.systemState.updatedAt }); } if (method === "PUT" && url.pathname === "/v1/admin/login-domains") { 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 handleLoginDomainsUpdate(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"); } if (!isLoginDomainAllowed(email)) { return sendError(res, 403, "auth.email.domain_forbidden", "Nur Club-Mailadressen (@arcg.at oder @oevsv.at) sind zum Anmelden moeglich"); } 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 (requestedMethod && !selectedMethod) { return sendError(res, 400, "auth.method_unavailable", "Gewaehlte Bestaetigungsart ist fuer dieses Konto nicht verfuegbar"); } 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, requestedMethod: requestedMethod || null, 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 if (selectedMethod.type === "oauth") { const purpose = user.status === "active" ? "login" : "verify"; const stateToken = await issueOauthChallenge(user.id, purpose, selectedMethod.id); const plugin = runtime.plugins.get(selectedMethod.pluginId); if (!plugin || typeof plugin.instance.execute !== "function") { return sendError(res, 409, "auth.oauth_not_configured", "OAuth Provider ist nicht konfiguriert"); } const callbackUrl = `${publicBaseUrlFor(req)}/v1/auth/oauth/callback`; const oauthResult = await plugin.instance.execute("start_oauth", { methodId: selectedMethod.id, methodType: selectedMethod.type, user: sanitizeUser(user), recipient: user.email, origin: publicBaseUrlFor(req), payload: { purpose, state: stateToken, callbackUrl } }, { user }); const authorizeUrl = oauthResult && oauthResult.authorizeUrl ? String(oauthResult.authorizeUrl) : ""; if (!authorizeUrl) { return sendError(res, 409, "auth.oauth_not_configured", "OAuth Provider liefert keine Authorize-URL"); } challengeType = "oauth"; challengeHint = `Anmeldung ueber ${selectedMethod.label}`; await appendAudit("auth.request_access", user, { status: user.status, requestedMethod: requestedMethod || null, method: selectedMethod.id, challengeType, oauthRedirect: true }); return sendJson(res, 200, { ok: true, method: selectedMethod.id, challengeType, challengeHint, authorizeUrl, message: "Weiterleitung zum OAuth-Provider...", domainAllowed: isPrimaryDomainEmail(email), requestApprovalHint: !isPrimaryDomainEmail(email) ? "Adresse ausserhalb der Hauptdomain: Nach Bestaetigung wird eine Freigabe angefordert." : null }); } else { return sendError(res, 400, "auth.method_invalid", "Unbekannte Bestaetigungsart"); } await appendAudit("auth.request_access", user, { status: user.status, requestedMethod: requestedMethod || null, method: selectedMethod.id, challengeType }); 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"); } const result = await finalizeLoginForVerification(req, user, verification.purpose, verification.purpose); if (result && result.error) { return sendError(res, result.error.status, result.error.code, result.error.message); } return sendJson(res, 200, result); } async function handleOauthCallback(req, res, url) { const error = String(url.searchParams.get("error") || "").trim(); const errorDescription = String(url.searchParams.get("error_description") || "").trim(); const stateToken = String(url.searchParams.get("state") || "").trim(); const code = String(url.searchParams.get("code") || "").trim(); if (error) { return redirectToLoginWithAuthError(res, `oauth_${error}`, errorDescription || "OAuth Login fehlgeschlagen"); } if (!stateToken || !code) { return redirectToLoginWithAuthError(res, "auth.oauth_missing", "OAuth Parameter fehlen"); } const challenge = consumeOauthChallenge(stateToken); if (!challenge.ok) { return redirectToLoginWithAuthError(res, "auth.oauth_invalid", challenge.message || "OAuth state ungueltig"); } const user = runtime.users.find((entry) => String(entry.id || "") === String(challenge.userId || "")); if (!user) { return redirectToLoginWithAuthError(res, "auth.user_not_found", "Benutzer nicht gefunden"); } const method = findPublicAuthMethodById(challenge.methodId); if (!method || method.type !== "oauth") { return redirectToLoginWithAuthError(res, "auth.oauth_method_invalid", "OAuth Methode ist nicht verfuegbar"); } const plugin = runtime.plugins.get(method.pluginId); if (!plugin || typeof plugin.instance.execute !== "function") { return redirectToLoginWithAuthError(res, "auth.oauth_not_configured", "OAuth Provider ist nicht konfiguriert"); } try { const callbackUrl = `${publicBaseUrlFor(req)}/v1/auth/oauth/callback`; const oauthResult = await plugin.instance.execute("finish_oauth", { methodId: method.id, methodType: method.type, user: sanitizeUser(user), recipient: user.email, origin: publicBaseUrlFor(req), payload: { code, state: stateToken, callbackUrl, challengePurpose: challenge.purpose } }, { user }); const oauthEmail = normalizeEmail(oauthResult && oauthResult.email ? oauthResult.email : user.email); if (!oauthEmail || oauthEmail !== normalizeEmail(user.email)) { await appendAudit("auth.oauth.email_mismatch", user, { method: method.id, oauthEmail: oauthEmail || null }); return redirectToLoginWithAuthError(res, "auth.oauth_email_mismatch", "OAuth E-Mail passt nicht zum angeforderten Konto"); } const finalPurpose = challenge.purpose === "verify" ? "verifyToken" : "loginToken"; const token = await issueEmailToken(user.id, challenge.purpose === "verify" ? "verify" : "login"); await appendAudit("auth.oauth.callback", user, { method: method.id, purpose: challenge.purpose }); res.writeHead(302, { Location: `/login?${finalPurpose}=${encodeURIComponent(token)}` }); res.end(); } catch (oauthError) { const message = String(oauthError && oauthError.message ? oauthError.message : oauthError); await appendAudit("auth.oauth.callback.failed", user, { method: method.id, purpose: challenge.purpose, error: message }); return redirectToLoginWithAuthError(res, "auth.oauth_failed", message || "OAuth Callback fehlgeschlagen"); } } 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) }); } async function handleApprovalDelete(res, actor, pathname) { const match = pathname.match(/^\/v1\/approvals\/([^/]+)$/); if (!match) { return sendError(res, 404, "approval.not_found", "Freigabe nicht gefunden"); } const approvalId = decodeURIComponent(match[1]); const index = runtime.approvalRequests.findIndex((entry) => entry.id === approvalId); if (index < 0) { return sendError(res, 404, "approval.not_found", "Freigabe nicht gefunden"); } const removed = runtime.approvalRequests[index]; runtime.approvalRequests.splice(index, 1); await saveApprovalRequests(); await appendAudit("approval.delete", actor, { approvalId, email: removed && removed.email ? removed.email : null, status: removed && removed.status ? removed.status : null }); return sendJson(res, 200, { ok: true, deletedId: approvalId }); } 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 handleLoginDomainsUpdate(res, actor, body) { const domains = normalizeLoginDomainList(body && body.domains); if (domains.length === 0) { return sendError(res, 400, "auth.login_domains.invalid", "Mindestens eine gueltige Domain ist erforderlich"); } runtime.systemState.loginAllowedDomains = domains; runtime.systemState.updatedAt = new Date().toISOString(); await saveSystemState(); await appendAudit("admin.login_domains.update", actor, { domains }); return sendJson(res, 200, { ok: true, domains, 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 : {}; const loginAllowedDomains = normalizeLoginDomainList(input.loginAllowedDomains); 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 }, loginAllowedDomains: loginAllowedDomains.length > 0 ? loginAllowedDomains : config.loginAllowedDomains, 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; changed = ensureOauthSettings() || changed; if (changed) { await savePluginState(); } } function ensureOauthSettings() { const pluginId = "rms.auth.oauth"; const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object" ? { ...runtime.pluginState.settings[pluginId] } : {}; const next = { ...current, authorizeUrl: current.authorizeUrl || "https://accounts.google.com/o/oauth2/v2/auth", tokenUrl: current.tokenUrl || "https://oauth2.googleapis.com/token", userInfoUrl: current.userInfoUrl || "https://openidconnect.googleapis.com/v1/userinfo", clientId: current.clientId || "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com", clientSecret: current.clientSecret || "YOUR_GOOGLE_CLIENT_SECRET", scope: current.scope || "openid email profile", redirectUri: current.redirectUri || "", emailField: current.emailField || "email", authStyle: current.authStyle || "body", audience: current.audience || "", extraAuthorizeParams: current.extraAuthorizeParams || "", extraTokenParams: current.extraTokenParams || "" }; if (JSON.stringify(current) !== JSON.stringify(next)) { runtime.pluginState.settings[pluginId] = next; return true; } return false; } 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(); if (requestedMethodId) { const requestedPublic = all.find((entry) => entry.id === requestedMethodId) || null; if (requestedPublic && requestedPublic.type === "otp") { return requestedPublic; } } 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; } function findPublicAuthMethodById(methodId) { const needle = String(methodId || "").trim(); if (!needle) { return null; } return listPublicAuthMethods().find((entry) => entry.id === needle) || null; } async function issueOauthChallenge(userId, purpose, methodId) { const state = crypto.randomBytes(24).toString("base64url"); runtime.authState.oauthChallenges.push({ id: crypto.randomUUID(), userId, purpose, methodId, state, createdAt: new Date().toISOString(), expiresAtMs: Date.now() + 10 * 60 * 1000, consumedAt: null }); pruneOauthChallenges(); await saveAuthState(); return state; } function consumeOauthChallenge(state) { const token = String(state || "").trim(); if (!token) { return { ok: false, message: "OAuth state fehlt" }; } const now = Date.now(); const record = runtime.authState.oauthChallenges .filter((entry) => !entry.consumedAt && now <= Number(entry.expiresAtMs || 0)) .find((entry) => safeEquals(String(entry.state || ""), token)); if (!record) { return { ok: false, message: "OAuth state ungueltig oder abgelaufen" }; } record.consumedAt = new Date().toISOString(); saveAuthState().catch(() => {}); return { ok: true, userId: record.userId, purpose: record.purpose, methodId: record.methodId, state: record.state }; } function pruneOauthChallenges() { const now = Date.now(); runtime.authState.oauthChallenges = runtime.authState.oauthChallenges.filter((entry) => { if (entry.consumedAt && now - new Date(entry.consumedAt).getTime() > 24 * 60 * 60 * 1000) { return false; } return Number(entry.expiresAtMs || 0) > now - 24 * 60 * 60 * 1000; }); } async function finalizeLoginForVerification(req, user, purpose, via) { if (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 { 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 { error: { status: 503, code: "auth.maintenance", message: runtime.systemState.maintenanceMessage } }; } if (user.status !== "active") { return { error: { status: 403, code: "auth.not_approved", message: "Benutzer ist noch nicht freigegeben" } }; } const tokens = await issueTokenPair(user, req); await appendAudit("auth.login", user, { sid: tokens.sid, via: via || purpose }); return { ok: true, user: sanitizeUser(user), accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresInSec: config.accessTokenTtlSec }; } function redirectToLoginWithAuthError(res, code, message) { const params = new URLSearchParams(); params.set("authError", String(code || "auth.oauth_failed")); if (message) { params.set("authMessage", String(message)); } res.writeHead(302, { Location: `/login?${params.toString()}` }); res.end(); } 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, "
"); const safeActionLabel = escapeHtml(actionLabel); const safeActionLink = escapeHtml(actionLink); const safeCode = escapeHtml(code); const html = [ "", '', '' + safeSubject + "", '', '', '", "
', '', '", '", '", "
', (logoUrl ? 'Logo' : ""), '
ARCG RemoteStation
', '

' + safeSubject + "

", "
', '

Hallo' + (user && user.email ? " " + escapeHtml(user.email) : "") + ",

", '

' + safeTextHtml + "

", (code ? '
Dein Code' + safeCode + "
" : ""), (actionLink ? '

' + safeActionLabel + "

" : ""), (actionLink ? '

Falls der Button nicht funktioniert, oeffne diesen Link im Browser:
' + safeActionLink + "

" : ""), "
', "Diese Nachricht wurde automatisch von " + safeStationName + " gesendet.", "
", "
", "", "" ].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, "&") .replace(//g, ">") .replace(/\"/g, """) .replace(/'/g, "'"); } function domainForEmail(email) { const at = email.indexOf("@"); return at === -1 ? "" : email.slice(at + 1).toLowerCase(); } function parseDomainList(value) { return normalizeLoginDomainList(String(value || "")); } function normalizeLoginDomainList(value) { const list = Array.isArray(value) ? value : String(value || "") .split(/[\n,;\s]+/); const unique = []; const seen = new Set(); for (const entry of list) { const domain = String(entry || "").trim().toLowerCase(); if (!domain) continue; if (!/^[a-z0-9.-]+$/.test(domain)) continue; if (!domain.includes(".")) continue; if (seen.has(domain)) continue; seen.add(domain); unique.push(domain); } return unique; } function isPrimaryDomainEmail(email) { return domainForEmail(email) === config.primaryEmailDomain; } function isLoginDomainAllowed(email) { const allowed = Array.isArray(runtime.systemState && runtime.systemState.loginAllowedDomains) ? runtime.systemState.loginAllowedDomains : config.loginAllowedDomains; const domain = domainForEmail(email); return allowed.includes(domain); } 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) { if (runtime.station.manualReleaseReservationUserId || runtime.station.manualReleaseReservationUntil) { runtime.station.manualReleaseReservationUserId = null; runtime.station.manualReleaseReservationUntil = null; runtime.station.updatedAt = new Date().toISOString(); await writeJson(files.station, runtime.station); } return false; } const holdUserId = String(runtime.station.manualReleaseReservationUserId || ""); const holdUntilMs = parseIsoMs(runtime.station.manualReleaseReservationUntil); if ( holdUserId && Number.isFinite(holdUntilMs) && holdUntilMs > nowMs && String(activeEntry.userId || "") === holdUserId ) { return false; } if (holdUserId || runtime.station.manualReleaseReservationUntil) { runtime.station.manualReleaseReservationUserId = null; runtime.station.manualReleaseReservationUntil = null; runtime.station.updatedAt = new Date().toISOString(); await writeJson(files.station, runtime.station); } 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, manualReleaseReservationUserId: null, manualReleaseReservationUntil: null, 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 (!Object.prototype.hasOwnProperty.call(runtime.station, "manualReleaseReservationUserId")) { runtime.station.manualReleaseReservationUserId = null; changed = true; } if (!Object.prototype.hasOwnProperty.call(runtime.station, "manualReleaseReservationUntil")) { runtime.station.manualReleaseReservationUntil = 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, manualReleaseReservationUserId: null, manualReleaseReservationUntil: null, 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, manualReleaseReservationUserId: lockInfo.activeEntry && String(lockInfo.activeEntry.userId || "") === String(user && user.id ? user.id : "") ? String(user.id || "") : null, manualReleaseReservationUntil: lockInfo.activeEntry && String(lockInfo.activeEntry.userId || "") === String(user && user.id ? user.id : "") ? String(lockInfo.activeEntry.to || "") : null, updatedAt: new Date().toISOString(), lastAction: "deactivate" }; await writeJson(files.station, runtime.station); await appendAudit("station.deactivate", user, null); 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 handleDeleteReservationByUserId(res, user, user.id, false); } async function handleDeleteReservationByUserId(res, actorUser, targetUserId, adminMode = false) { return withLock(async () => { const nowMs = Date.now(); const reservations = getNormalizedStationReservations(nowMs); const targetId = String(targetUserId || ""); const targetEntry = reservations.find((entry) => String(entry.userId || "") === targetId); if (!targetEntry) { return sendError( res, 404, "station.reservation.not_found", adminMode ? "Reservierung nicht gefunden" : "Keine eigene Reservierung gefunden" ); } const removed = removeReservationByUserId(reservations, targetId); if (!removed.changed) { return sendError( res, 404, "station.reservation.not_found", adminMode ? "Reservierung nicht gefunden" : "Keine eigene Reservierung gefunden" ); } const fromMs = parseIsoMs(targetEntry.from); const toMs = parseIsoMs(targetEntry.to); const deletingActiveSlot = Number.isFinite(fromMs) && Number.isFinite(toMs) && fromMs <= nowMs && nowMs < toMs; const targetIsCurrentOwner = runtime.station.isInUse && String(runtime.station.activeByUserId || "") === targetId; if (deletingActiveSlot && targetIsCurrentOwner) { try { await safeShutdownStationSession(actorUser, "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, manualReleaseReservationUserId: String(runtime.station.manualReleaseReservationUserId || "") === targetId ? null : (runtime.station.manualReleaseReservationUserId || null), manualReleaseReservationUntil: String(runtime.station.manualReleaseReservationUserId || "") === targetId ? null : (runtime.station.manualReleaseReservationUntil || null), updatedAt: new Date().toISOString(), lastAction: adminMode ? "reserve-remove-current-admin" : "reserve-remove-current" }; await writeJson(files.station, runtime.station); await appendAudit("station.reserve.remove", actorUser, { queueLength: removed.reservations.length, currentSlot: true, targetUserId: targetId, mode: adminMode ? "admin" : "self" }); const autoActivated = await autoActivateReservationSlotIfNeededUnlocked({ excludeUserId: targetId }); if (!autoActivated) { broadcastEvent("station.status.changed", buildStationStatusView()); } return sendJson(res, 200, { ok: true, status: buildStationStatusView() }); } runtime.station = { ...runtime.station, reservations: removed.reservations, manualReleaseReservationUserId: String(runtime.station.manualReleaseReservationUserId || "") === targetId ? null : (runtime.station.manualReleaseReservationUserId || null), manualReleaseReservationUntil: String(runtime.station.manualReleaseReservationUserId || "") === targetId ? null : (runtime.station.manualReleaseReservationUntil || null), updatedAt: new Date().toISOString(), lastAction: adminMode ? "reserve-remove-admin" : "reserve-remove" }; await writeJson(files.station, runtime.station); await appendAudit("station.reserve.remove", actorUser, { queueLength: removed.reservations.length, targetUserId: targetId, mode: adminMode ? "admin" : "self" }); 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), getPluginSetting: (pluginId, key, fallback = null) => getPluginSetting(pluginId, 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] = plugin && plugin.manifest && typeof plugin.manifest.defaultEnabled === "boolean" ? plugin.manifest.defaultEnabled : 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: [], manualReleaseReservationUserId: null, manualReleaseReservationUntil: null, 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; } if (!Array.isArray(runtime.authState.oauthChallenges)) { runtime.authState.oauthChallenges = []; 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 = { id: crypto.randomUUID(), 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 (let index = 0; index < lines.length; index += 1) { const line = lines[index]; const entry = parseAuditLine(line); if (!entry) { continue; } if (!entry || !interesting.has(entry.action)) { continue; } const id = typeof entry.id === "string" && entry.id.trim() ? entry.id.trim() : `legacy-${index}`; entries.push({ id, 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); } async function handleActivityLogDelete(res, actor, pathname) { const match = pathname.match(/^\/v1\/activity-log\/([^/]+)$/); if (!match) { return sendError(res, 404, "activity.not_found", "Aktivitaetseintrag nicht gefunden"); } const targetId = decodeURIComponent(match[1]); const raw = await storage.readText(files.audit, ""); const lines = raw .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); const kept = []; let removed = null; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; const entry = parseAuditLine(line); const id = entry && typeof entry.id === "string" && entry.id.trim() ? entry.id.trim() : `legacy-${index}`; if (!removed && id === targetId) { removed = entry || { action: "unknown", at: null }; continue; } kept.push(line); } if (!removed) { return sendError(res, 404, "activity.not_found", "Aktivitaetseintrag nicht gefunden"); } const nextRaw = kept.length ? `${kept.join("\n")}\n` : ""; await storage.writeText(files.audit, nextRaw); await appendAudit("activity.delete", actor, { removedId: targetId, removedAction: removed.action || null, removedAt: removed.at || null, removedEmail: removed.email || null }); return sendJson(res, 200, { ok: true, deletedId: targetId }); } function parseAuditLine(line) { try { const parsed = JSON.parse(line); return parsed && typeof parsed === "object" ? parsed : null; } catch { return null; } } 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; } } }