mirror of
https://github.com/sakaljurgis/pvpn-transmission.git
synced 2026-07-08 20:47:41 +00:00
add tracker server, esm, refactor
This commit is contained in:
@@ -13,3 +13,5 @@ DISALLOWED_INTERNAL_IPS=your_internal_ip_e_g_192.168
|
||||
DISALLOWED_EXT_IPS=your_external_ip
|
||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/your_webhook_url
|
||||
UPDATE_INTERVAL_SECONDS=300
|
||||
TRACKER_TOKEN=test
|
||||
TRACKER_PORT=12345
|
||||
|
||||
Generated
+939
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"scripts": {
|
||||
"start": "npx ts-node src/index.ts"
|
||||
"start": "npx tsx src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.7",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"bittorrent-tracker": "^11.0.2",
|
||||
"docker-compose": "^0.24.8",
|
||||
"dotenv": "^16.4.5",
|
||||
"transmission": "^0.4.10"
|
||||
|
||||
+15
-3
@@ -1,12 +1,24 @@
|
||||
import { setPortFlow } from './set-port-flow';
|
||||
import { settings } from './settings';
|
||||
import { trackerServer } from './tracker/tracker';
|
||||
import { kvDataStorage } from './services/kv-data-storage';
|
||||
import { onTrackerAnnounce } from './tracker-announce-flow';
|
||||
|
||||
const isWorker = process.argv[2] === 'worker';
|
||||
|
||||
if (isWorker) {
|
||||
//initial timeout is in case of a crash to not flood everything
|
||||
console.log(`Starting worker, first check in ${settings.updateIntervalMs/1000}s`);
|
||||
setTimeout(worker, settings.updateIntervalMs);
|
||||
trackerServer.on('announce', onTrackerAnnounce);
|
||||
|
||||
const lastRun = kvDataStorage.get<number>('lastRun') || 0;
|
||||
const sinceLastRun = Date.now() - lastRun;
|
||||
if (sinceLastRun > settings.updateIntervalMs) {
|
||||
console.log(`Starting worker, check now`);
|
||||
worker();
|
||||
} else {
|
||||
console.log(`Starting worker, first check in ${(settings.updateIntervalMs - sinceLastRun)/1000}s`);
|
||||
setTimeout(worker, settings.updateIntervalMs - sinceLastRun);
|
||||
}
|
||||
|
||||
} else {
|
||||
setPortFlow()
|
||||
.then(() => console.log('All done'))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { v2 as compose } from 'docker-compose';
|
||||
import { settings } from './settings';
|
||||
import { settings } from '../settings';
|
||||
|
||||
type IPInfo = {
|
||||
ip: string,
|
||||
@@ -60,11 +60,4 @@ export class DockerContainer {
|
||||
ip: dataRaw[6],
|
||||
}
|
||||
}
|
||||
|
||||
async setOpenFirewallPort(port: number) {
|
||||
const {exitCode: exit0} = await this.exec(`iptables -A INPUT -i tun0 -p tcp --dport ${port} -j ACCEPT`);
|
||||
const {exitCode: exit1} = await this.exec(`iptables -A INPUT -i tun0 -p udp --dport ${port} -j ACCEPT`);
|
||||
|
||||
return exit0 === 0 && exit1 === 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { DockerContainer } from './docker';
|
||||
import { settings } from '../settings';
|
||||
|
||||
export class GluetunContainer extends DockerContainer {
|
||||
constructor() {
|
||||
super(
|
||||
settings.dockerGluetun.service,
|
||||
settings.dockerGluetun.cwd,
|
||||
settings.dockerGluetun.serviceName
|
||||
);
|
||||
}
|
||||
|
||||
async setOpenFirewallPort(port: number) {
|
||||
const {exitCode: exit0} = await this.exec(`iptables -A INPUT -i tun0 -p tcp --dport ${port} -j ACCEPT`);
|
||||
const {exitCode: exit1} = await this.exec(`iptables -A INPUT -i tun0 -p udp --dport ${port} -j ACCEPT`);
|
||||
|
||||
return exit0 === 0 && exit1 === 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const gluetunContainer = new GluetunContainer();
|
||||
@@ -2,6 +2,9 @@ import { writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
|
||||
import path from 'path';
|
||||
const __dirname = path.resolve();
|
||||
|
||||
class KeyValueDataStorage {
|
||||
private readonly data: Record<string, unknown> = {};
|
||||
private readonly filePath: string;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { settings } from './settings';
|
||||
import { settings } from '../settings';
|
||||
|
||||
class NotificationService {
|
||||
async sendNotification(message: string, important = false) {
|
||||
@@ -1,5 +1,6 @@
|
||||
// @ts-ignore
|
||||
import TransmissionClient from 'transmission';
|
||||
import { settings } from '../settings';
|
||||
|
||||
type TransmissionOptions = {
|
||||
url?: string;
|
||||
@@ -48,3 +49,5 @@ export class Transmission {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const transmissionClient = new Transmission(settings.transmission);
|
||||
@@ -0,0 +1,25 @@
|
||||
import { DockerContainer } from './docker';
|
||||
import { settings } from '../settings';
|
||||
import { kvDataStorage } from './kv-data-storage';
|
||||
import { notificationService } from './notification';
|
||||
|
||||
export class TransmissionContainer extends DockerContainer {
|
||||
constructor() {
|
||||
super(
|
||||
settings.dockerTransmission.service,
|
||||
settings.dockerTransmission.cwd,
|
||||
settings.dockerTransmission.serviceName
|
||||
);
|
||||
}
|
||||
|
||||
async downTransmission(reason: string) {
|
||||
console.log(reason);
|
||||
|
||||
await kvDataStorage.set({ isServiceRunning: false });
|
||||
await notificationService.sendNotification(reason, true);
|
||||
|
||||
return await this.down();
|
||||
}
|
||||
}
|
||||
|
||||
export const transmissionContainer = new TransmissionContainer();
|
||||
+29
-40
@@ -1,41 +1,22 @@
|
||||
import { Transmission } from './transmission';
|
||||
import { DockerContainer } from './docker';
|
||||
import { transmissionClient } from './services/transmission-client';
|
||||
import { settings } from './settings';
|
||||
import { notificationService } from './notification';
|
||||
import { kvDataStorage } from './kv-data-storage';
|
||||
import { dailyCheckIn } from './check-in';
|
||||
|
||||
async function downTransmission(transmissionContainer: DockerContainer, reason: string) {
|
||||
console.log(reason);
|
||||
|
||||
await kvDataStorage.set({ isServiceRunning: false });
|
||||
await notificationService.sendNotification(reason, true);
|
||||
return await transmissionContainer.down();
|
||||
}
|
||||
import { notificationService } from './services/notification';
|
||||
import { kvDataStorage } from './services/kv-data-storage';
|
||||
import { dailyCheckIn } from './services/daily-check-in';
|
||||
import { transmissionContainer } from './services/transmission-container';
|
||||
import { gluetunContainer } from './services/gluetun-container';
|
||||
|
||||
export async function setPortFlow() {
|
||||
const transmissionClient = new Transmission(settings.transmission);
|
||||
const transmissionContainer = new DockerContainer(
|
||||
settings.dockerTransmission.service,
|
||||
settings.dockerTransmission.cwd,
|
||||
settings.dockerTransmission.serviceName
|
||||
);
|
||||
const gluetunContainer = new DockerContainer(
|
||||
settings.dockerGluetun.service,
|
||||
settings.dockerGluetun.cwd,
|
||||
settings.dockerGluetun.serviceName
|
||||
);
|
||||
|
||||
try {
|
||||
await doSetPortFlow(transmissionClient, transmissionContainer, gluetunContainer);
|
||||
await doSetPortFlow();
|
||||
} catch (e) {
|
||||
console.error('Error in set port flow', e);
|
||||
await downTransmission(transmissionContainer, `Shutting down: error in set port flow.`);
|
||||
await transmissionContainer.downTransmission(`Shutting down: error in set port flow.`)
|
||||
}
|
||||
await dailyCheckIn();
|
||||
}
|
||||
|
||||
async function doSetPortFlow(transmissionClient: Transmission, transmissionContainer: DockerContainer, gluetunContainer: DockerContainer) {
|
||||
async function doSetPortFlow() {
|
||||
console.log('Starting set port flow');
|
||||
|
||||
const containerState = await transmissionContainer.getState();
|
||||
@@ -58,12 +39,25 @@ async function doSetPortFlow(transmissionClient: Transmission, transmissionConta
|
||||
console.log(`External ip: ${ipInfo?.ip} Country: ${ipInfo?.country}`);
|
||||
|
||||
if (!defaultInterface || !ipInfo) {
|
||||
return await downTransmission(
|
||||
transmissionContainer,
|
||||
await kvDataStorage.set({
|
||||
country: null,
|
||||
internalIp: null,
|
||||
externalIp: null,
|
||||
interface: null,
|
||||
})
|
||||
|
||||
return await transmissionContainer.downTransmission(
|
||||
`Shutting down: defaultInterface: ${!!defaultInterface} ipInfo: ${!!ipInfo}`,
|
||||
);
|
||||
}
|
||||
|
||||
await kvDataStorage.set({
|
||||
country: ipInfo.country,
|
||||
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));
|
||||
const externalIpOK = !settings.disallowedExtIps.some((ip) => ipInfo.ip.includes(ip));
|
||||
@@ -72,8 +66,7 @@ async function doSetPortFlow(transmissionClient: Transmission, transmissionConta
|
||||
console.log(`Country OK: ${countryOK} Internal IP OK: ${internalIpOK} External IP OK: ${externalIpOK} Interface OK: ${interfaceOK}`);
|
||||
|
||||
if (!countryOK || !internalIpOK || !externalIpOK || !interfaceOK) {
|
||||
return await downTransmission(
|
||||
transmissionContainer,
|
||||
return await transmissionContainer.downTransmission(
|
||||
`Shutting down: Country OK: ${countryOK}, Internal IP OK: ${internalIpOK}, External IP OK: ${externalIpOK}, Interface OK: ${interfaceOK}.`,
|
||||
);
|
||||
}
|
||||
@@ -92,8 +85,7 @@ async function doSetPortFlow(transmissionClient: Transmission, transmissionConta
|
||||
console.log(`Data received: ${JSON.stringify(data)}`);
|
||||
|
||||
if (!data.status || !data.supported) {
|
||||
return await downTransmission(
|
||||
transmissionContainer,
|
||||
return await transmissionContainer.downTransmission(
|
||||
`Shutting down: could not fetch new port, data: ${JSON.stringify(data)}`,
|
||||
);
|
||||
}
|
||||
@@ -101,8 +93,7 @@ async function doSetPortFlow(transmissionClient: Transmission, transmissionConta
|
||||
const newPort= Number(data.status.split(" ")[1]);
|
||||
|
||||
if (!newPort) {
|
||||
return await downTransmission(
|
||||
transmissionContainer,
|
||||
return await transmissionContainer.downTransmission(
|
||||
`Shutting down: could not parse new port, data: ${JSON.stringify(data)}`,
|
||||
);
|
||||
}
|
||||
@@ -110,8 +101,7 @@ async function doSetPortFlow(transmissionClient: Transmission, transmissionConta
|
||||
console.log(`Setting port to ${newPort}`);
|
||||
const resultOK = await gluetunContainer.setOpenFirewallPort(newPort);
|
||||
if (!resultOK) {
|
||||
return await downTransmission(
|
||||
transmissionContainer,
|
||||
return await transmissionContainer.downTransmission(
|
||||
`Shutting down: could not open new port in gluetun firewall`,
|
||||
);
|
||||
}
|
||||
@@ -126,8 +116,7 @@ async function doSetPortFlow(transmissionClient: Transmission, transmissionConta
|
||||
console.log(`Port ${newPort} is open: ${newPortOpen}`);
|
||||
|
||||
if (!newPortOpen) {
|
||||
return await downTransmission(
|
||||
transmissionContainer,
|
||||
return await transmissionContainer.downTransmission(
|
||||
`Shutting down: new port is still not open, data: ${JSON.stringify(data)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,4 +24,6 @@ export const settings = {
|
||||
disallowedExtIps: process.env.DISALLOWED_EXT_IPS?.split(',') ?? [],
|
||||
discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL,
|
||||
updateIntervalMs: (Number(process.env.UPDATE_INTERVAL_SECONDS) ?? 60 * 5) * 1000,
|
||||
trackerToken: process.env.TRACKER_TOKEN,
|
||||
trackerPort: Number(process.env.TRACKER_PORT),
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { TrackerAnnounceData } from './tracker/types';
|
||||
import { settings } from './settings';
|
||||
import { transmissionContainer } from './services/transmission-container';
|
||||
import { kvDataStorage } from './services/kv-data-storage';
|
||||
import { notificationService } from './services/notification';
|
||||
|
||||
export const onTrackerAnnounce = async (data: TrackerAnnounceData) => {
|
||||
//check if ip is allowed
|
||||
if (settings.disallowedIntIps.some((ip) => data.ip.includes(ip)) || settings.disallowedExtIps.some((ip) => data.ip.includes(ip))) {
|
||||
console.log(`Announce from disallowed ip ${data.ip}`);
|
||||
const transmissionContainerStatus = await transmissionContainer.getState() || { state: 'unknown' };
|
||||
if (transmissionContainerStatus.state !== 'running') {
|
||||
const lastNotRunningButAnnouncedNotification = kvDataStorage.get<number>('lastNotRunningButAnnouncedNotification') || 0;
|
||||
if (Date.now() - lastNotRunningButAnnouncedNotification < 60 * 60 * 1000) {
|
||||
return;
|
||||
}
|
||||
await notificationService.sendNotification(`Announce from disallowed ip ${data.ip}, but transmission is not running`, true);
|
||||
await kvDataStorage.set({ lastNotRunningButAnnouncedNotification: Date.now() });
|
||||
return;
|
||||
}
|
||||
await transmissionContainer.downTransmission(`Shutting down: announce from disallowed ip ${data.ip}`);
|
||||
return;
|
||||
}
|
||||
|
||||
//check if ip matches external ip
|
||||
const externalIp = kvDataStorage.get<string>('externalIp') || '0.0.0.0';
|
||||
if (data.ip !== externalIp) {
|
||||
const ipMismatchCount = kvDataStorage.get<number>('ipMismatchCount') || 0;
|
||||
if (ipMismatchCount > 2) {
|
||||
console.log(`Announce from ip ${data.ip} but external ip is ${externalIp}`);
|
||||
await transmissionContainer.downTransmission(`Shutting down: announce from ip ${data.ip} but external ip is ${externalIp}`);
|
||||
}
|
||||
await kvDataStorage.set({ ipMismatchCount: ipMismatchCount + 1 });
|
||||
|
||||
return;
|
||||
}
|
||||
await kvDataStorage.set({ ipMismatchCount: 0 });
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
// @ts-ignore
|
||||
import { Server } from 'bittorrent-tracker';
|
||||
import { TrackerAnnounceData, TrackerParams } from './types';
|
||||
import { settings } from '../settings';
|
||||
|
||||
export const trackerServer = new Server({
|
||||
udp: true, // enable udp server? [default=true]
|
||||
http: true, // enable http server? [default=true]
|
||||
ws: true, // enable websocket server? [default=true]
|
||||
stats: false, // enable web-based statistics? [default=true]
|
||||
trustProxy: false, // enable trusting x-forwarded-for header for remote IP [default=false]
|
||||
interval: 3 * 60 * 1000,
|
||||
filter: function (infoHash: string, params: TrackerParams, cb: (arg0: Error | null) => void) {
|
||||
console.log(`tracker announced from ${params.addr} with correct token ${params.token === process.env.TRACKER_TOKEN}`);
|
||||
if (settings.trackerToken && params.token === settings.trackerToken) {
|
||||
const announceData: TrackerAnnounceData = {
|
||||
infoHash,
|
||||
ip: params.ip,
|
||||
port: params.port,
|
||||
addr: params.addr,
|
||||
token: params.token,
|
||||
}
|
||||
this.emit('announce', announceData);
|
||||
cb(null)
|
||||
} else {
|
||||
cb(new Error('unauthorised'))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
trackerServer.on('error', function (err: { message: any; }) {
|
||||
console.log('tracker server error', err.message)
|
||||
})
|
||||
|
||||
trackerServer.on('warning', function (err: { message: any; }) {
|
||||
console.log('tracker server warning',err.message)
|
||||
})
|
||||
|
||||
// trackerServer.on('listening', function () {
|
||||
// // fired when all requested servers are listening
|
||||
//
|
||||
// // HTTP
|
||||
// const httpAddr = trackerServer.http.address()
|
||||
// const httpHost = httpAddr.address !== '::' ? httpAddr.address : 'localhost'
|
||||
// const httpPort = httpAddr.port
|
||||
// console.log(`HTTP tracker: http://${httpHost}:${httpPort}/announce`)
|
||||
//
|
||||
// // UDP
|
||||
// const udpAddr = trackerServer.udp.address()
|
||||
// const udpHost = udpAddr.address
|
||||
// const udpPort = udpAddr.port
|
||||
// console.log(`UDP tracker: udp://${udpHost}:${udpPort}`)
|
||||
//
|
||||
// // WS
|
||||
// const wsAddr = trackerServer.ws.address()
|
||||
// const wsHost = wsAddr.address !== '::' ? wsAddr.address : 'localhost'
|
||||
// const wsPort = wsAddr.port
|
||||
// console.log(`WebSocket tracker: ws://${wsHost}:${wsPort}`)
|
||||
//
|
||||
// })
|
||||
|
||||
|
||||
// start tracker server listening! Use 0 to listen on a random free port.
|
||||
const port = settings.trackerPort;
|
||||
trackerServer.listen(port, () => {
|
||||
console.log(`tracker listening on port ${port}`)
|
||||
})
|
||||
|
||||
// trackerServer.on('start', function (addr: string) {
|
||||
// console.log('got start message from ' + addr)
|
||||
// })
|
||||
// trackerServer.on('complete', function (addr: string) {
|
||||
// console.log('got complete message from ' + addr)
|
||||
// })
|
||||
// trackerServer.on('update', function (addr: string) {
|
||||
// console.log('got update message from ' + addr)
|
||||
// })
|
||||
// trackerServer.on('stop', function (addr: string) {
|
||||
// console.log('got stop message from ' + addr)
|
||||
// })
|
||||
// trackerServer.on('announce', function (data: TrackerAnnounceData) {
|
||||
// console.log('got announce message', data)
|
||||
// })
|
||||
|
||||
// // get info hashes for all torrents in the tracker server
|
||||
// Object.keys(trackerServer.torrents)
|
||||
|
||||
// // get the number of seeders for a particular torrent
|
||||
// trackerServer.torrents[infoHash].complete
|
||||
|
||||
// // get the number of leechers for a particular torrent
|
||||
// trackerServer.torrents[infoHash].incomplete
|
||||
|
||||
// // get the peers who are in a particular torrent swarm
|
||||
// trackerServer.torrents[infoHash].peers
|
||||
@@ -0,0 +1,36 @@
|
||||
export type TrackerParams = {
|
||||
token: string,
|
||||
info_hash: string,
|
||||
peer_id: string,
|
||||
port: number,
|
||||
uploaded: string, // string number bytes
|
||||
downloaded: string, // string number bytes
|
||||
left: number,
|
||||
corrupt?: string, // string number bytes
|
||||
numwant: number,
|
||||
key: string,
|
||||
event?: string, // started, completed, stopped
|
||||
compact: number,
|
||||
no_peer_id?: string, // "1" or "0"
|
||||
supportcrypto: string, // "1" or "0"
|
||||
redundant?: string, // "1" or "0"
|
||||
type: string, // http, udp, ws
|
||||
action: number, // 1?
|
||||
ip: string,
|
||||
addr: string, // ip:port
|
||||
headers: {
|
||||
host: string,
|
||||
"user-agent": string,
|
||||
accept?: string,
|
||||
"accept-encoding": string,
|
||||
connection?: string,
|
||||
}
|
||||
}
|
||||
|
||||
export type TrackerAnnounceData = {
|
||||
infoHash: string,
|
||||
ip: string,
|
||||
port: number,
|
||||
addr: string,
|
||||
token: string,
|
||||
}
|
||||
+10
-3
@@ -1,5 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"sourceMap": true,
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
@@ -11,7 +18,7 @@
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
@@ -25,7 +32,7 @@
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
// "module": "commonjs", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
@@ -77,7 +84,7 @@
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
|
||||
Reference in New Issue
Block a user