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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@ What does not work yet:
- **Any notification.** Nothing is emailed, posted or pushed anywhere: there is no mail
configuration and no secret for one. `GET /api/alerts` lists every domain whose last DNS
check did not find its token, and every domain no check has reached, which is what an
operator has instead.
operator has instead. A domain verified by hand (`domain add --verified`) is the one
exception: it is not listed until a check has passed for it at least once, because
nothing changed for it — a check that then fails, having once passed, is listed again.
- **Most link settings in the CLI.** `clickmonk link add` sets targets, a backup URL, a
click cap, an expiry, passthrough and traffic action overrides only, and no command
changes a link once it is added. Per-device destinations, a returning-visitor
Expand Down Expand Up @@ -228,7 +230,10 @@ answer 404. Over HTTPS there is no certificate to present, so the connection nev
that far: the TLS handshake itself fails, an SSL error with no page behind it. That is
what stops somebody else's hostname, pointed at your server, from getting a certificate
out of your install. `domain add --verified` is the way round it, for a trial or for a
domain you proved some other way; it says on screen that no DNS check was made.
domain you proved some other way; it says on screen that no DNS check was made, and such
a domain is not shown in `GET /api/alerts` or counted in `GET /api/status` until a check
has passed for it once — until then nothing has changed for it, so there is nothing to
warn about. A check that later fails, having once passed, is an alert again.

ClickMonk does not know its own public address, so it records what a domain resolves to
rather than judging it — a host behind NAT, a load balancer or a CDN is normal. A domain
Expand Down
71 changes: 71 additions & 0 deletions packages/admin/src/domains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,27 @@ describe('adding a domain', () => {
expect(minted.rows.filter((row) => uuidShaped(row.verification_token))).not.toHaveLength(21)
})

// The interface reads `handVerified` rather than re-deriving it, so the
// shape this route answers with is the whole of the contract.
it('answers passedAt and handVerified, computed from verified and the last check', async () => {
const fresh = (await add()).json()
expect(fresh.passedAt).toBeNull()
// Unverified, so not hand-verified either — there is nothing to call
// "verified by hand" about a domain that answers 404.
expect(fresh.handVerified).toBe(false)

await pg.query(
`INSERT INTO domains (id, host, verified, verification_token) VALUES
('00000000-0000-4000-8000-0000000000f1', 'byhand.example.test', true, '${'7'.repeat(32)}')`,
)
const list = await app.inject({ method: 'GET', url: '/api/domains', headers: read(cookie) })
const byHand = (
list.json().domains as { host: string; passedAt: unknown; handVerified: unknown }[]
).find((d) => d.host === 'byhand.example.test')
expect(byHand?.passedAt).toBeNull()
expect(byHand?.handVerified).toBe(true)
})

