Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/backend/src/__tests__/handshake.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
140 changes: 140 additions & 0 deletions apps/backend/src/__tests__/peer-handshake.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | null> = {}
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' })
})
})
42 changes: 42 additions & 0 deletions apps/backend/src/__tests__/version.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
7 changes: 7 additions & 0 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions apps/backend/src/lib/errors/IncompatiblePeerError.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
41 changes: 37 additions & 4 deletions apps/backend/src/lib/servers/peer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
Expand Down Expand Up @@ -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' })
Expand All @@ -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<void> {
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'}`)
}
Comment thread
roziscoding marked this conversation as resolved.

this._peerVersion = version
span.setAttributes({ 'peer.version': version, 'peer.initialized': true })
logger.debug({ peer: this.name, version }, `Connected to Jack peer ${this.name}`)
})
}

Expand Down
46 changes: 46 additions & 0 deletions apps/backend/src/lib/version.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading