add tracker server, esm, refactor

This commit is contained in:
Jurgis Sakalauskas
2024-05-07 10:22:24 +03:00
parent 416f048421
commit eead7ab522
17 changed files with 1224 additions and 57 deletions
+2
View File
@@ -13,3 +13,5 @@ DISALLOWED_INTERNAL_IPS=your_internal_ip_e_g_192.168
DISALLOWED_EXT_IPS=your_external_ip DISALLOWED_EXT_IPS=your_external_ip
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/your_webhook_url DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/your_webhook_url
UPDATE_INTERVAL_SECONDS=300 UPDATE_INTERVAL_SECONDS=300
TRACKER_TOKEN=test
TRACKER_PORT=12345
+939
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,12 +1,14 @@
{ {
"scripts": { "scripts": {
"start": "npx ts-node src/index.ts" "start": "npx tsx src/index.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"typescript": "^5.4.5" "typescript": "^5.4.5"
}, },
"type": "module",
"dependencies": { "dependencies": {
"bittorrent-tracker": "^11.0.2",
"docker-compose": "^0.24.8", "docker-compose": "^0.24.8",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"transmission": "^0.4.10" "transmission": "^0.4.10"
+15 -3
View File
@@ -1,12 +1,24 @@
import { setPortFlow } from './set-port-flow'; import { setPortFlow } from './set-port-flow';
import { settings } from './settings'; 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'; const isWorker = process.argv[2] === 'worker';
if (isWorker) { if (isWorker) {
//initial timeout is in case of a crash to not flood everything trackerServer.on('announce', onTrackerAnnounce);
console.log(`Starting worker, first check in ${settings.updateIntervalMs/1000}s`);
setTimeout(worker, settings.updateIntervalMs); 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 { } else {
setPortFlow() setPortFlow()
.then(() => console.log('All done')) .then(() => console.log('All done'))
+1 -8
View File
@@ -1,5 +1,5 @@
import { v2 as compose } from 'docker-compose'; import { v2 as compose } from 'docker-compose';
import { settings } from './settings'; import { settings } from '../settings';
type IPInfo = { type IPInfo = {
ip: string, ip: string,
@@ -60,11 +60,4 @@ export class DockerContainer {
ip: dataRaw[6], 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;
}
} }
+21
View File
@@ -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 { join } from 'path';
import { existsSync, readFileSync } from 'fs'; import { existsSync, readFileSync } from 'fs';
import path from 'path';
const __dirname = path.resolve();
class KeyValueDataStorage { class KeyValueDataStorage {
private readonly data: Record<string, unknown> = {}; private readonly data: Record<string, unknown> = {};
private readonly filePath: string; private readonly filePath: string;
@@ -1,4 +1,4 @@
import { settings } from './settings'; import { settings } from '../settings';
class NotificationService { class NotificationService {
async sendNotification(message: string, important = false) { async sendNotification(message: string, important = false) {
@@ -1,5 +1,6 @@
// @ts-ignore // @ts-ignore
import TransmissionClient from 'transmission'; import TransmissionClient from 'transmission';
import { settings } from '../settings';
type TransmissionOptions = { type TransmissionOptions = {
url?: string; url?: string;
@@ -48,3 +49,5 @@ export class Transmission {
}); });
} }
} }
export const transmissionClient = new Transmission(settings.transmission);
+25
View File
@@ -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
View File
@@ -1,41 +1,22 @@
import { Transmission } from './transmission'; import { transmissionClient } from './services/transmission-client';
import { DockerContainer } from './docker';
import { settings } from './settings'; import { settings } from './settings';
import { notificationService } from './notification'; import { notificationService } from './services/notification';
import { kvDataStorage } from './kv-data-storage'; import { kvDataStorage } from './services/kv-data-storage';
import { dailyCheckIn } from './check-in'; import { dailyCheckIn } from './services/daily-check-in';
import { transmissionContainer } from './services/transmission-container';
async function downTransmission(transmissionContainer: DockerContainer, reason: string) { import { gluetunContainer } from './services/gluetun-container';
console.log(reason);
await kvDataStorage.set({ isServiceRunning: false });
await notificationService.sendNotification(reason, true);
return await transmissionContainer.down();
}
export async function setPortFlow() { 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 { try {
await doSetPortFlow(transmissionClient, transmissionContainer, gluetunContainer); await doSetPortFlow();
} catch (e) { } catch (e) {
console.error('Error in set port flow', 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(); await dailyCheckIn();
} }
async function doSetPortFlow(transmissionClient: Transmission, transmissionContainer: DockerContainer, gluetunContainer: DockerContainer) { 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();
@@ -58,12 +39,25 @@ async function doSetPortFlow(transmissionClient: Transmission, transmissionConta
console.log(`External ip: ${ipInfo?.ip} Country: ${ipInfo?.country}`); console.log(`External ip: ${ipInfo?.ip} Country: ${ipInfo?.country}`);
if (!defaultInterface || !ipInfo) { if (!defaultInterface || !ipInfo) {
return await downTransmission( await kvDataStorage.set({
transmissionContainer, country: null,
internalIp: null,
externalIp: null,
interface: null,
})
return await transmissionContainer.downTransmission(
`Shutting down: defaultInterface: ${!!defaultInterface} ipInfo: ${!!ipInfo}`, `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 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));
const externalIpOK = !settings.disallowedExtIps.some((ip) => ipInfo.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}`); console.log(`Country OK: ${countryOK} Internal IP OK: ${internalIpOK} External IP OK: ${externalIpOK} Interface OK: ${interfaceOK}`);
if (!countryOK || !internalIpOK || !externalIpOK || !interfaceOK) { if (!countryOK || !internalIpOK || !externalIpOK || !interfaceOK) {
return await downTransmission( return await transmissionContainer.downTransmission(
transmissionContainer,
`Shutting down: Country OK: ${countryOK}, Internal IP OK: ${internalIpOK}, External IP OK: ${externalIpOK}, Interface OK: ${interfaceOK}.`, `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)}`); console.log(`Data received: ${JSON.stringify(data)}`);
if (!data.status || !data.supported) { if (!data.status || !data.supported) {
return await downTransmission( return await transmissionContainer.downTransmission(
transmissionContainer,
`Shutting down: could not fetch new port, data: ${JSON.stringify(data)}`, `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]); const newPort= Number(data.status.split(" ")[1]);
if (!newPort) { if (!newPort) {
return await downTransmission( return await transmissionContainer.downTransmission(
transmissionContainer,
`Shutting down: could not parse new port, data: ${JSON.stringify(data)}`, `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}`); console.log(`Setting port to ${newPort}`);
const resultOK = await gluetunContainer.setOpenFirewallPort(newPort); const resultOK = await gluetunContainer.setOpenFirewallPort(newPort);
if (!resultOK) { if (!resultOK) {
return await downTransmission( return await transmissionContainer.downTransmission(
transmissionContainer,
`Shutting down: could not open new port in gluetun firewall`, `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}`); console.log(`Port ${newPort} is open: ${newPortOpen}`);
if (!newPortOpen) { if (!newPortOpen) {
return await downTransmission( return await transmissionContainer.downTransmission(
transmissionContainer,
`Shutting down: new port is still not open, data: ${JSON.stringify(data)}`, `Shutting down: new port is still not open, data: ${JSON.stringify(data)}`,
); );
} }
+2
View File
@@ -24,4 +24,6 @@ export const settings = {
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,
trackerPort: Number(process.env.TRACKER_PORT),
} as const; } as const;
+38
View File
@@ -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 });
};
+95
View File
@@ -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
+36
View File
@@ -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,
}
+11 -4
View File
@@ -1,5 +1,12 @@
{ {
"compilerOptions": { "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 */ /* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */ /* Projects */
@@ -11,7 +18,7 @@
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */ /* 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. */ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */ // "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ // "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. */ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */ /* 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. */ // "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ // "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. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
@@ -44,7 +51,7 @@
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */ // "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */ /* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
@@ -77,7 +84,7 @@
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ // "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. */ // "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. */ // "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. */ // "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. */ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */