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
204 lines
7.8 KiB
TypeScript
204 lines
7.8 KiB
TypeScript
import { db } from 'officerdb/db';
|
|
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
|
import { eq } from 'drizzle-orm';
|
|
import {
|
|
proxyManagerDomains,
|
|
proxyManagerDnsCredentials,
|
|
type ProxyManagerDomain,
|
|
} from '../db/schema';
|
|
import { checkDomain, normaliseDomain, looksLikeDomain, type DomainCheck } from './dns';
|
|
|
|
/**
|
|
* Domains, and the DNS credentials that go with them.
|
|
*
|
|
* The secret rule is the same one `connection.ts` states and for the same reason: it never leaves this
|
|
* module in plaintext except into the code that uses it. There is no route that returns a DNS key, at any
|
|
* level, to anybody. A screen that wants to know whether one exists gets a boolean.
|
|
*/
|
|
|
|
const PURPOSE = 'proxy-manager';
|
|
|
|
export type DomainView = {
|
|
id: number;
|
|
domain: string;
|
|
dnsProvider: string | null;
|
|
pointsTo: string | null;
|
|
resolvedIps: string[];
|
|
lastCheckedAt: string | null;
|
|
/** Whether a credential exists for THIS domain's provider. Never the credential. */
|
|
hasCredential: boolean;
|
|
/** `narrow` | `broad` | null. What was handed over, not what it is. */
|
|
credentialScope: string | null;
|
|
};
|
|
|
|
const view = (row: ProxyManagerDomain, credentials: Map<string, string>): DomainView => ({
|
|
id: row.id,
|
|
domain: row.domain,
|
|
dnsProvider: row.dnsProvider,
|
|
pointsTo: row.pointsTo,
|
|
resolvedIps: row.resolvedIps ?? [],
|
|
lastCheckedAt: row.lastCheckedAt ? row.lastCheckedAt.toISOString() : null,
|
|
hasCredential: !!row.dnsProvider && credentials.has(row.dnsProvider),
|
|
credentialScope: (row.dnsProvider && credentials.get(row.dnsProvider)) ?? null,
|
|
});
|
|
|
|
/** Provider → scope, for every stored credential. The scope is safe to show; the secret is not. */
|
|
async function credentialScopes(): Promise<Map<string, string>> {
|
|
const rows = await db
|
|
.select({ provider: proxyManagerDnsCredentials.provider, scope: proxyManagerDnsCredentials.scope })
|
|
.from(proxyManagerDnsCredentials);
|
|
return new Map(rows.map((row) => [row.provider, row.scope]));
|
|
}
|
|
|
|
export async function listDomains(): Promise<DomainView[]> {
|
|
const [rows, scopes] = await Promise.all([
|
|
db.select().from(proxyManagerDomains).orderBy(proxyManagerDomains.domain),
|
|
credentialScopes(),
|
|
]);
|
|
return rows.map((row) => view(row, scopes));
|
|
}
|
|
|
|
export class DomainError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly status: 400 | 404 | 409,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a domain, checking it on the way in.
|
|
*
|
|
* The check is not optional and not deferred: a row with no provider and no verdict is a row somebody has
|
|
* to remember to refresh, and the whole value of the list is that it says something. A domain that does
|
|
* not resolve is still added — "nowhere" is a real and common state for a name bought ten minutes ago —
|
|
* but it is added with that recorded rather than blank.
|
|
*/
|
|
export async function addDomain(input: string): Promise<DomainView> {
|
|
const domain = normaliseDomain(input);
|
|
if (!looksLikeDomain(domain)) throw new DomainError(`"${input}" does not look like a domain.`, 400);
|
|
|
|
const existing = await db.select().from(proxyManagerDomains).where(eq(proxyManagerDomains.domain, domain));
|
|
if (existing.length > 0) throw new DomainError(`${domain} is already here.`, 409);
|
|
|
|
const checked = await checkDomain(domain);
|
|
const [row] = await db
|
|
.insert(proxyManagerDomains)
|
|
.values({
|
|
domain,
|
|
dnsProvider: checked.dnsProvider,
|
|
pointsTo: checked.pointsTo,
|
|
resolvedIps: checked.resolvedIps,
|
|
lastCheckedAt: new Date(),
|
|
})
|
|
.returning();
|
|
|
|
return view(row!, await credentialScopes());
|
|
}
|
|
|
|
/** Re-run the lookup for a domain already on the list, and keep what it says. */
|
|
export async function recheckDomain(id: number): Promise<DomainView> {
|
|
const [row] = await db.select().from(proxyManagerDomains).where(eq(proxyManagerDomains.id, id));
|
|
if (!row) throw new DomainError('No such domain.', 404);
|
|
|
|
const checked = await checkDomain(row.domain);
|
|
const [updated] = await db
|
|
.update(proxyManagerDomains)
|
|
.set({
|
|
dnsProvider: checked.dnsProvider,
|
|
pointsTo: checked.pointsTo,
|
|
resolvedIps: checked.resolvedIps,
|
|
lastCheckedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(proxyManagerDomains.id, id))
|
|
.returning();
|
|
|
|
return view(updated!, await credentialScopes());
|
|
}
|
|
|
|
export async function removeDomain(id: number): Promise<void> {
|
|
await db.delete(proxyManagerDomains).where(eq(proxyManagerDomains.id, id));
|
|
}
|
|
|
|
/** A check with nothing stored — what the button does before you commit to adding it. */
|
|
export async function dryCheck(input: string): Promise<DomainCheck> {
|
|
const domain = normaliseDomain(input);
|
|
if (!looksLikeDomain(domain)) throw new DomainError(`"${input}" does not look like a domain.`, 400);
|
|
return checkDomain(domain);
|
|
}
|
|
|
|
// ── Credentials ─────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
export type CredentialView = { provider: string; scope: string; label: string | null; updatedAt: string };
|
|
|
|
export async function listCredentials(): Promise<CredentialView[]> {
|
|
const rows = await db.select().from(proxyManagerDnsCredentials).orderBy(proxyManagerDnsCredentials.provider);
|
|
return rows.map((row) => ({
|
|
provider: row.provider,
|
|
scope: row.scope,
|
|
label: row.label,
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
// No secret. Deliberately not even a length.
|
|
}));
|
|
}
|
|
|
|
export type SaveCredentialParams = {
|
|
provider: string;
|
|
/** The provider's own fields — one token, or a key and a secret. Shapes come from NPM's dns-providers list. */
|
|
fields: Record<string, string>;
|
|
scope: 'narrow' | 'broad';
|
|
label?: string;
|
|
};
|
|
|
|
export async function saveCredential(params: SaveCredentialParams): Promise<CredentialView> {
|
|
const provider = params.provider.trim().toLowerCase();
|
|
if (!provider) throw new DomainError('A provider is required.', 400);
|
|
|
|
const filled = Object.entries(params.fields ?? {}).filter(([, value]) => typeof value === 'string' && value.trim());
|
|
if (filled.length === 0) throw new DomainError('The credential is empty.', 400);
|
|
|
|
const secret = encryptSecret(PURPOSE, JSON.stringify(Object.fromEntries(filled)));
|
|
const existing = await db
|
|
.select()
|
|
.from(proxyManagerDnsCredentials)
|
|
.where(eq(proxyManagerDnsCredentials.provider, provider));
|
|
|
|
if (existing.length > 0) {
|
|
await db
|
|
.update(proxyManagerDnsCredentials)
|
|
.set({ secret, scope: params.scope, label: params.label ?? null, updatedAt: new Date() })
|
|
.where(eq(proxyManagerDnsCredentials.provider, provider));
|
|
} else {
|
|
await db
|
|
.insert(proxyManagerDnsCredentials)
|
|
.values({ provider, secret, scope: params.scope, label: params.label ?? null });
|
|
}
|
|
|
|
const [row] = await db
|
|
.select()
|
|
.from(proxyManagerDnsCredentials)
|
|
.where(eq(proxyManagerDnsCredentials.provider, provider));
|
|
return { provider, scope: row!.scope, label: row!.label, updatedAt: row!.updatedAt.toISOString() };
|
|
}
|
|
|
|
export async function removeCredential(provider: string): Promise<void> {
|
|
await db.delete(proxyManagerDnsCredentials).where(eq(proxyManagerDnsCredentials.provider, provider.toLowerCase()));
|
|
}
|
|
|
|
/**
|
|
* Decrypted, for the code that places a challenge record. **Never serialise the result of this.**
|
|
*
|
|
* Nothing calls it yet — issuing a certificate through DNS-01 is the next piece, and it is the only thing
|
|
* that should. It exists now so the storage above is provably usable rather than write-only.
|
|
*/
|
|
export async function readCredentialFields(provider: string): Promise<Record<string, string> | null> {
|
|
const [row] = await db
|
|
.select()
|
|
.from(proxyManagerDnsCredentials)
|
|
.where(eq(proxyManagerDnsCredentials.provider, provider.toLowerCase()));
|
|
if (!row) return null;
|
|
return JSON.parse(decryptSecret(PURPOSE, row.secret)) as Record<string, string>;
|
|
}
|