Files
ARCG-Remote-Station-Software/server/index.js

8053 lines
283 KiB
JavaScript

const http = require("http");
const fs = require("fs");
const fsp = require("fs/promises");
const path = require("path");
const crypto = require("crypto");
const { spawn } = require("child_process");
const { WebSocketServer } = require("ws");
const { createStorageProvider } = require("./storage");
const DEBUG_REMOTE_INTERFACE_ENABLED = true;
const DEBUG_REMOTE_DEFAULT_TOKEN = "dbg_8f4e5c9b71a64f2fa3e7c1d6b5a9e2f0_owrx";
const rootDir = path.resolve(__dirname, "..");
const initialEnvKeys = new Set(Object.keys(process.env));
if (!initialEnvKeys.has("DATA_DIR")) {
loadDotEnv(path.join(rootDir, ".env"));
}
const defaultDataDir = resolveDefaultDataDir(rootDir);
const config = {
port: Number(process.env.PORT || 8080),
dataDir: path.resolve(rootDir, process.env.DATA_DIR || defaultDataDir),
brandingUploadsDir: path.resolve(rootDir, process.env.BRANDING_UPLOADS_DIR || path.join(process.env.DATA_DIR || defaultDataDir, "uploads")),
vswrImagesDir: path.resolve(rootDir, process.env.VSWR_IMAGES_DIR_PATH || path.join(process.env.DATA_DIR || defaultDataDir, "vswr", "images")),
storageProvider: String(process.env.STORAGE_PROVIDER || "json").trim(),
storageModulePath: String(process.env.STORAGE_MODULE_PATH || "").trim(),
storageSqlitePath: String(process.env.STORAGE_SQLITE_PATH || "").trim(),
pluginDir: path.resolve(rootDir, process.env.PLUGIN_DIR || "./plugins"),
stationName: process.env.STATION_NAME || "ARCG Stradnerkogel",
adminEmails: new Set(
String(process.env.ADMIN_EMAILS || "")
.split(",")
.map((value) => normalizeEmail(value))
.filter(Boolean)
),
accessTokenTtlSec: Number(process.env.ACCESS_TOKEN_TTL_SEC || 10800),
refreshTokenTtlSec: Number(process.env.REFRESH_TOKEN_TTL_SEC || 60 * 60 * 24 * 14),
jwtSecret: process.env.JWT_SECRET || "change-me-in-production",
jwtIssuer: process.env.JWT_ISSUER || "rms-arcg",
jwtAudience: process.env.JWT_AUDIENCE || "rms-clients",
authRateWindowMs: Number(process.env.AUTH_RATE_WINDOW_MS || 10 * 60 * 1000),
authRateLimit: Number(process.env.AUTH_RATE_LIMIT || 25),
actionRateWindowMs: Number(process.env.ACTION_RATE_WINDOW_MS || 60 * 1000),
actionRateLimit: Number(process.env.ACTION_RATE_LIMIT || 30),
openWebRxPttLiveFreqToleranceHz: Number(process.env.OPENWEBRX_PTT_LIVE_FREQ_TOLERANCE_HZ || 50),
openWebRxLiveStateTtlMs: Number(process.env.OPENWEBRX_LIVE_STATE_TTL_MS || 10000),
openWebRxPttBlockedBandConfigIdsRaw: String(process.env.OPENWEBRX_PTT_BLOCKED_BAND_CONFIG_IDS || "27,6,24"),
openWebRxTxPollMs: Number(process.env.OPENWEBRX_TX_POLL_MS || 3000),
txAudioEnabled: String(process.env.TX_AUDIO_ENABLED || "true").trim().toLowerCase(),
txAudioAlsaDevice: String(process.env.TX_AUDIO_ALSA_DEVICE || "plughw:CARD=CODEC,DEV=0").trim(),
txAudioInputMime: String(process.env.TX_AUDIO_INPUT_MIME || "webm").trim().toLowerCase(),
txAudioStopOnDisconnect: String(process.env.TX_AUDIO_STOP_ON_DISCONNECT || "true").trim().toLowerCase(),
txAudioChunkMs: Number(process.env.TX_AUDIO_CHUNK_MS || 100),
txAudioSessionTimeoutMs: Number(process.env.TX_AUDIO_SESSION_TIMEOUT_MS || 120000),
txAudioFfmpegPath: String(process.env.TX_AUDIO_FFMPEG_PATH || "").trim(),
txAudioFfmpegExtraArgs: String(process.env.TX_AUDIO_FFMPEG_EXTRA_ARGS || "").trim(),
openWebRxPttCommandsEnabled: String(process.env.OPENWEBRX_PTT_COMMANDS_ENABLED || "false").trim().toLowerCase(),
openWebRxPttDevice: String(process.env.OPENWEBRX_PTT_DEVICE || process.env.RMS_MICROHAM_DEV || "/dev/rms-microham-u3").trim(),
openWebRxPttTimeoutMs: Number(process.env.OPENWEBRX_PTT_TIMEOUT_MS || 5000),
openWebRxPttDownCmd: String(process.env.OPENWEBRX_PTT_DOWN_CMD || "").trim(),
openWebRxPttUpCmd: String(process.env.OPENWEBRX_PTT_UP_CMD || "").trim(),
rotorDevice: String(process.env.RMS_ROTOR_DEV || process.env.RMS_FTDI_DEV || "/dev/rms-ftdi-uart").trim(),
rotorModel: Number(process.env.ROTOR_ROTCTL_MODEL || 902),
rotorSetEnabled: String(process.env.ROTOR_SET_ENABLED || "true").trim().toLowerCase(),
rotorGetTimeoutMs: Number(process.env.ROTOR_GET_TIMEOUT_MS || 10000),
rotorSetTimeoutMs: Number(process.env.ROTOR_SET_TIMEOUT_MS || 20000),
rotorMonitorMaxMs: Number(process.env.ROTOR_MONITOR_MAX_MS || 120000),
rotorStatusRetryCount: Number(process.env.ROTOR_STATUS_RETRY_COUNT || 6),
rotorStatusRetryDelayMs: Number(process.env.ROTOR_STATUS_RETRY_DELAY_MS || 400),
rotorPostSetStatusTimeoutMs: Number(process.env.ROTOR_POST_SET_STATUS_TIMEOUT_MS || 5000),
autoDisableTxBeforeActivation: String(process.env.AUTO_DISABLE_TX_BEFORE_ACTIVATION || "").trim().toLowerCase(),
stationMaxUsageSec: Number(process.env.STATION_MAX_USAGE_SEC || 3600),
primaryEmailDomain: String(process.env.PRIMARY_EMAIL_DOMAIN || "arcg.at").toLowerCase(),
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, "<br>");
const safeActionLabel = escapeHtml(actionLabel);
const safeActionLink = escapeHtml(actionLink);
const safeCode = escapeHtml(code);
const html = [
"<!doctype html>",
'<html lang="de">',
'<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' + safeSubject + "</title></head>",
'<body style="margin:0;padding:0;background:#f5f7fb;color:#10253f;font-family:Arial,Helvetica,sans-serif;">',
'<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="padding:28px 12px;">',
'<tr><td align="center">',
'<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:620px;background:#ffffff;border:1px solid #e3e8ef;border-radius:14px;overflow:hidden;">',
'<tr><td style="padding:20px 24px;border-bottom:1px solid #edf1f6;">',
(logoUrl ? '<img src="' + escapeHtml(logoUrl) + '" alt="Logo" style="max-height:54px;max-width:220px;display:block;margin:0 0 12px 0;">' : ""),
'<div style="font-size:12px;color:#5d6f85;letter-spacing:0.06em;text-transform:uppercase;">ARCG RemoteStation</div>',
'<h1 style="margin:6px 0 0 0;font-size:22px;line-height:1.3;color:#10253f;">' + safeSubject + "</h1>",
"</td></tr>",
'<tr><td style="padding:24px;">',
'<p style="margin:0 0 12px 0;font-size:15px;line-height:1.6;">Hallo' + (user && user.email ? " " + escapeHtml(user.email) : "") + ",</p>",
'<p style="margin:0 0 16px 0;font-size:15px;line-height:1.65;color:#233b57;">' + safeTextHtml + "</p>",
(code ? '<div style="margin:0 0 16px 0;padding:14px 16px;border:1px dashed #9fb1c8;border-radius:10px;background:#f8fbff;"><span style="font-size:12px;color:#5d6f85;display:block;margin-bottom:6px;">Dein Code</span><strong style="font-size:26px;letter-spacing:0.08em;color:#10253f;">' + safeCode + "</strong></div>" : ""),
(actionLink ? '<p style="margin:0 0 18px 0;"><a href="' + safeActionLink + '" style="display:inline-block;background:#0b4fa8;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:8px;font-weight:700;">' + safeActionLabel + "</a></p>" : ""),
(actionLink ? '<p style="margin:0;font-size:12px;line-height:1.5;color:#5d6f85;word-break:break-all;">Falls der Button nicht funktioniert, oeffne diesen Link im Browser:<br><a href="' + safeActionLink + '" style="color:#0b4fa8;">' + safeActionLink + "</a></p>" : ""),
"</td></tr>",
'<tr><td style="padding:16px 24px;background:#f8fafc;border-top:1px solid #edf1f6;font-size:12px;line-height:1.6;color:#5d6f85;">',
"Diese Nachricht wurde automatisch von " + safeStationName + " gesendet.",
"</td></tr>",
"</table>",
"</td></tr>",
"</table>",
"</body>",
"</html>"
].join("");
return { subject, text, html };
}
function toAbsoluteUrl(baseUrl, maybeRelative) {
const value = String(maybeRelative || "").trim();
if (!value) {
return "";
}
if (/^https?:\/\//i.test(value)) {
return value;
}
const base = String(baseUrl || "").trim();
if (!base) {
return value;
}
const baseTrimmed = base.replace(/\/$/, "");
return `${baseTrimmed}/${value.replace(/^\/+/, "")}`;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function domainForEmail(email) {
const at = email.indexOf("@");
return at === -1 ? "" : email.slice(at + 1).toLowerCase();
}
function 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;
}
}
}