feat: Tailscale sync jobs
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped

Adds a background job system that polls the Tailscale API on a configurable
interval and syncs tailscale status and IPs to instances by hostname match.

- New config table (key/value) in SQLite for persistent server-side settings
- New server/jobs.js: runTailscaleSync + restartJobs scheduler
- GET/PUT /api/config — read and write Tailscale settings; API key masked as **REDACTED** on GET
- POST /api/jobs/tailscale/run — immediate manual sync
- Settings modal: new Tailscale Sync section with enable toggle, tailnet, API key, poll interval, Save + Run Now buttons, last-run status

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-28 17:11:40 -04:00
parent 31a5090f4f
commit 47e9c4faf7
8 changed files with 308 additions and 0 deletions

View File

@@ -453,3 +453,73 @@ describe('error handling — unexpected DB failures', () => {
)
})
})
// ── GET /api/config ───────────────────────────────────────────────────────────
describe('GET /api/config', () => {
it('returns 200 with all config keys', async () => {
const res = await request(app).get('/api/config')
expect(res.status).toBe(200)
expect(res.body).toHaveProperty('tailscale_enabled')
expect(res.body).toHaveProperty('tailscale_api_key')
expect(res.body).toHaveProperty('tailscale_poll_minutes')
})
it('returns empty string for api key when not set', async () => {
expect((await request(app).get('/api/config')).body.tailscale_api_key).toBe('')
})
it('masks api key as **REDACTED** when set', async () => {
await request(app).put('/api/config').send({ tailscale_api_key: 'tskey-secret' })
expect((await request(app).get('/api/config')).body.tailscale_api_key).toBe('**REDACTED**')
})
})
// ── PUT /api/config ───────────────────────────────────────────────────────────
describe('PUT /api/config', () => {
it('saves config and returns ok', async () => {
const res = await request(app).put('/api/config').send({ tailscale_tailnet: 'example.com' })
expect(res.status).toBe(200)
expect(res.body.ok).toBe(true)
})
it('does not overwrite api key when **REDACTED** is sent', async () => {
await request(app).put('/api/config').send({ tailscale_api_key: 'real-key' })
await request(app).put('/api/config').send({ tailscale_api_key: '**REDACTED**' })
expect(dbModule.getConfig('tailscale_api_key')).toBe('real-key')
})
})
// ── POST /api/jobs/tailscale/run ──────────────────────────────────────────────
describe('POST /api/jobs/tailscale/run', () => {
afterEach(() => vi.unstubAllGlobals())
it('returns 400 when not configured', async () => {
const res = await request(app).post('/api/jobs/tailscale/run')
expect(res.status).toBe(400)
expect(res.body.error).toMatch(/not configured/i)
})
it('updates matching instance and returns count', async () => {
await request(app).put('/api/config').send({
tailscale_api_key: 'tskey-test',
tailscale_tailnet: 'example.com',
})
await request(app).post('/api/instances').send({ ...base, name: 'traefik', vmid: 100 })
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ devices: [{ hostname: 'traefik', addresses: ['100.64.0.2'] }] }),
}))
const res = await request(app).post('/api/jobs/tailscale/run')
expect(res.status).toBe(200)
expect(res.body.updated).toBe(1)
const inst = await request(app).get('/api/instances/100')
expect(inst.body.tailscale).toBe(1)
expect(inst.body.tailscale_ip).toBe('100.64.0.2')
})
})