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
|
UPDATE_INTERVAL_SECONDS=300
|
||||||
TRACKER_TOKEN=test
|
TRACKER_TOKEN=test
|
||||||
TRACKER_PORT=12345
|
TRACKER_PORT=12345
|
||||||
|
UI_PORT=3000
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { settings } from './settings';
|
|||||||
import { trackerServer } from './tracker/tracker';
|
import { trackerServer } from './tracker/tracker';
|
||||||
import { kvDataStorage } from './services/kv-data-storage';
|
import { kvDataStorage } from './services/kv-data-storage';
|
||||||
import { onTrackerAnnounce } from './tracker-announce-flow';
|
import { onTrackerAnnounce } from './tracker-announce-flow';
|
||||||
|
import { startUiServer } from './ui-server';
|
||||||
|
|
||||||
const isWorker = process.argv[2] === 'worker';
|
const isWorker = process.argv[2] === 'worker';
|
||||||
|
|
||||||
if (isWorker) {
|
if (isWorker) {
|
||||||
|
startUiServer(settings.uiPort);
|
||||||
trackerServer.on('announce', onTrackerAnnounce);
|
trackerServer.on('announce', onTrackerAnnounce);
|
||||||
|
|
||||||
const lastRun = kvDataStorage.get<number>('lastRun') || 0;
|
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 {
|
try {
|
||||||
await doSetPortFlow();
|
await doSetPortFlow();
|
||||||
} catch (e) {
|
} 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);
|
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();
|
await dailyCheckIn();
|
||||||
}
|
}
|
||||||
@@ -22,36 +22,43 @@ const checkIfPortOpen = true;
|
|||||||
async function doSetPortFlow() {
|
async function doSetPortFlow() {
|
||||||
console.log('Starting set port flow');
|
console.log('Starting set port flow');
|
||||||
|
|
||||||
const containerState = await transmissionContainer.getState();
|
const containerState = await transmissionContainer.getState().catch(() => null);
|
||||||
if (containerState?.state !== 'running') {
|
if (containerState?.state !== 'running') {
|
||||||
console.log('Transmission service is not running');
|
console.log('Transmission service is not running');
|
||||||
await kvDataStorage.set({ isServiceRunning: false });
|
await kvDataStorage.set({ isServiceRunning: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const numRuns = kvDataStorage.get<number>('numRuns') || 0;
|
const numRuns = kvDataStorage.get<number>('numRuns') || 0;
|
||||||
await kvDataStorage.set({ isServiceRunning: true, numRuns: numRuns + 1, lastRun: Date.now() });
|
await kvDataStorage.set({ isServiceRunning: true, numRuns: numRuns + 1, lastRun: Date.now() });
|
||||||
|
|
||||||
console.log(`Gathering info`);
|
console.log('Gathering info');
|
||||||
const port = await transmissionClient.getPort();
|
|
||||||
|
let port: number;
|
||||||
|
try {
|
||||||
|
port = await transmissionClient.getPort();
|
||||||
console.log(`Port: ${port}`);
|
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}`);
|
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}`);
|
console.log(`Default interface: ${defaultInterface?.interface} Internal ip: ${defaultInterface?.ip}`);
|
||||||
const ipInfo = await transmissionContainer.getIpInfo();
|
|
||||||
console.log(`External ip: ${ipInfo?.ip} Country: ${ipInfo?.country}`);
|
console.log(`External ip: ${ipInfo?.ip} Country: ${ipInfo?.country}`);
|
||||||
|
|
||||||
if (!defaultInterface || !ipInfo) {
|
if (!defaultInterface || !ipInfo) {
|
||||||
await kvDataStorage.set({
|
console.error(`Could not gather network info, skipping cycle. defaultInterface: ${!!defaultInterface} ipInfo: ${!!ipInfo}`);
|
||||||
country: null,
|
await notificationService.sendNotification(`Could not gather network info (interface: ${!!defaultInterface}, ipInfo: ${!!ipInfo}), skipping cycle`);
|
||||||
internalIp: null,
|
return;
|
||||||
externalIp: null,
|
|
||||||
interface: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
return await transmissionContainer.downTransmission(
|
|
||||||
`Shutting down: defaultInterface: ${!!defaultInterface} ipInfo: ${!!ipInfo}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await kvDataStorage.set({
|
await kvDataStorage.set({
|
||||||
@@ -59,7 +66,7 @@ async function doSetPortFlow() {
|
|||||||
internalIp: defaultInterface.ip,
|
internalIp: defaultInterface.ip,
|
||||||
externalIp: ipInfo.ip,
|
externalIp: ipInfo.ip,
|
||||||
interface: defaultInterface.interface,
|
interface: defaultInterface.interface,
|
||||||
})
|
});
|
||||||
|
|
||||||
const countryOK = !settings.disallowedCountries.some((country) => ipInfo.country.includes(country));
|
const countryOK = !settings.disallowedCountries.some((country) => ipInfo.country.includes(country));
|
||||||
const internalIpOK = !settings.disallowedIntIps.some((ip) => defaultInterface.ip.includes(ip));
|
const internalIpOK = !settings.disallowedIntIps.some((ip) => defaultInterface.ip.includes(ip));
|
||||||
@@ -79,56 +86,63 @@ async function doSetPortFlow() {
|
|||||||
return;
|
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}
|
|
||||||
|
|
||||||
|
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)}`);
|
console.log(`Data received: ${JSON.stringify(data)}`);
|
||||||
|
} catch (e) {
|
||||||
if (!data.status || !data.supported) {
|
console.error('Failed to fetch new port from PrivateVPN API, skipping cycle', e);
|
||||||
return await transmissionContainer.downTransmission(
|
await notificationService.sendNotification('Could not fetch new port from PrivateVPN API, skipping cycle');
|
||||||
`Shutting down: could not fetch new port, data: ${JSON.stringify(data)}`,
|
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) {
|
if (!newPort) {
|
||||||
return await transmissionContainer.downTransmission(
|
console.error(`Could not parse new port, skipping cycle: ${JSON.stringify(data)}`);
|
||||||
`Shutting down: could not parse new port, data: ${JSON.stringify(data)}`,
|
await notificationService.sendNotification(`Could not parse new port from PrivateVPN API (${JSON.stringify(data)}), skipping cycle`);
|
||||||
);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPort === port) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Setting port to ${newPort}`);
|
console.log(`Setting port to ${newPort}`);
|
||||||
const resultOK = await gluetunContainer.setOpenFirewallPort(newPort);
|
const firewallOK = await gluetunContainer.setOpenFirewallPort(newPort);
|
||||||
// TODO - remove old port from firewall?
|
if (!firewallOK) {
|
||||||
if (!resultOK) {
|
console.error('Could not open new port in Gluetun firewall, skipping cycle');
|
||||||
return await transmissionContainer.downTransmission(
|
await notificationService.sendNotification('Could not open new port in Gluetun firewall, skipping cycle');
|
||||||
`Shutting down: could not open new port in gluetun firewall`,
|
return;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
console.log(`Port in gluetun firewall opened successfully`);
|
console.log('Port in Gluetun firewall opened successfully');
|
||||||
|
|
||||||
|
try {
|
||||||
await transmissionClient.setPort(newPort);
|
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;
|
const numPortsChanged = kvDataStorage.get<number>('numPortsChanged') || 0;
|
||||||
await kvDataStorage.set({ numPortsChanged: numPortsChanged + 1 });
|
await kvDataStorage.set({ numPortsChanged: numPortsChanged + 1 });
|
||||||
|
|
||||||
console.log('Port set');
|
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}`);
|
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}.`);
|
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(',') ?? [],
|
disallowedIntIps: process.env.DISALLOWED_INTERNAL_IPS?.split(',') ?? [],
|
||||||
disallowedExtIps: process.env.DISALLOWED_EXT_IPS?.split(',') ?? [],
|
disallowedExtIps: process.env.DISALLOWED_EXT_IPS?.split(',') ?? [],
|
||||||
discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL,
|
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,
|
trackerToken: process.env.TRACKER_TOKEN,
|
||||||
trackerPort: Number(process.env.TRACKER_PORT),
|
trackerPort: Number(process.env.TRACKER_PORT),
|
||||||
|
uiPort: Number(process.env.UI_PORT) || 3000,
|
||||||
} as const;
|
} 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