domains: what you own, who runs its DNS, and the keys
one section, not two. a provider serves many domains so the lists differ, but a person thinks in DOMAINS — the provider is something the check discovers, and a key only matters as the answer to "can officer do anything for THIS domain". two tabs would make somebody hold that join in their head, and the providers tab would be two rows most of the time. so the key is entered from the row that needs one and stored against its provider: add it for one cloudflare domain and every other cloudflare domain turns green at once. THE CHECK runs before you commit, because its answer is usually the reason you would or would not add it, and a list of unchecked rows is a list somebody has to remember to revisit. it reports five verdicts, not two, and the extra three are the interesting ones. `private` — atelier.officer.dev resolves to 100.64.x, which is CORRECT and reads as broken against any public-IP comparison. `unknown` — we could not establish our own address, so "is that us" has no answer and saying so beats a confident wrong one. `nowhere` — a name bought ten minutes ago. plus maybeProxied, because a cloudflare domain resolving to cloudflare is a working setup rather than a misconfiguration. NS gives the DNS HOST, not the registrar. officer.dev is godaddy on both counts here, but a domain bought at godaddy and delegated to cloudflare answers cloudflare — and that is the one that matters, since its API is what places an ACME challenge. the UI says so where it would otherwise be read as wrong. only an apex has NS records, so the lookup walks up to find them. verified live: platform.officer.dev now answers godaddy instead of nothing. CREDENTIALS are keyed on the provider, encrypted under this plugin's purpose key, never returned by any route. scope is narrow|broad and NOT read|write — DNS-01 works by WRITING a TXT record, so no read-only key can ever issue a certificate, and offering one would produce a setup that looks configured and fails at the first renewal sixty days later. the dialog says that out loud. the form is generated from NPM's own dns-providers template, so the field names are the provider's and it stays right for providers we have never heard of. the routes live under /_config, which the manifest already marks ownerOnly: a DNS key can rewrite every record for a domain, which is strictly more than the NPM login kept there, and declaring it once beats checking it twice. tables verified by query rather than by db:push's exit code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
+106
-1
@@ -1,4 +1,4 @@
|
||||
import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, serial, text, timestamp, integer, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
/**
|
||||
* The connection to Nginx Proxy Manager.
|
||||
@@ -46,3 +46,108 @@ export const proxyManagerConnection = pgTable('proxy_manager_connection', {
|
||||
});
|
||||
|
||||
export type ProxyManagerConnection = typeof proxyManagerConnection.$inferSelect;
|
||||
|
||||
/**
|
||||
* A domain this machine is responsible for.
|
||||
*
|
||||
* ── Why Officer stores this at all, when DNS already knows ──
|
||||
*
|
||||
* Because "which domains are mine" is not a question DNS can answer — you can only ask about a name you
|
||||
* already have. Today that list lives in a document somebody edits by hand, which is correct on the day it
|
||||
* is written. A row here is the owner saying "this one is mine", and everything else on it is a cached
|
||||
* observation that can be refreshed.
|
||||
*
|
||||
* ── Everything except `domain` is a CACHE ──
|
||||
*
|
||||
* `dnsProvider`, `pointsTo` and `resolvedIps` are what a lookup said at `lastCheckedAt`. They are stored so
|
||||
* a list renders without doing nine DNS queries, and they are never treated as authority: the check can be
|
||||
* re-run, and a stale answer is labelled with its age rather than presented as current.
|
||||
*/
|
||||
export const proxyManagerDomains = pgTable(
|
||||
'proxy_manager_domains',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
|
||||
/** The apex, lowercased, no trailing dot — `officer.dev`. Subdomains are not rows; they belong to one. */
|
||||
domain: text('domain').notNull(),
|
||||
|
||||
/**
|
||||
* Who answers for it, derived from its NS records — `cloudflare`, `godaddy`, `route53`.
|
||||
*
|
||||
* **This is the DNS HOST, not the registrar**, and they are frequently different: a domain bought at
|
||||
* GoDaddy with nameservers pointed at Cloudflare answers `cloudflare` here. That is the useful one —
|
||||
* it is whose API can place an ACME challenge record — but it is worth the sentence, because somebody
|
||||
* who knows where they bought a domain will otherwise read this as wrong.
|
||||
*/
|
||||
dnsProvider: text('dns_provider'),
|
||||
|
||||
/** What the last check made of where it points — see `PointsTo` in `api/dns.ts`. */
|
||||
pointsTo: text('points_to'),
|
||||
|
||||
/** The addresses it resolved to, so a person can see the actual answer rather than our verdict. */
|
||||
resolvedIps: text('resolved_ips').array(),
|
||||
|
||||
lastCheckedAt: timestamp('last_checked_at', { withTimezone: true }),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [uniqueIndex('uq_proxy_manager_domains_domain').on(table.domain)],
|
||||
);
|
||||
|
||||
export type ProxyManagerDomain = typeof proxyManagerDomains.$inferSelect;
|
||||
|
||||
/**
|
||||
* One DNS provider's API credential.
|
||||
*
|
||||
* ── Keyed on the PROVIDER, not the domain ──
|
||||
*
|
||||
* One Cloudflare token serves every domain whose DNS is on Cloudflare. Storing a copy per domain would
|
||||
* mean rotating it in six places and getting five of them.
|
||||
*
|
||||
* ── `scope` is narrow or broad, and it is NOT read or write ──
|
||||
*
|
||||
* A DNS-01 challenge works by CREATING a `_acme-challenge` TXT record, so issuing a certificate needs
|
||||
* write access. There is no read-only option that can do it, and offering one would produce a setup that
|
||||
* looks configured and fails at the first renewal — sixty days later, silently.
|
||||
*
|
||||
* What actually differs is blast radius. `narrow` is a token the provider scoped to DNS edits on the zones
|
||||
* that need it; `broad` is a full-account key that can do anything. Both issue certificates. Only the
|
||||
* second lets an agent manage records generally, which is a thing the owner may well want — but should
|
||||
* choose knowing which one they handed over.
|
||||
*
|
||||
* Whether `narrow` is even available is the provider's business: Cloudflare scopes tokens per zone,
|
||||
* GoDaddy's key and secret are account-wide with no scoping at all.
|
||||
*/
|
||||
export const proxyManagerDnsCredentials = pgTable(
|
||||
'proxy_manager_dns_credentials',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
|
||||
/** Matches `proxyManagerDomains.dnsProvider` — `cloudflare`, `godaddy`. */
|
||||
provider: text('provider').notNull(),
|
||||
|
||||
/**
|
||||
* The credential itself, encrypted with `encryptSecret('proxy-manager', …)`.
|
||||
*
|
||||
* A JSON object rather than a string, because providers want different fields — Cloudflare one token,
|
||||
* GoDaddy a key AND a secret, Route53 three. NPM already describes those field names per provider
|
||||
* (`GET /nginx/certificates/dns-providers`), so the shape is theirs and this only carries it.
|
||||
*
|
||||
* **Never returned across the API boundary**, at any level, to anybody, including the owner.
|
||||
*/
|
||||
secret: text('secret').notNull(),
|
||||
|
||||
/** `narrow` | `broad`. See the note above — this is blast radius, not read versus write. */
|
||||
scope: text('scope').notNull().default('narrow'),
|
||||
|
||||
/** What the owner called it, so a rotated key is recognisable. Optional. */
|
||||
label: text('label'),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [uniqueIndex('uq_proxy_manager_dns_credentials_provider').on(table.provider)],
|
||||
);
|
||||
|
||||
export type ProxyManagerDnsCredential = typeof proxyManagerDnsCredentials.$inferSelect;
|
||||
|
||||
Reference in New Issue
Block a user