From 1c56e70526a1fb784a185c009cd0804089bb8318 Mon Sep 17 00:00:00 2001 From: Roz Date: Sun, 7 Jun 2026 15:57:18 +0200 Subject: [PATCH 1/2] feat: add peer protocol version handshake and compatibility check - Add version module (SERVER_VERSION, MIN_PEER_PROTOCOL_VERSION, compareVersions) - Add authenticated /handshake endpoint returning { name, version } - Gate PeerConnector init on handshake version check - Surface peer version in /servers output - Old/incompatible peers fail init with clear IncompatiblePeerError Closes #8 --- apps/backend/src/__tests__/handshake.test.ts | 28 ++++ .../src/__tests__/peer-handshake.test.ts | 140 ++++++++++++++++++ apps/backend/src/__tests__/version.test.ts | 42 ++++++ apps/backend/src/app.ts | 7 + .../src/lib/errors/IncompatiblePeerError.ts | 12 ++ apps/backend/src/lib/servers/peer.ts | 41 ++++- apps/backend/src/lib/version.ts | 46 ++++++ .../modules/servers/servers.controllers.ts | 12 +- 8 files changed, 322 insertions(+), 6 deletions(-) create mode 100644 apps/backend/src/__tests__/handshake.test.ts create mode 100644 apps/backend/src/__tests__/peer-handshake.test.ts create mode 100644 apps/backend/src/__tests__/version.test.ts create mode 100644 apps/backend/src/lib/errors/IncompatiblePeerError.ts create mode 100644 apps/backend/src/lib/version.ts diff --git a/apps/backend/src/__tests__/handshake.test.ts b/apps/backend/src/__tests__/handshake.test.ts new file mode 100644 index 0000000..37c9abb --- /dev/null +++ b/apps/backend/src/__tests__/handshake.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test' +import { getApp } from '../app' +import { AppConfig } from '../lib/config' +import { SERVER_VERSION } from '../lib/version' + +const envs = { ENVIRONMENT: 'test', ENABLE_LOGS: false, LOG_LEVEL: 'fatal' } as any + +function buildApp() { + const config = AppConfig.parse({ + jack: { baseUrl: 'http://jack:5225', apiKey: 'test-api-key' }, + servers: [], + peers: [], + }) + return getApp(envs, config, { servers: [], peers: [] }) +} + +describe('GET /handshake', () => { + test('returns the server identity and version with a valid api key', async () => { + const res = await buildApp().request('/handshake?apikey=test-api-key') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ name: 'jack', version: SERVER_VERSION }) + }) + + test('rejects a request without an api key', async () => { + const res = await buildApp().request('/handshake') + expect(res.status).toBe(401) + }) +}) diff --git a/apps/backend/src/__tests__/peer-handshake.test.ts b/apps/backend/src/__tests__/peer-handshake.test.ts new file mode 100644 index 0000000..f669e18 --- /dev/null +++ b/apps/backend/src/__tests__/peer-handshake.test.ts @@ -0,0 +1,140 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { getApp } from '../app' +import { AppConfig } from '../lib/config' +import { PeerConnector } from '../lib/servers/peer' +import { ServersController } from '../modules/servers/servers.controllers' + +const envs = { ENVIRONMENT: 'test', ENABLE_LOGS: false, LOG_LEVEL: 'fatal' } as any + +const server = setupServer() +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +function makePeer(url = 'http://peer.test') { + return new PeerConnector({ url, apiKey: 'peer-key', name: 'Friend Jack' }) +} + +describe('PeerConnector handshake compatibility', () => { + test('initializes against a compatible peer, sends its api key, and records its version', async () => { + const seenHeaders: Record = {} + server.use( + http.get('http://peer.test/handshake', ({ request }) => { + seenHeaders.apiKey = request.headers.get('x-api-key') + return HttpResponse.json({ name: 'jack', version: '0.1.0' }) + }), + ) + const peer = makePeer() + peer.init() + await peer.initialization + + expect(peer.isInitialized).toBe(true) + expect(peer.peerVersion).toBe('0.1.0') + expect(peer.initializationError).toBeNull() + expect(seenHeaders.apiKey).toBe('peer-key') + }) + + test('fails on a peer whose version is below the minimum', async () => { + server.use( + http.get('http://peer.test/handshake', () => HttpResponse.json({ name: 'jack', version: '0.0.9' })), + ) + const peer = makePeer() + peer.init() + await peer.initialization?.catch(() => {}) + + expect(peer.isInitialized).toBe(false) + expect(peer.initializationError).toContain('incompatible peer-protocol version') + expect(peer.initializationError).toContain('got 0.0.9') + }) + + test('fails when the handshake has no version field', async () => { + server.use( + http.get('http://peer.test/handshake', () => HttpResponse.json({ name: 'jack' })), + ) + const peer = makePeer() + peer.init() + await peer.initialization?.catch(() => {}) + + expect(peer.isInitialized).toBe(false) + expect(peer.initializationError).toContain('got none') + }) + + test('fails when the handshake version is malformed (null / non-string)', async () => { + server.use( + http.get('http://peer.test/handshake', () => HttpResponse.json({ name: 'jack', version: null })), + ) + const peer = makePeer() + peer.init() + await peer.initialization?.catch(() => {}) + + expect(peer.isInitialized).toBe(false) + expect(peer.initializationError).toContain('incompatible peer-protocol version') + expect(peer.initializationError).toContain('got none') + }) + + test('treats an old peer with no /handshake route (404) as incompatible', async () => { + server.use( + http.get('http://peer.test/handshake', () => new HttpResponse(null, { status: 404 })), + ) + const peer = makePeer() + peer.init() + await peer.initialization?.catch(() => {}) + + expect(peer.isInitialized).toBe(false) + expect(peer.initializationError).toContain('incompatible peer-protocol version') + }) + + test('propagates an auth failure without claiming a version mismatch', async () => { + server.use( + http.get('http://peer.test/handshake', () => new HttpResponse(null, { status: 401 })), + ) + const peer = makePeer() + peer.init() + await peer.initialization?.catch(() => {}) + + expect(peer.isInitialized).toBe(false) + expect(peer.initializationError).not.toContain('incompatible peer-protocol version') + }) +}) + +describe('ServersController surfaces peer version', () => { + test('listServers includes each peer reported version', () => { + const fakePeer = { + name: 'Friend Jack', + url: 'http://peer.test', + type: 'jack', + isInitialized: true, + initializationError: null, + peerVersion: '0.1.0', + } as any + const controller = new ServersController({ servers: [], peers: [fakePeer] }) + + const { peers } = controller.listServers() + expect(peers).toHaveLength(1) + expect(peers[0]).toMatchObject({ name: 'Friend Jack', version: '0.1.0' }) + }) + + test('GET /servers exposes the peer version through the real route', async () => { + server.use( + http.get('http://peer.test/handshake', () => HttpResponse.json({ name: 'jack', version: '0.1.0' })), + ) + const peer = makePeer() + peer.init() + await peer.initialization + + const config = AppConfig.parse({ + jack: { baseUrl: 'http://jack:5225', apiKey: 'test-api-key' }, + servers: [], + peers: [], + }) + const app = getApp(envs, config, { servers: [], peers: [peer] }) + + const res = await app.request('/servers?apikey=test-api-key') + expect(res.status).toBe(200) + const body = await res.json() as { peers: Array<{ name: string, version: string | null }> } + expect(body.peers).toHaveLength(1) + expect(body.peers[0]).toMatchObject({ name: 'Friend Jack', version: '0.1.0' }) + }) +}) diff --git a/apps/backend/src/__tests__/version.test.ts b/apps/backend/src/__tests__/version.test.ts new file mode 100644 index 0000000..9b3bf09 --- /dev/null +++ b/apps/backend/src/__tests__/version.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test' +import { compareVersions, isPeerVersionCompatible, MIN_PEER_PROTOCOL_VERSION, SERVER_VERSION } from '../lib/version' + +describe('compareVersions', () => { + test('returns 0 for equal versions', () => { + expect(compareVersions('0.1.0', '0.1.0')).toBe(0) + }) + + test('compares by major, then minor, then patch', () => { + expect(compareVersions('1.0.0', '0.9.9')).toBe(1) + expect(compareVersions('0.2.0', '0.1.9')).toBe(1) + expect(compareVersions('0.1.2', '0.1.1')).toBe(1) + expect(compareVersions('0.1.0', '0.2.0')).toBe(-1) + expect(compareVersions('0.0.9', '0.1.0')).toBe(-1) + }) + + test('throws on a malformed version string', () => { + expect(() => compareVersions('abc', '0.1.0')).toThrow('Invalid version string: "abc"') + expect(() => compareVersions('0.1.0', '1.2')).toThrow('Invalid version string: "1.2"') + }) +}) + +describe('isPeerVersionCompatible', () => { + test('accepts the minimum and anything above it', () => { + expect(isPeerVersionCompatible(MIN_PEER_PROTOCOL_VERSION)).toBe(true) + expect(isPeerVersionCompatible('0.1.0')).toBe(true) + expect(isPeerVersionCompatible('1.0.0')).toBe(true) + }) + + test('rejects versions below the minimum', () => { + expect(isPeerVersionCompatible('0.0.9')).toBe(false) + }) + + test('rejects malformed or empty versions', () => { + expect(isPeerVersionCompatible('')).toBe(false) + expect(isPeerVersionCompatible('nope')).toBe(false) + }) + + test('SERVER_VERSION is itself compatible', () => { + expect(isPeerVersionCompatible(SERVER_VERSION)).toBe(true) + }) +}) diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 979e209..fd6e1b5 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -8,6 +8,7 @@ import { httpInstrumentationMiddleware } from '@hono/otel' import { Hono } from 'hono' import { secureHeaders } from 'hono/secure-headers' import { getAppEnvs, isOtelEnabled } from './lib/envs' +import { SERVER_VERSION } from './lib/version' import { handleError } from './middleware/handle-error' import { logRequests } from './middleware/log-requests' import { requireApiKey } from './middleware/require-auth' @@ -96,6 +97,12 @@ export function getApp(envs: Envs, config: AppConfig, connectors: Connectors, se const torznabRouter = getTorznabRouter(torznabController) const downloadRouter = getDownloadRouter(connectors.peers) + // Peer handshake — other Jacks probe this at init to read our identity and + // protocol version, then check it against their minimum compatible version. + // Authenticated (mounted after requireApiKey) so a bad API key still fails + // loudly at connect time, unlike the unauthenticated /ping health check. + app.get('/handshake', c => c.json({ name: 'jack', version: SERVER_VERSION }, 200)) + // Peer API — other Jacks talk to us. Serves empty results // when there's no local source to read from. app.route('/peer', peerRouter) diff --git a/apps/backend/src/lib/errors/IncompatiblePeerError.ts b/apps/backend/src/lib/errors/IncompatiblePeerError.ts new file mode 100644 index 0000000..035c1b1 --- /dev/null +++ b/apps/backend/src/lib/errors/IncompatiblePeerError.ts @@ -0,0 +1,12 @@ +import { AppError } from './AppError' + +/** + * A peer reported a peer-protocol version we can't talk to — or is too old to + * report one at all. Thrown during init so the connector fails loudly and the + * mismatch surfaces in /servers (initialized:false + initializationError). + */ +export class IncompatiblePeerError extends AppError { + constructor(message: string, cause?: unknown) { + super(message, 'INCOMPATIBLE_PEER', { cause }) + } +} diff --git a/apps/backend/src/lib/servers/peer.ts b/apps/backend/src/lib/servers/peer.ts index e23f909..f38ab22 100644 --- a/apps/backend/src/lib/servers/peer.ts +++ b/apps/backend/src/lib/servers/peer.ts @@ -5,10 +5,12 @@ import { logger } from '../../logger' import { requireInitialization } from '../decorators/require-initialization' import { FetchError } from '../errors/FetchError' import { IdleTimeoutError } from '../errors/IdleTimeoutError' +import { IncompatiblePeerError } from '../errors/IncompatiblePeerError' import { IncompleteDownloadError } from '../errors/IncompleteDownloadError' import { UnknownSizeError } from '../errors/UnknownSizeError' import { normalizeImdbId, Release } from '../release' import { withSpan } from '../tracing' +import { isPeerVersionCompatible, MIN_PEER_PROTOCOL_VERSION } from '../version' import { ServerConnector } from './base' const PeerSearchResponse = z.object({ items: z.array(Release) }) @@ -63,9 +65,11 @@ function parseContentRange(value: string | null): { start: number, end: number, * `Release`s, just like a local arr source. */ export class PeerConnector extends ServerConnector { + private _peerVersion: string | null = null + constructor(config: { url: string, apiKey: string, name: string, headers?: ConnectorHeadersConfig }) { super({ - pingPath: '/peer/search', + pingPath: '/handshake', pingMethod: 'GET', authHeader: 'X-Api-Key', }, { ...config, type: 'jack' }) @@ -75,15 +79,44 @@ export class PeerConnector extends ServerConnector { return this.apiKey } + /** The peer's reported protocol version, set on a successful handshake. */ + get peerVersion(): string | null { + return this._peerVersion + } + protected override async runInit(): Promise { await withSpan('peer.init', { 'peer.name': this.name, 'peer.id': this.id, 'server.url': this.url, }, async (span) => { - await this.ping() - span.setAttribute('peer.initialized', true) - logger.debug(`Connected to Jack peer ${this.name}`) + let handshake: unknown + try { + handshake = await this.ping() + } + catch (err) { + // An old peer (pre-0.1.0) has no /handshake route → 404. Treat that as + // an incompatible/unsupported protocol version rather than a generic + // fetch failure, so the cause is unmistakable. Network/timeout/401/5xx + // propagate unchanged (auth/connectivity stay distinct from version). + if (err instanceof FetchError && err.response.status === 404) { + throw new IncompatiblePeerError(`Peer "${this.name}" runs an incompatible peer-protocol version: expected >= ${MIN_PEER_PROTOCOL_VERSION}, got none (no handshake endpoint)`) + } + throw err + } + + // Read the version defensively: any non-string/missing value collapses to + // `undefined` so a malformed or unversioned peer fails the same clean way. + const version = typeof handshake === 'object' && handshake !== null && 'version' in handshake && typeof handshake.version === 'string' + ? handshake.version + : undefined + if (!version || !isPeerVersionCompatible(version)) { + throw new IncompatiblePeerError(`Peer "${this.name}" runs an incompatible peer-protocol version: expected >= ${MIN_PEER_PROTOCOL_VERSION}, got ${version ?? 'none'}`) + } + + this._peerVersion = version + span.setAttributes({ 'peer.version': version, 'peer.initialized': true }) + logger.debug({ peer: this.name, version }, `Connected to Jack peer ${this.name}`) }) } diff --git a/apps/backend/src/lib/version.ts b/apps/backend/src/lib/version.ts new file mode 100644 index 0000000..0414bdf --- /dev/null +++ b/apps/backend/src/lib/version.ts @@ -0,0 +1,46 @@ +// jack's own version, reported to peers over /handshake. This doubles as the +// peer-protocol version: a bump here signals a potential protocol change. +export const SERVER_VERSION = '0.1.0' + +// Oldest peer version we can still talk to. Peers below this — or peers too old +// to expose a version at all — are rejected at init time as incompatible. +export const MIN_PEER_PROTOCOL_VERSION = '0.1.0' + +const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/ + +function parseVersion(version: string): [number, number, number] | null { + const match = VERSION_PATTERN.exec(version.trim()) + if (!match) + return null + return [Number(match[1]), Number(match[2]), Number(match[3])] +} + +/** + * Compare two `x.y.z` versions numerically by major, then minor, then patch. + * Returns -1 if `a < b`, 0 if equal, 1 if `a > b`. Throws on malformed input. + */ +export function compareVersions(a: string, b: string): -1 | 0 | 1 { + const parsedA = parseVersion(a) + const parsedB = parseVersion(b) + if (!parsedA) + throw new Error(`Invalid version string: "${a}"`) + if (!parsedB) + throw new Error(`Invalid version string: "${b}"`) + for (let i = 0; i < 3; i++) { + if (parsedA[i]! < parsedB[i]!) + return -1 + if (parsedA[i]! > parsedB[i]!) + return 1 + } + return 0 +} + +/** + * Whether a peer's reported version is new enough to talk to (>= the minimum we + * support). A malformed or empty version is treated as incompatible. + */ +export function isPeerVersionCompatible(version: string): boolean { + if (!parseVersion(version)) + return false + return compareVersions(version, MIN_PEER_PROTOCOL_VERSION) >= 0 +} diff --git a/apps/backend/src/modules/servers/servers.controllers.ts b/apps/backend/src/modules/servers/servers.controllers.ts index 2c0b3c7..94b4e39 100644 --- a/apps/backend/src/modules/servers/servers.controllers.ts +++ b/apps/backend/src/modules/servers/servers.controllers.ts @@ -1,5 +1,6 @@ import type { ArrServerConnector } from '../../lib/servers/arr/base' import type { ServerConnector } from '../../lib/servers/base' +import type { PeerConnector } from '../../lib/servers/peer' import { SonarrServerConnector } from '../../lib/servers/arr/sonarr' function stringifyConnector(c: ServerConnector) { @@ -20,9 +21,16 @@ function stringifyServer(c: ArrServerConnector) { } } +function stringifyPeer(c: PeerConnector) { + return { + ...stringifyConnector(c), + version: c.peerVersion, + } +} + export class ServersController { constructor( - private readonly connectors: { servers: ArrServerConnector[], peers: ServerConnector[] }, + private readonly connectors: { servers: ArrServerConnector[], peers: PeerConnector[] }, ) {} async getIssues(serverUrl?: string) { @@ -50,7 +58,7 @@ export class ServersController { listServers() { return { servers: this.connectors.servers.map(stringifyServer), - peers: this.connectors.peers.map(stringifyConnector), + peers: this.connectors.peers.map(stringifyPeer), } } } From bd4ba7e529d715e7136ad622d49cf35d563d8b74 Mon Sep 17 00:00:00 2001 From: Roz <3948961+roziscoding@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:09:21 +0200 Subject: [PATCH 2/2] fix: compare versions via OR instead of nullish coallescing Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- apps/backend/src/lib/servers/peer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/lib/servers/peer.ts b/apps/backend/src/lib/servers/peer.ts index f38ab22..4ac8c44 100644 --- a/apps/backend/src/lib/servers/peer.ts +++ b/apps/backend/src/lib/servers/peer.ts @@ -111,7 +111,7 @@ export class PeerConnector extends ServerConnector { ? handshake.version : undefined if (!version || !isPeerVersionCompatible(version)) { - throw new IncompatiblePeerError(`Peer "${this.name}" runs an incompatible peer-protocol version: expected >= ${MIN_PEER_PROTOCOL_VERSION}, got ${version ?? 'none'}`) + throw new IncompatiblePeerError(`Peer "${this.name}" runs an incompatible peer-protocol version: expected >= ${MIN_PEER_PROTOCOL_VERSION}, got ${version || 'none'}`) } this._peerVersion = version