From f9d3f94c6809556163bc16a2dbe14688e0fb7efe Mon Sep 17 00:00:00 2001 From: krk Date: Wed, 6 May 2026 09:56:50 +0300 Subject: [PATCH] error handlind, ui status --- .env.app.example | 1 + src/index.ts | 2 + src/services/transmission-client-port-test.ts | 82 ------------- src/set-port-flow.ts | 116 ++++++++++-------- src/settings.ts | 3 +- src/ui-server.ts | 85 +++++++++++++ 6 files changed, 155 insertions(+), 134 deletions(-) delete mode 100644 src/services/transmission-client-port-test.ts create mode 100644 src/ui-server.ts diff --git a/.env.app.example b/.env.app.example index 4a41a78..8a027a4 100644 --- a/.env.app.example +++ b/.env.app.example @@ -15,3 +15,4 @@ DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/your_webhook_url UPDATE_INTERVAL_SECONDS=300 TRACKER_TOKEN=test TRACKER_PORT=12345 +UI_PORT=3000 diff --git a/src/index.ts b/src/index.ts index 608da40..0a5d9b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,10 +3,12 @@ import { settings } from './settings'; import { trackerServer } from './tracker/tracker'; import { kvDataStorage } from './services/kv-data-storage'; import { onTrackerAnnounce } from './tracker-announce-flow'; +import { startUiServer } from './ui-server'; const isWorker = process.argv[2] === 'worker'; if (isWorker) { + startUiServer(settings.uiPort); trackerServer.on('announce', onTrackerAnnounce); const lastRun = kvDataStorage.get('lastRun') || 0; diff --git a/src/services/transmission-client-port-test.ts b/src/services/transmission-client-port-test.ts deleted file mode 100644 index 05da6b6..0000000 --- a/src/services/transmission-client-port-test.ts +++ /dev/null @@ -1,82 +0,0 @@ -// @ts-ignore -import TransmissionClient from 'transmission'; -import { settings } from '../settings'; - -type TransmissionOptions = { - url?: string; - host?: string; - port?: number; - ssl?: boolean; -} - -export class Transmission { - client: any; - - constructor(transmissionOptions: TransmissionOptions) { - this.client = new TransmissionClient(transmissionOptions); - } - - async getPort(): Promise { - return new Promise((resolve, reject) => { - this.client.session((err: any, arg: any) => { - if (err) { - reject(err); - } - resolve(arg['peer-port']); - }); - }); - } - - async setPort(port: number) { - return new Promise((resolve, reject) => { - this.client.callServer({ "arguments": { "peer-port": port }, "method": "session-set" }, (err: any, arg: any) => { - if (err) { - reject(err); - } - resolve(); - }); - }); - } - - async testPortIfOpen(): Promise { - return new Promise((resolve, reject) => { - this.client.callServer({ "method": "port-test" }, (err: any, arg: any) => { - if (err) { - reject(err); - return; - } - if (!('port-is-open' in arg)) { - console.log('port-is-open-arg', arg); - resolve(false); - return; - } - resolve(arg['port-is-open']); - }); - }); - } - - async testPortIfOpen2() { - this.client.callServer({ "method": "port-test" }, (err: any, arg: any) => { - if (err) { - console.error('Error testing port:', err); - return; - } - - console.log('Port test result:', arg); - }); - } -} - -export const transmissionClient = new Transmission({ - host: 'localhost', - port: 9091, -}); - -transmissionClient.getPort().then(port => { - console.log('Current Transmission port:', port); -}).catch(err => { - console.error('Error getting Transmission port:', err); -}); - - -transmissionClient.testPortIfOpen2() diff --git a/src/set-port-flow.ts b/src/set-port-flow.ts index 1871c88..5d069e7 100644 --- a/src/set-port-flow.ts +++ b/src/set-port-flow.ts @@ -10,9 +10,9 @@ export async function setPortFlow() { try { await doSetPortFlow(); } catch (e) { - console.error('Error in set port flow', e); + console.error('Unexpected error in set port flow', e); const reason = e instanceof Error ? e.message : JSON.stringify(e); - await transmissionContainer.downTransmission(`Shutting down: error in set port flow. Reason: ${reason}`); + await notificationService.sendNotification(`Unexpected error in set port flow: ${reason}`, true); } await dailyCheckIn(); } @@ -22,36 +22,43 @@ const checkIfPortOpen = true; async function doSetPortFlow() { console.log('Starting set port flow'); - const containerState = await transmissionContainer.getState(); + const containerState = await transmissionContainer.getState().catch(() => null); if (containerState?.state !== 'running') { console.log('Transmission service is not running'); await kvDataStorage.set({ isServiceRunning: false }); return; } + const numRuns = kvDataStorage.get('numRuns') || 0; await kvDataStorage.set({ isServiceRunning: true, numRuns: numRuns + 1, lastRun: Date.now() }); - console.log(`Gathering info`); - const port = await transmissionClient.getPort(); - console.log(`Port: ${port}`); - const portOpen = checkIfPortOpen ? await transmissionClient.testPortIfOpen().catch(() => 'error_checking_port') : 'not_checked'; + console.log('Gathering info'); + + let port: number; + try { + port = await transmissionClient.getPort(); + console.log(`Port: ${port}`); + await kvDataStorage.set({ currentPort: port }); + } catch (e) { + console.error('Failed to get port from Transmission RPC, skipping cycle', e); + await notificationService.sendNotification('Could not reach Transmission RPC, skipping cycle'); + return; + } + + const portOpen = checkIfPortOpen + ? await transmissionClient.testPortIfOpen().catch(() => 'error_checking_port' as const) + : 'not_checked'; console.log(`Port is open: ${portOpen}`); - const defaultInterface = await transmissionContainer.getDefaultInterface(); + + const defaultInterface = await transmissionContainer.getDefaultInterface().catch(() => null); + const ipInfo = await transmissionContainer.getIpInfo().catch(() => null); console.log(`Default interface: ${defaultInterface?.interface} Internal ip: ${defaultInterface?.ip}`); - const ipInfo = await transmissionContainer.getIpInfo(); console.log(`External ip: ${ipInfo?.ip} Country: ${ipInfo?.country}`); if (!defaultInterface || !ipInfo) { - await kvDataStorage.set({ - country: null, - internalIp: null, - externalIp: null, - interface: null, - }) - - return await transmissionContainer.downTransmission( - `Shutting down: defaultInterface: ${!!defaultInterface} ipInfo: ${!!ipInfo}`, - ); + console.error(`Could not gather network info, skipping cycle. defaultInterface: ${!!defaultInterface} ipInfo: ${!!ipInfo}`); + await notificationService.sendNotification(`Could not gather network info (interface: ${!!defaultInterface}, ipInfo: ${!!ipInfo}), skipping cycle`); + return; } await kvDataStorage.set({ @@ -59,7 +66,7 @@ async function doSetPortFlow() { internalIp: defaultInterface.ip, externalIp: ipInfo.ip, interface: defaultInterface.interface, - }) + }); const countryOK = !settings.disallowedCountries.some((country) => ipInfo.country.includes(country)); const internalIpOK = !settings.disallowedIntIps.some((ip) => defaultInterface.ip.includes(ip)); @@ -79,56 +86,63 @@ async function doSetPortFlow() { return; } - console.log(`Port ${port} is not open or not checked (status ${portOpen}). Fetching new port.`); + console.log(`Port ${port} is not open or not checked (status: ${portOpen}). Fetching new port.`); - //fetch new port - const request = await fetch(`https://connect.pvdatanet.com/v3/Api/port?ip[]=${defaultInterface.ip}`) - const data = await request.json() as {status: string, supported: boolean} - - console.log(`Data received: ${JSON.stringify(data)}`); - - if (!data.status || !data.supported) { - return await transmissionContainer.downTransmission( - `Shutting down: could not fetch new port, data: ${JSON.stringify(data)}`, - ); + let data: { status: string; supported: boolean }; + try { + const request = await fetch(`https://connect.pvdatanet.com/v3/Api/port?ip[]=${defaultInterface.ip}`); + data = await request.json() as { status: string; supported: boolean }; + console.log(`Data received: ${JSON.stringify(data)}`); + } catch (e) { + console.error('Failed to fetch new port from PrivateVPN API, skipping cycle', e); + await notificationService.sendNotification('Could not fetch new port from PrivateVPN API, skipping cycle'); + return; } - const newPort= Number(data.status.split(" ")[1]); + if (!data.status || !data.supported) { + console.error(`PrivateVPN API returned unexpected data, skipping cycle: ${JSON.stringify(data)}`); + await notificationService.sendNotification(`PrivateVPN API returned unexpected data (${JSON.stringify(data)}), skipping cycle`); + return; + } + const newPort = Number(data.status.split(' ')[1]); if (!newPort) { - return await transmissionContainer.downTransmission( - `Shutting down: could not parse new port, data: ${JSON.stringify(data)}`, - ); + console.error(`Could not parse new port, skipping cycle: ${JSON.stringify(data)}`); + await notificationService.sendNotification(`Could not parse new port from PrivateVPN API (${JSON.stringify(data)}), skipping cycle`); + return; } if (newPort === port) { - console.log(`New port ${newPort} is the same as the old port ${port}`); + console.log(`New port ${newPort} is the same as the old port`); return; } console.log(`Setting port to ${newPort}`); - const resultOK = await gluetunContainer.setOpenFirewallPort(newPort); - // TODO - remove old port from firewall? - if (!resultOK) { - return await transmissionContainer.downTransmission( - `Shutting down: could not open new port in gluetun firewall`, - ); + const firewallOK = await gluetunContainer.setOpenFirewallPort(newPort); + if (!firewallOK) { + console.error('Could not open new port in Gluetun firewall, skipping cycle'); + await notificationService.sendNotification('Could not open new port in Gluetun firewall, skipping cycle'); + return; + } + console.log('Port in Gluetun firewall opened successfully'); + + try { + await transmissionClient.setPort(newPort); + await kvDataStorage.set({ currentPort: newPort }); + } catch (e) { + console.error('Failed to set port in Transmission, skipping cycle', e); + await notificationService.sendNotification('Could not set new port in Transmission RPC, skipping cycle'); + return; } - console.log(`Port in gluetun firewall opened successfully`); - await transmissionClient.setPort(newPort); const numPortsChanged = kvDataStorage.get('numPortsChanged') || 0; await kvDataStorage.set({ numPortsChanged: numPortsChanged + 1 }); console.log('Port set'); - const newPortOpen = checkIfPortOpen ? await transmissionClient.testPortIfOpen().catch(() => 'error_checking_port') : 'not_checked'; + const newPortOpen = checkIfPortOpen + ? await transmissionClient.testPortIfOpen().catch(() => 'error_checking_port' as const) + : 'not_checked'; console.log(`Port ${newPort} is open: ${newPortOpen}`); - // if (!newPortOpen) { - // return await transmissionContainer.downTransmission( - // `Shutting down: new port is still not open, data: ${JSON.stringify(data)}`, - // ); - // } - await notificationService.sendNotification(`Port changed from ${port} to ${newPort} successfully. New port open: ${newPortOpen}.`); } diff --git a/src/settings.ts b/src/settings.ts index 4767821..ac7cf29 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -23,7 +23,8 @@ export const settings = { disallowedIntIps: process.env.DISALLOWED_INTERNAL_IPS?.split(',') ?? [], disallowedExtIps: process.env.DISALLOWED_EXT_IPS?.split(',') ?? [], discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL, - updateIntervalMs: (Number(process.env.UPDATE_INTERVAL_SECONDS) ?? 60 * 5) * 1000, + updateIntervalMs: (Number(process.env.UPDATE_INTERVAL_SECONDS) || 60 * 5) * 1000, trackerToken: process.env.TRACKER_TOKEN, trackerPort: Number(process.env.TRACKER_PORT), + uiPort: Number(process.env.UI_PORT) || 3000, } as const; diff --git a/src/ui-server.ts b/src/ui-server.ts new file mode 100644 index 0000000..40bbce1 --- /dev/null +++ b/src/ui-server.ts @@ -0,0 +1,85 @@ +import { createServer, IncomingMessage, ServerResponse } from 'node:http'; +import { kvDataStorage } from './services/kv-data-storage'; +import { transmissionContainer } from './services/transmission-container'; + +function formatDate(ts: number | null): string { + if (!ts) return 'never'; + return new Date(ts).toLocaleString('lt-LT'); +} + +function row(label: string, value: string | number | null | undefined): string { + return `${label}${value ?? '—'}`; +} + +function renderHtml(): string { + const isRunning = kvDataStorage.get('isServiceRunning'); + const statusColor = isRunning ? '#4caf50' : '#f44336'; + + return ` + + + + + pvpn-transmission + + + +

pvpn-transmission

+
${isRunning ? 'Running' : 'Stopped'}
+ + ${row('External IP', kvDataStorage.get('externalIp'))} + ${row('Internal IP', kvDataStorage.get('internalIp'))} + ${row('Country', kvDataStorage.get('country'))} + ${row('Interface', kvDataStorage.get('interface'))} + ${row('Current port', kvDataStorage.get('currentPort'))} + ${row('Last run', formatDate(kvDataStorage.get('lastRun')))} + ${row('Last check-in', formatDate(kvDataStorage.get('lastCheckIn')))} + ${row('Ports changed', kvDataStorage.get('numPortsChanged') ?? 0)} + ${row('Checks run', kvDataStorage.get('numRuns') ?? 0)} + ${row('IP mismatches', kvDataStorage.get('ipMismatchCount') ?? 0)} +
+ ${!isRunning ? `
` : ''} +

Auto-refreshes every 30s

+ +`; +} + +async function handleRequest(req: IncomingMessage, res: ServerResponse) { + if (req.method === 'GET' && req.url === '/') { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(renderHtml()); + return; + } + + if (req.method === 'POST' && req.url === '/start') { + console.log('UI: starting Transmission container'); + try { + await transmissionContainer.up(); + await kvDataStorage.set({ isServiceRunning: true }); + } catch (e) { + console.error('UI: failed to start Transmission', e); + } + res.writeHead(302, { Location: '/' }); + res.end(); + return; + } + + res.writeHead(404); + res.end(); +} + +export function startUiServer(port: number) { + createServer(handleRequest).listen(port, () => { + console.log(`UI listening on http://localhost:${port}`); + }); +}