it('refuses a body that tries to mark it verified', async () => {
expect((await add({ host: 'go.example.test', verified: true })).statusCode).toBe(400)
expect((await pg.query('SELECT 1 FROM domains')).rowCount).toBe(0)
Expand Down Expand Up @@ -413,6 +434,14 @@ describe('checking the DNS on demand', () => {
expect(check.rows[0]?.status).toBe('verified')
expect(check.rows[0]?.checked_at.toISOString()).toBe(clock.now().toISOString())
expect(resolver.cancelled).toBe(1)
// A check that just passed for the first time: passedAt follows, and the
// domain is no different from one verified by DNS all along.
const after = await app.inject({ method: 'GET', url: '/api/domains', headers: read(cookie) })
const domain = (
after.json().domains as { id: string; passedAt: string | null; handVerified: boolean }[]
).find((d) => d.id === created.id)
expect(domain?.passedAt).toBe(clock.now().toISOString())
expect(domain?.handVerified).toBe(false)
})

it('leaves a domain unverified when the token is not there, and says why', async () => {
Expand Down Expand Up @@ -603,4 +632,46 @@ describe('what the operator has to know', () => {
expect(r.json().domains).toHaveLength(MAX_DOMAINS_LISTED)
expect(r.json().truncated).toBe(true)
})

// The four cases issue #47 draws the line between. Written directly with
// SQL, the way `clickmonk domain add --verified` and a worker pass both
// write: nothing over this API can mark a domain verified or plant a
// `passed_at` of its own choosing.
it('excludes a domain verified by hand until a check has passed for it, and re-alerts it once a passed check later fails', async () => {
await pg.query(`INSERT INTO domains (id, host, verified, verification_token) VALUES
('00000000-0000-4000-8000-0000000000e1', 'untested.example.test', true, '${'1'.repeat(32)}'),
('00000000-0000-4000-8000-0000000000e2', 'stillbare.example.test', true, '${'2'.repeat(32)}'),
('00000000-0000-4000-8000-0000000000e3', 'regressed.example.test', true, '${'3'.repeat(32)}'),
('00000000-0000-4000-8000-0000000000e4', 'awaiting.example.test', false, '${'4'.repeat(32)}')`)
await pg.query(`INSERT INTO domain_dns_checks (domain_id, status, detail, checked_at, passed_at) VALUES
-- Checked and failed, but never once passed: still hand-verified.
('00000000-0000-4000-8000-0000000000e2', 'missing_token', 'no record', now(), NULL),
-- Passed once, and its most recent check failed: something changed.
('00000000-0000-4000-8000-0000000000e3', 'missing_token', 'record gone', now(), '2026-09-01T00:00:00.000Z')`)
// 'untested' has no domain_dns_checks row at all: verified by hand, never checked.

const alerts = await app.inject({ method: 'GET', url: '/api/alerts', headers: read(cookie) })
const rows = alerts.json().domains as {
host: string
passedAt: string | null
handVerified: boolean
}[]
const hosts = rows.map((d) => d.host)
expect(hosts).not.toContain('untested.example.test')
expect(hosts).not.toContain('stillbare.example.test')
expect(hosts).toContain('regressed.example.test')
expect(hosts).toContain('awaiting.example.test')
// handVerified is always false here — ALERT_CONDITION already excludes
// every domain it would be true for — but passedAt still carries real
// information: it is what makes 'regressed' an alert at all.
const regressed = rows.find((d) => d.host === 'regressed.example.test')
expect(regressed?.passedAt).toBe('2026-09-01T00:00:00.000Z')
expect(regressed?.handVerified).toBe(false)
const awaiting = rows.find((d) => d.host === 'awaiting.example.test')
expect(awaiting?.passedAt).toBeNull()
expect(awaiting?.handVerified).toBe(false)

const status = await app.inject({ method: 'GET', url: '/api/status', headers: read(cookie) })
expect(status.json().alerts).toBe(2)
})
})
39 changes: 30 additions & 9 deletions packages/admin/src/domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,19 @@ import { fail, readBody } from './http.js'
export const MAX_DOMAINS_LISTED = 500

/**
* Which domains need attention: every one whose last check did not find the
* token, and every one no check has reached yet. One spelling, read by the
* listing below and by the count `GET /api/status` answers, so "which domains
* need attention" is never asked two ways that could answer differently.
* Which domains need attention: every domain not verified at all, and every
* verified domain whose last check did not find the token — *unless* no
* check has ever passed for it, because then nothing changed: it is exactly
* what `domain add --verified` describes on screen, verified by hand with no
* TXT record, not a domain that stopped proving itself. One spelling, read by
* the listing below and by the count `GET /api/status` answers, so "which
* domains need attention" is never asked two ways that could answer
* differently.
*/
export const ALERT_CONDITION = `(c.status IS NULL OR c.status <> 'verified')`
export const ALERT_CONDITION = `(
(c.status IS NULL OR c.status <> 'verified')
AND NOT (d.verified AND c.passed_at IS NULL)
)`

/**
* How long after a check the same domain may be checked again over the API,
Expand Down Expand Up @@ -90,10 +97,11 @@ interface DomainRow {
status: DomainDnsStatus | null
detail: string | null
checked_at: Date | null
passed_at: Date | null
}

const SELECT_DOMAINS = `SELECT d.id, d.host, d.verified, d.root_url, d.not_found_url,
d.verification_token, c.status, c.detail, c.checked_at
d.verification_token, c.status, c.detail, c.checked_at, c.passed_at
FROM domains d
LEFT JOIN domain_dns_checks c ON c.domain_id = d.id`

Expand All @@ -115,6 +123,11 @@ function asDomain(d: DomainRow): Record<string, unknown> {
d.checked_at === null
? null
: { status: d.status, detail: d.detail, checkedAt: d.checked_at.toISOString() },
passedAt: d.passed_at === null ? null : d.passed_at.toISOString(),
// So the interface never re-derives the rule ALERT_CONDITION encodes: a
// domain shown as verified by hand, with nothing to warn about, is one
// that is verified and has never once passed a check.
handVerified: d.verified && d.passed_at === null,
}
}

Expand Down Expand Up @@ -268,9 +281,11 @@ export function registerDomainRoutes(app: FastifyInstance, ctx: AdminContext): v

/**
* What the operator has to know: every domain whose last check did not find
* the token, and every domain no check has reached yet. Verification is
* never revoked automatically, so a domain here may still be serving — that
* is the point of showing it.
* the token, and every domain no check has reached yet — except a domain
* verified by hand that has never once passed a check, which is not shown
* here until it does (`ALERT_CONDITION`). Verification is never revoked
* automatically, so a domain here may still be serving — that is the point
* of showing it.
*
* Capped and counted exactly as the listing is: an operator shown a prefix
* of what is wrong, with no sign that it was a prefix, is worse off than
Expand All @@ -292,6 +307,12 @@ export function registerDomainRoutes(app: FastifyInstance, ctx: AdminContext): v
status: d.status ?? 'never_checked',
detail: d.detail,
checkedAt: d.checked_at?.toISOString() ?? null,
passedAt: d.passed_at?.toISOString() ?? null,
// Always false here: ALERT_CONDITION already excludes a hand-verified
// domain that has never passed a check, so nothing this route lists
// ever is one. Present anyway, so every domain shape in this API
// carries the same fields.
handVerified: d.verified && d.passed_at === null,
})),
}
})
Expand Down
44 changes: 37 additions & 7 deletions packages/admin/src/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,23 @@ beforeAll(async () => {
},
],
})
// Three domains: one verified by a check that found its token, one whose last
// check did not, and one no check has reached. The last two are alerts.
// Five domains, covering every case issue #47 draws the line between:
// d1 checked and currently passing (not an alert, as always);
// d2 verified by hand, no check has reached it yet (not an alert);
// d3 verified by hand, its one check failed, but nothing ever passed (not an alert);
// d4 checked, passed once, and its latest check failed — something changed (an alert);
// d5 not verified at all, awaiting its record (an alert).
await pool.query(`INSERT INTO domains (id, host, verified) VALUES
('00000000-0000-4000-8000-0000000000d1', 'ok.example.test', true),
('00000000-0000-4000-8000-0000000000d2', 'gone.example.test', true),
('00000000-0000-4000-8000-0000000000d3', 'new.example.test', false)`)
await pool.query(`INSERT INTO domain_dns_checks (domain_id, status, detail, checked_at) VALUES
('00000000-0000-4000-8000-0000000000d1', 'verified', '', '2026-09-24T11:00:00Z'),
('00000000-0000-4000-8000-0000000000d2', 'missing_token', 'no TXT record', '2026-09-24T11:00:00Z')`)
('00000000-0000-4000-8000-0000000000d2', 'untested.example.test', true),
('00000000-0000-4000-8000-0000000000d3', 'stillbare.example.test', true),
('00000000-0000-4000-8000-0000000000d4', 'regressed.example.test', true),
('00000000-0000-4000-8000-0000000000d5', 'new.example.test', false)`)
await pool.query(`INSERT INTO domain_dns_checks (domain_id, status, detail, checked_at, passed_at) VALUES
('00000000-0000-4000-8000-0000000000d1', 'verified', '', '2026-09-24T11:00:00Z', '2026-09-24T11:00:00Z'),
('00000000-0000-4000-8000-0000000000d3', 'missing_token', 'no TXT record', '2026-09-24T11:00:00Z', NULL),
('00000000-0000-4000-8000-0000000000d4', 'missing_token', 'record gone', '2026-09-24T11:00:00Z', '2026-09-01T00:00:00Z')`)
// d2 has no domain_dns_checks row at all: verified by hand, never checked.
})

beforeEach(async () => {
Expand Down Expand Up @@ -129,6 +137,28 @@ describe('GET /api/status', () => {
})
})

// The count alone does not say which two; this pins that it is the right
// two, and that a hand-verified domain's failed check joins them the moment
// something about it changes — a check that once passed. A domain of its
// own, cleaned up after, so the shared fixture the other tests in this file
// read is never left different from what they expect.
it('counts a domain verified by hand as an alert only after a check has passed for it and later failed', async () => {
await pool.query(`INSERT INTO domains (id, host, verified, verification_token) VALUES
('00000000-0000-4000-8000-0000000000d9', 'handverified.example.test', true, '${'9'.repeat(32)}')`)
await pool.query(`INSERT INTO domain_dns_checks (domain_id, status, detail, checked_at) VALUES
('00000000-0000-4000-8000-0000000000d9', 'missing_token', 'no record', '2026-09-24T11:00:00Z')`)
try {
expect((await status(app)).json().alerts).toBe(2)
await pool.query(
`UPDATE domain_dns_checks SET passed_at = '2026-09-01T00:00:00Z'
WHERE domain_id = '00000000-0000-4000-8000-0000000000d9'`,
)
expect((await status(app)).json().alerts).toBe(3)
} finally {
await pool.query(`DELETE FROM domains WHERE id = '00000000-0000-4000-8000-0000000000d9'`)
}
})

it('says there is no IP data yet, which is not a problem', async () => {
const r = await status(app)
expect(r.json().ipData).toBeNull()
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,7 @@ describe('clickmonk domain list and verify', () => {
)
expect(r.rows[0]?.verified).toBe(true)
expect(lines.join('\n')).toContain('marked verified without a DNS check')
expect(lines.join('\n')).toContain('not shown as needing attention until a check has passed')
})

it('verifies a domain whose token is published, and exits 0', async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ async function domainAdd(args: string[], d: CliDeps): Promise<void> {
d.out(`domain ${host} ${created.id}`)
if (verified) {
d.out('marked verified without a DNS check, so it can be given a certificate at once')
d.out('it is not shown as needing attention until a check has passed for it once')
return
}
printVerificationRecords(host, created.verificationToken, d)
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* The highest migration version this build understands. Every migration bumps
* it; `migrate()` refuses to run against a database whose ledger is newer.
*/
export const SCHEMA_VERSION = 9
export const SCHEMA_VERSION = 10

export * from './admin-host.js'
export * from './attempts.js'
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ describe('SCHEMA_VERSION', () => {
it('is the highest migration version this build knows about', () => {
// Bumped with every migration added. packages/db's
// schema-version.test.ts ties it to the migration files on disk.
expect(SCHEMA_VERSION).toBe(9)
expect(SCHEMA_VERSION).toBe(10)
})
})
20 changes: 20 additions & 0 deletions packages/db/migrations/postgres/010_domain_check_passed_at.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- When a domain's DNS check last passed, so a domain verified by hand (no
-- check has ever found its token) can be told apart from one that was
-- checked and found wanting. `domains.verified` alone cannot make that
-- distinction: it is set by a passing check or by `domain add --verified`,
-- and never records which.
--
-- Only `domain_dns_checks` is altered here, for the same reason 005 put the
-- check result in its own table rather than on `domains`: the redirect's
-- snapshot load reads `links` and then `domains` in one transaction, and a
-- migration holding a lock on both, in the other order, could deadlock
-- against it and be the statement Postgres aborts. This migration takes an
-- ACCESS EXCLUSIVE lock on `domain_dns_checks` alone, a table the redirect's
-- snapshot load never reads, so it cannot be either half of that deadlock.
--
-- The column is added without a default, backfilled, and left without one:
-- every future row is written by `recordDomainCheck`'s single upsert, which
-- always states `passed_at` explicitly, so a default would only paper over a
-- caller that forgot to.
ALTER TABLE domain_dns_checks ADD COLUMN passed_at timestamptz NULL;
UPDATE domain_dns_checks SET passed_at = checked_at WHERE status = 'verified';
Loading
Loading