mirror of
https://github.com/sakaljurgis/pvpn-transmission.git
synced 2026-07-08 20:47:41 +00:00
error handlind, ui status
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<number>('lastRun') || 0;
|
||||
|
||||
@@ -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<number> {
|
||||
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<void>((resolve, reject) => {
|
||||
this.client.callServer({ "arguments": { "peer-port": port }, "method": "session-set" }, (err: any, arg: any) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async testPortIfOpen(): Promise<boolean> {
|
||||
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()
|
||||
+62
-48
@@ -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<number>('numRuns') || 0;
|
||||
await kvDataStorage.set({ isServiceRunning: true, numRuns: numRuns + 1, lastRun: Date.now() });
|
||||
|
||||
console.log(`Gathering info`);
|
||||
const port = await transmissionClient.getPort();
|
||||
console.log('Gathering info');
|
||||
|
||||
let port: number;
|
||||
try {
|
||||
port = await transmissionClient.getPort();
|
||||
console.log(`Port: ${port}`);
|
||||
const portOpen = checkIfPortOpen ? await transmissionClient.testPortIfOpen().catch(() => 'error_checking_port') : 'not_checked';
|
||||
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.`);
|
||||
|
||||
//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(`Port ${port} is not open or not checked (status: ${portOpen}). Fetching new port.`);
|
||||
|
||||
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)}`);
|
||||
|
||||
if (!data.status || !data.supported) {
|
||||
return await transmissionContainer.downTransmission(
|
||||
`Shutting down: could not fetch new port, data: ${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`);
|
||||
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;
|
||||
}
|
||||
|
||||
const numPortsChanged = kvDataStorage.get<number>('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}.`);
|
||||
}
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
@@ -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 `<tr><td>${label}</td><td>${value ?? '—'}</td></tr>`;
|
||||
}
|
||||
|
||||
function renderHtml(): string {
|
||||
const isRunning = kvDataStorage.get<boolean>('isServiceRunning');
|
||||
const statusColor = isRunning ? '#4caf50' : '#f44336';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="30">
|
||||
<title>pvpn-transmission</title>
|
||||
<style>
|
||||
body { font-family: monospace; background: #1a1a1a; color: #e0e0e0; padding: 2rem; max-width: 560px; margin: 0 auto; }
|
||||
h1 { font-size: 1.1rem; color: #888; margin-bottom: 1.5rem; }
|
||||
.badge { display: inline-block; padding: 0.2rem 0.7rem; border-radius: 3px; background: ${statusColor}; color: #fff; font-weight: bold; margin-bottom: 1.5rem; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 1.5rem; }
|
||||
td { padding: 0.35rem 0.5rem; border-bottom: 1px solid #2a2a2a; }
|
||||
td:first-child { color: #888; width: 45%; }
|
||||
button { padding: 0.5rem 1.2rem; background: #1976d2; color: #fff; border: none; border-radius: 3px; cursor: pointer; font-family: monospace; font-size: 0.95rem; }
|
||||
button:hover { background: #1565c0; }
|
||||
.hint { color: #555; font-size: 0.8rem; margin-top: 1.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>pvpn-transmission</h1>
|
||||
<div class="badge">${isRunning ? 'Running' : 'Stopped'}</div>
|
||||
<table>
|
||||
${row('External IP', kvDataStorage.get<string>('externalIp'))}
|
||||
${row('Internal IP', kvDataStorage.get<string>('internalIp'))}
|
||||
${row('Country', kvDataStorage.get<string>('country'))}
|
||||
${row('Interface', kvDataStorage.get<string>('interface'))}
|
||||
${row('Current port', kvDataStorage.get<number>('currentPort'))}
|
||||
${row('Last run', formatDate(kvDataStorage.get<number>('lastRun')))}
|
||||
${row('Last check-in', formatDate(kvDataStorage.get<number>('lastCheckIn')))}
|
||||
${row('Ports changed', kvDataStorage.get<number>('numPortsChanged') ?? 0)}
|
||||
${row('Checks run', kvDataStorage.get<number>('numRuns') ?? 0)}
|
||||
${row('IP mismatches', kvDataStorage.get<number>('ipMismatchCount') ?? 0)}
|
||||
</table>
|
||||
${!isRunning ? `<form method="POST" action="/start"><button type="submit">Start Transmission</button></form>` : ''}
|
||||
<p class="hint">Auto-refreshes every 30s</p>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
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}`);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user