@bakers-build/addon-ports-frp 0.0.0-stage → 0.1.0-next.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 keytec GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @bakers-build/addon-ports-frp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ bakers.build ports transport: a self-hosted frp (frpc) client over an operator-owned config, reconciled via its local admin API. Implements @bakers-build/addon-ports's PortsTransport contract.
4
+
5
+ Part of the bakers.build CLI. Install the meta package `@bakers-build/bakers` to get the `bakers` CLI with all addons. Source: https://git.key-tec.de/bakers-build/bakers-build (bakers-cli/).
@@ -0,0 +1,92 @@
1
+ import type { TunnelDesiredRecord } from '@bakers-build/addon-ports/transport';
2
+ /** The `@bakers-build/addon-ports` entry's `with.transport.with` for this transport. */
3
+ export interface FrpSettings {
4
+ readonly zone: string;
5
+ /** Absolute path to the operator-owned frpc client config (JSON). This
6
+ * addon only READS and validates it — it never writes to it. */
7
+ readonly clientConfigPath: string;
8
+ readonly adminPort: number;
9
+ readonly adminUser: string;
10
+ readonly adminPassword: string;
11
+ }
12
+ export declare class FrpConfigError extends Error {
13
+ constructor(message: string);
14
+ }
15
+ export declare function validateSettings(settings: unknown): FrpSettings;
16
+ /** The slice of the operator's frpc JSON config this addon actually reads.
17
+ * ponytail: this is a PLAUSIBLE frpc 0.68.0 config shape built from the
18
+ * public docs' field names (`serverAddr`/`serverPort`/`auth`/`user`/
19
+ * `webServer`/`transport.tls`/`includes`) — not a verified real capture.
20
+ * Upgrade path: swap in the real frpc config schema/types if frp ships
21
+ * one, or once an operator confirms the exact shape in use. */
22
+ export interface FrpClientConfig {
23
+ readonly serverAddr?: unknown;
24
+ readonly serverPort?: unknown;
25
+ readonly user?: unknown;
26
+ readonly auth?: {
27
+ readonly method?: unknown;
28
+ } | undefined;
29
+ readonly webServer?: {
30
+ readonly addr?: unknown;
31
+ } | undefined;
32
+ readonly transport?: {
33
+ readonly tls?: {
34
+ readonly enable?: unknown;
35
+ readonly trustedCaFile?: unknown;
36
+ } | undefined;
37
+ } | undefined;
38
+ readonly includes?: readonly unknown[] | undefined;
39
+ readonly proxies?: unknown;
40
+ readonly visitors?: unknown;
41
+ readonly store?: unknown;
42
+ }
43
+ /** The nonsecret canonical client identity: server + user + zone + the
44
+ * config path itself (NOT its contents — token rotation inside the same
45
+ * file must not change this, per Task 2's identity-change-safety rule). */
46
+ export interface FrpClientIdentity {
47
+ readonly server: string;
48
+ readonly user: string;
49
+ }
50
+ /**
51
+ * Validate the operator's frpc config strictly (spec): admin API loopback
52
+ * only, a dedicated user/client identity, server address/port, auth
53
+ * present, EXPLICIT server-TLS verification (frp's default encryption alone
54
+ * is not identity verification), and EXACTLY ONE include — the absolute
55
+ * routes file path this addon owns. Any other shape — inline proxies,
56
+ * visitors, a store, extra/missing includes — is a hard, pre-mutation error.
57
+ */
58
+ export declare function validateClientConfig(config: unknown, routesPath: string): FrpClientIdentity;
59
+ /** frp client-plugin proxy shape this addon writes into routes.json — the
60
+ * complete desired proxy list, no secrets. */
61
+ export interface FrpProxy {
62
+ readonly name: string;
63
+ readonly type: 'http';
64
+ readonly customDomains: readonly [string];
65
+ readonly localIP?: string;
66
+ readonly localPort?: number;
67
+ readonly hostHeaderRewrite?: string;
68
+ readonly requestHeaders?: {
69
+ readonly set: Readonly<Record<string, string>>;
70
+ };
71
+ readonly plugin?: {
72
+ readonly type: 'http2https';
73
+ readonly localAddr: string;
74
+ readonly hostHeaderRewrite?: string;
75
+ };
76
+ }
77
+ export declare class FrpUnverifiedHttpsError extends FrpConfigError {
78
+ constructor(hostname: string | undefined);
79
+ }
80
+ /** The record's `direct` container origin as host + port, or `undefined`
81
+ * when it has none. `direct` comes from persisted state, so anything but a
82
+ * plain `http:` URL with a host is rejected rather than dialled. */
83
+ export declare function directOrigin(record: TunnelDesiredRecord): {
84
+ readonly host: string;
85
+ readonly port: number;
86
+ } | undefined;
87
+ /**
88
+ * Pure translation: one `TunnelDesiredRecord` -> one frp proxy. Throws
89
+ * `FrpUnverifiedHttpsError` for an HTTPS origin whose verification was
90
+ * requested (insecureTls false/omitted) — never silently downgraded.
91
+ */
92
+ export declare function toFrpProxy(record: TunnelDesiredRecord): FrpProxy;
@@ -0,0 +1,162 @@
1
+ // BB-681 Task 3 — frp (frpc) as a `PortsTransport`. The frpc PROCESS is
2
+ // operator-managed (same stance as Cloudflare's `cloudflared`): this package
3
+ // never starts/stops it, only (a) validates the operator's own frpc config
4
+ // is shaped the way this addon needs, (b) writes the ONE file it owns
5
+ // (`<home>/frp/routes.json`, the config's sole `includes` entry) and (c)
6
+ // drives the local admin API to reload + confirm.
7
+ export class FrpConfigError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = 'FrpConfigError';
11
+ }
12
+ }
13
+ export function validateSettings(settings) {
14
+ if (typeof settings !== 'object' || settings === null) {
15
+ throw new FrpConfigError("the @bakers-build/addon-ports entry's with.transport.with for 'frp' must be an object with zone/clientConfigPath/adminPort/adminUser/adminPassword");
16
+ }
17
+ const obj = settings;
18
+ if (typeof obj.zone !== 'string' || obj.zone === '') {
19
+ throw new FrpConfigError("the @bakers-build/addon-ports entry's with.transport.with for 'frp' is missing: zone");
20
+ }
21
+ if (typeof obj.clientConfigPath !== 'string' ||
22
+ !obj.clientConfigPath.startsWith('/')) {
23
+ throw new FrpConfigError("the @bakers-build/addon-ports entry's with.transport.with for 'frp' requires an absolute 'clientConfigPath'");
24
+ }
25
+ if (typeof obj.adminPort !== 'number' || !Number.isInteger(obj.adminPort)) {
26
+ throw new FrpConfigError("the @bakers-build/addon-ports entry's with.transport.with for 'frp' requires an integer 'adminPort'");
27
+ }
28
+ if (typeof obj.adminUser !== 'string' || obj.adminUser === '') {
29
+ throw new FrpConfigError("the @bakers-build/addon-ports entry's with.transport.with for 'frp' is missing: adminUser");
30
+ }
31
+ if (typeof obj.adminPassword !== 'string' || obj.adminPassword === '') {
32
+ throw new FrpConfigError("the @bakers-build/addon-ports entry's with.transport.with for 'frp' is missing: adminPassword");
33
+ }
34
+ return {
35
+ zone: obj.zone,
36
+ clientConfigPath: obj.clientConfigPath,
37
+ adminPort: obj.adminPort,
38
+ adminUser: obj.adminUser,
39
+ adminPassword: obj.adminPassword,
40
+ };
41
+ }
42
+ /**
43
+ * Validate the operator's frpc config strictly (spec): admin API loopback
44
+ * only, a dedicated user/client identity, server address/port, auth
45
+ * present, EXPLICIT server-TLS verification (frp's default encryption alone
46
+ * is not identity verification), and EXACTLY ONE include — the absolute
47
+ * routes file path this addon owns. Any other shape — inline proxies,
48
+ * visitors, a store, extra/missing includes — is a hard, pre-mutation error.
49
+ */
50
+ export function validateClientConfig(config, routesPath) {
51
+ if (typeof config !== 'object' || config === null) {
52
+ throw new FrpConfigError('frpc client config must be a JSON object');
53
+ }
54
+ const cfg = config;
55
+ if (cfg.proxies !== undefined) {
56
+ throw new FrpConfigError('frpc client config must not declare inline proxies — this addon owns routes.json exclusively');
57
+ }
58
+ if (cfg.visitors !== undefined) {
59
+ throw new FrpConfigError('frpc client config must not declare inline visitors');
60
+ }
61
+ if (cfg.store !== undefined) {
62
+ throw new FrpConfigError('frpc client config must not declare a store');
63
+ }
64
+ if (typeof cfg.serverAddr !== 'string' || cfg.serverAddr === '') {
65
+ throw new FrpConfigError('frpc client config is missing serverAddr');
66
+ }
67
+ if (typeof cfg.serverPort !== 'number') {
68
+ throw new FrpConfigError('frpc client config is missing serverPort');
69
+ }
70
+ if (typeof cfg.user !== 'string' || cfg.user === '') {
71
+ throw new FrpConfigError('frpc client config must declare a dedicated user/client identity');
72
+ }
73
+ if (typeof cfg.auth?.method !== 'string' || cfg.auth.method === '') {
74
+ throw new FrpConfigError('frpc client config must declare authentication');
75
+ }
76
+ if (cfg.webServer?.addr !== '127.0.0.1') {
77
+ throw new FrpConfigError('frpc admin service (webServer.addr) must bind 127.0.0.1 (loopback only)');
78
+ }
79
+ const tls = cfg.transport?.tls;
80
+ if (tls?.enable !== true ||
81
+ typeof tls.trustedCaFile !== 'string' ||
82
+ tls.trustedCaFile === '') {
83
+ throw new FrpConfigError('frpc client config must explicitly require TLS verification of the server (transport.tls.enable=true with a trustedCaFile) — encryption alone is not identity verification');
84
+ }
85
+ const includes = cfg.includes ?? [];
86
+ if (includes.length !== 1 || includes[0] !== routesPath) {
87
+ throw new FrpConfigError(`frpc client config must have EXACTLY ONE include, the routes file this addon owns (${routesPath})`);
88
+ }
89
+ return { server: `${cfg.serverAddr}:${cfg.serverPort}`, user: cfg.user };
90
+ }
91
+ export class FrpUnverifiedHttpsError extends FrpConfigError {
92
+ constructor(hostname) {
93
+ super(`${hostname ?? '(unknown route)'}: requests verified HTTPS origin proxying, but frp 0.68.0's http2https plugin unconditionally skips origin certificate verification — set insecureTls: true to accept that, or serve plain HTTP`);
94
+ this.name = 'FrpUnverifiedHttpsError';
95
+ }
96
+ }
97
+ /** The record's `direct` container origin as host + port, or `undefined`
98
+ * when it has none. `direct` comes from persisted state, so anything but a
99
+ * plain `http:` URL with a host is rejected rather than dialled. */
100
+ export function directOrigin(record) {
101
+ if (record.direct === undefined)
102
+ return undefined;
103
+ let url;
104
+ try {
105
+ url = new URL(record.direct);
106
+ }
107
+ catch {
108
+ url = undefined;
109
+ }
110
+ if (url?.protocol !== 'http:' || url.hostname === '') {
111
+ throw new FrpConfigError(`${record.hostname ?? '(unknown route)'}: direct origin must be a plain http://<host>:<port> URL`);
112
+ }
113
+ return { host: url.hostname, port: Number(url.port || '80') };
114
+ }
115
+ /**
116
+ * Pure translation: one `TunnelDesiredRecord` -> one frp proxy. Throws
117
+ * `FrpUnverifiedHttpsError` for an HTTPS origin whose verification was
118
+ * requested (insecureTls false/omitted) — never silently downgraded.
119
+ */
120
+ export function toFrpProxy(record) {
121
+ const name = `${record.ticket}-${record.key}`;
122
+ const hostname = record.hostname;
123
+ // A direct container origin skips the router entirely: no Host rewrite, so
124
+ // the app sees the public Host; frps terminates TLS, hence the forced proto.
125
+ const direct = directOrigin(record);
126
+ if (direct) {
127
+ return {
128
+ name,
129
+ type: 'http',
130
+ customDomains: [hostname],
131
+ localIP: direct.host,
132
+ localPort: direct.port,
133
+ requestHeaders: { set: { 'X-Forwarded-Proto': 'https' } },
134
+ };
135
+ }
136
+ const url = new URL(record.service);
137
+ if (url.protocol === 'https:') {
138
+ if (record.insecureTls !== true) {
139
+ throw new FrpUnverifiedHttpsError(hostname);
140
+ }
141
+ const port = url.port || '443';
142
+ return {
143
+ name,
144
+ type: 'http',
145
+ customDomains: [hostname],
146
+ plugin: {
147
+ type: 'http2https',
148
+ localAddr: `${url.hostname}:${port}`,
149
+ ...(record.hostHeader ? { hostHeaderRewrite: record.hostHeader } : {}),
150
+ },
151
+ };
152
+ }
153
+ const port = Number(url.port || '80');
154
+ return {
155
+ name,
156
+ type: 'http',
157
+ customDomains: [hostname],
158
+ localIP: url.hostname,
159
+ localPort: port,
160
+ ...(record.hostHeader ? { hostHeaderRewrite: record.hostHeader } : {}),
161
+ };
162
+ }
@@ -0,0 +1,8 @@
1
+ import type { PortsTransport } from '@bakers-build/addon-ports/transport';
2
+ export type { FrpSettings } from './frp.js';
3
+ export { FrpConfigError, FrpUnverifiedHttpsError, toFrpProxy } from './frp.js';
4
+ export type { FrpDeps, FrpReconcileConfig } from './reconcile.js';
5
+ export { reconcileFrp } from './reconcile.js';
6
+ export default function createTransport(settings: unknown, context: {
7
+ readonly home: string;
8
+ }): Promise<PortsTransport>;
@@ -0,0 +1,83 @@
1
+ // BB-681 Task 3 — frp as a `PortsTransport`, wired to real Node I/O. The
2
+ // factory reads and strictly validates the operator's frpc config BEFORE
3
+ // returning a transport (spec: reject before any mutation), then hands
4
+ // `reconcileFrp` real fs/fetch/clock/delay seams.
5
+ import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
6
+ import { createConnection } from 'node:net';
7
+ import { join } from 'node:path';
8
+ import { validateClientConfig, validateSettings, } from './frp.js';
9
+ import { reconcileFrp, } from './reconcile.js';
10
+ export { FrpConfigError, FrpUnverifiedHttpsError, toFrpProxy } from './frp.js';
11
+ export { reconcileFrp } from './reconcile.js';
12
+ const nodeFrpDeps = {
13
+ readFile: (path) => readFile(path, 'utf8'),
14
+ writeFile: async (path, data, mode) => {
15
+ await writeFile(path, data, { encoding: 'utf8', mode });
16
+ await chmod(path, mode);
17
+ },
18
+ rename: (from, to) => rename(from, to),
19
+ mkdir: async (path) => {
20
+ await mkdir(path, { recursive: true });
21
+ },
22
+ fetch: async (url, init) => {
23
+ const res = await fetch(url, init);
24
+ return { ok: res.ok, status: res.status, json: () => res.json() };
25
+ },
26
+ now: () => Date.now(),
27
+ delay: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
28
+ canConnect: (host, port, timeoutMs) => new Promise((resolve) => {
29
+ const socket = createConnection({ host, port, timeout: timeoutMs });
30
+ const done = (reachable) => {
31
+ socket.destroy();
32
+ resolve(reachable);
33
+ };
34
+ socket.once('connect', () => done(true));
35
+ socket.once('timeout', () => done(false));
36
+ socket.once('error', () => done(false));
37
+ }),
38
+ };
39
+ /** Allowlisted provider state for this transport: only the generation
40
+ * fingerprint `reconcileFrp` uses for crash-recovery/foreign-file checks. */
41
+ function validateData(data) {
42
+ if (typeof data !== 'object' || data === null) {
43
+ throw new Error('frp transport data must be an object');
44
+ }
45
+ const obj = data;
46
+ const keys = Object.keys(obj);
47
+ if (keys.some((k) => k !== 'generation')) {
48
+ throw new Error(`frp transport data has unknown field(s): ${keys.filter((k) => k !== 'generation').join(', ')}`);
49
+ }
50
+ if (obj.generation !== undefined && typeof obj.generation !== 'string') {
51
+ throw new Error('frp transport data.generation must be a string');
52
+ }
53
+ }
54
+ export default async function createTransport(settings, context) {
55
+ const cfg = validateSettings(settings);
56
+ const routesPath = join(context.home, 'frp', 'routes.json');
57
+ let rawConfig;
58
+ try {
59
+ rawConfig = JSON.parse(await nodeFrpDeps.readFile(cfg.clientConfigPath));
60
+ }
61
+ catch {
62
+ // BB-681 review M5: a fixed message, never the raw fs/JSON.parse
63
+ // error — that error can embed filesystem details (permissions, other
64
+ // paths probed) this client has no business repeating into CLI/inbox
65
+ // output. The configured path alone is enough for the operator to fix.
66
+ throw new Error(`frp transport cannot read or parse clientConfigPath '${cfg.clientConfigPath}' as JSON`);
67
+ }
68
+ const clientIdentity = validateClientConfig(rawConfig, routesPath);
69
+ const reconcileCfg = {
70
+ routesPath,
71
+ identity: `${cfg.clientConfigPath}/${clientIdentity.server}/${clientIdentity.user}/${cfg.zone}`,
72
+ adminBase: `http://127.0.0.1:${cfg.adminPort}`,
73
+ adminUser: cfg.adminUser,
74
+ adminPassword: cfg.adminPassword,
75
+ };
76
+ return {
77
+ id: 'frp',
78
+ identity: reconcileCfg.identity,
79
+ zone: cfg.zone,
80
+ validateData,
81
+ reconcile: (records, { checkpoint }) => reconcileFrp(records, reconcileCfg, nodeFrpDeps, { checkpoint }),
82
+ };
83
+ }
@@ -0,0 +1,46 @@
1
+ import type { TunnelDesiredRecord } from '@bakers-build/addon-ports/transport';
2
+ import { type FrpProxy } from './frp.js';
3
+ /** The injectable seams (TDD deps pattern, matching `addon-ports-cloudflare`'s
4
+ * `CloudflareDeps` / `addon-ports`'s `TunnelStateDeps`): fs, fetch, clock,
5
+ * delay — nothing here calls `node:fs`/`fetch`/`setTimeout` directly. */
6
+ export interface FrpDeps {
7
+ readFile(path: string): Promise<string>;
8
+ writeFile(path: string, data: string, mode: number): Promise<void>;
9
+ rename(from: string, to: string): Promise<void>;
10
+ mkdir(path: string): Promise<void>;
11
+ fetch(url: string, init: {
12
+ readonly method: string;
13
+ readonly headers: Readonly<Record<string, string>>;
14
+ }): Promise<{
15
+ readonly ok: boolean;
16
+ readonly status: number;
17
+ json(): Promise<unknown>;
18
+ }>;
19
+ now(): number;
20
+ delay(ms: number): Promise<void>;
21
+ /** Bounded TCP connect — can this host reach a container IP at all? */
22
+ canConnect(host: string, port: number, timeoutMs: number): Promise<boolean>;
23
+ }
24
+ export interface FrpReconcileConfig {
25
+ readonly routesPath: string;
26
+ readonly identity: string;
27
+ readonly adminBase: string;
28
+ readonly adminUser: string;
29
+ readonly adminPassword: string;
30
+ /** Bounded status-poll deadline/interval — never an unbounded wait. */
31
+ readonly pollTimeoutMs?: number;
32
+ readonly pollIntervalMs?: number;
33
+ }
34
+ /** Deterministic fingerprint of (identity, desired proxy set) — the
35
+ * ownership marker written into routes.json alongside the content it
36
+ * describes, so a later reconcile can tell "this generation" apart from a
37
+ * foreign file without needing a lock held across process restarts.
38
+ * ponytail: content hash, not a counter — simplest thing that is stable
39
+ * for an unchanged desired set and changes whenever it doesn't. */
40
+ export declare function computeGeneration(identity: string, proxies: readonly FrpProxy[]): string;
41
+ export declare function reconcileFrp(records: readonly TunnelDesiredRecord[], cfg: FrpReconcileConfig, deps: FrpDeps, context: {
42
+ checkpoint(records: readonly TunnelDesiredRecord[]): Promise<void>;
43
+ }): Promise<{
44
+ readonly records: readonly TunnelDesiredRecord[];
45
+ readonly ok: boolean;
46
+ }>;
@@ -0,0 +1,311 @@
1
+ // BB-681 Task 3 — `PortsTransport.reconcile` for frp: write the complete
2
+ // desired proxy list to the ONE file this addon owns (`routes.json`), then
3
+ // drive the local admin API (reload, then poll status) until every intended
4
+ // proxy is confirmed running/absent. Desired intent stays `pending` until
5
+ // that full confirmation (spec) — a reload returning HTTP 200 is never
6
+ // treated as success by itself.
7
+ import { createHash, randomUUID } from 'node:crypto';
8
+ import { upsertRecord } from '@bakers-build/addon-ports/transport';
9
+ import { directOrigin, toFrpProxy } from './frp.js';
10
+ const DIRECT_PROBE_TIMEOUT_MS = 1500;
11
+ function flattenStatus(body) {
12
+ const byName = new Map();
13
+ for (const rows of Object.values(body)) {
14
+ for (const row of rows ?? [])
15
+ byName.set(row.name, row.status);
16
+ }
17
+ return byName;
18
+ }
19
+ /** Deterministic fingerprint of (identity, desired proxy set) — the
20
+ * ownership marker written into routes.json alongside the content it
21
+ * describes, so a later reconcile can tell "this generation" apart from a
22
+ * foreign file without needing a lock held across process restarts.
23
+ * ponytail: content hash, not a counter — simplest thing that is stable
24
+ * for an unchanged desired set and changes whenever it doesn't. */
25
+ export function computeGeneration(identity, proxies) {
26
+ const sorted = [...proxies].sort((a, b) => a.name.localeCompare(b.name));
27
+ return createHash('sha256')
28
+ .update(`${identity}\n${JSON.stringify(sorted)}`)
29
+ .digest('hex')
30
+ .slice(0, 16);
31
+ }
32
+ function basicAuthHeader(user, password) {
33
+ return `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`;
34
+ }
35
+ async function readRoutesFile(path, deps) {
36
+ let raw;
37
+ try {
38
+ raw = await deps.readFile(path);
39
+ }
40
+ catch (err) {
41
+ const code = err.code;
42
+ if (code === 'ENOENT')
43
+ return { kind: 'absent' };
44
+ throw err;
45
+ }
46
+ let parsed;
47
+ try {
48
+ parsed = JSON.parse(raw);
49
+ }
50
+ catch {
51
+ return { kind: 'malformed' };
52
+ }
53
+ const proxies = parsed?.proxies;
54
+ if (!Array.isArray(proxies))
55
+ return { kind: 'malformed' };
56
+ // Exactly what this addon writes after its last route is gone: nothing to
57
+ // protect, so a new identity (e.g. after a zone change) may take it over.
58
+ if (proxies.length === 0 && Object.keys(parsed).length === 1) {
59
+ return { kind: 'absent' };
60
+ }
61
+ return { kind: 'ok', proxies: proxies };
62
+ }
63
+ async function writeRoutesFile(path, dir, content, deps) {
64
+ await deps.mkdir(dir);
65
+ const tmp = `${path}.${randomUUID()}.tmp`;
66
+ await deps.writeFile(tmp, `${JSON.stringify(content, null, 2)}\n`, 0o600);
67
+ await deps.rename(tmp, path);
68
+ }
69
+ function lastAcknowledgedGeneration(records) {
70
+ for (const r of records) {
71
+ const g = r.data?.generation;
72
+ if (typeof g === 'string')
73
+ return g;
74
+ }
75
+ return undefined;
76
+ }
77
+ export async function reconcileFrp(records, cfg, deps, context) {
78
+ let next = records;
79
+ let ok = true;
80
+ const active = next.filter((r) => r.desired === 'present');
81
+ // A tombstone that never had a route written (no `data.generation` ever
82
+ // recorded) has nothing to confirm removed — drop it immediately, same as
83
+ // `addon-ports-cloudflare`'s "no-op absent" handling.
84
+ const noOpAbsentKeys = new Set(next
85
+ .filter((r) => r.desired === 'absent' && typeof r.data?.generation !== 'string')
86
+ .map((r) => `${r.worktree}:${r.key}`));
87
+ next = next.filter((r) => !(r.desired === 'absent' && noOpAbsentKeys.has(`${r.worktree}:${r.key}`)));
88
+ const absentWithRoute = next.filter((r) => r.desired === 'absent');
89
+ if (active.length === 0 && absentWithRoute.length === 0) {
90
+ // Nothing to reconcile (every tombstone was a no-op) — no file write,
91
+ // no reload, no poll.
92
+ return { records: next, ok: true };
93
+ }
94
+ // 1) Translate every active record; a bad candidate (e.g. an unverified
95
+ // HTTPS origin) is excluded and marked 'error' — siblings still ride along
96
+ // (never truncate the whole file for one bad record).
97
+ const proxies = [];
98
+ const proxyByKey = new Map();
99
+ for (const record of active) {
100
+ try {
101
+ // No fallback to the router path: a host that cannot reach container
102
+ // IPs (Docker Desktop) gets an error instead of a dead route.
103
+ const direct = directOrigin(record);
104
+ if (direct &&
105
+ !(await deps.canConnect(direct.host, direct.port, DIRECT_PROBE_TIMEOUT_MS))) {
106
+ throw new Error(`${record.hostname}: direct container routing needs a host that reaches Docker container IPs (${direct.host}:${direct.port} is unreachable) — on macOS use OrbStack`);
107
+ }
108
+ const proxy = toFrpProxy(record);
109
+ proxies.push(proxy);
110
+ proxyByKey.set(`${record.worktree}:${record.key}`, proxy);
111
+ }
112
+ catch (err) {
113
+ ok = false;
114
+ next = upsertRecord(next, {
115
+ ...record,
116
+ applied: 'error',
117
+ error: err instanceof Error ? err.message : String(err),
118
+ });
119
+ }
120
+ }
121
+ if (active.length > 0 && proxies.length === 0) {
122
+ // Every candidate failed translation: nothing valid to submit. Preserve
123
+ // whatever routes.json currently holds — do not write an empty file.
124
+ return { records: next, ok };
125
+ }
126
+ const desiredGeneration = computeGeneration(cfg.identity, proxies);
127
+ const lastGeneration = lastAcknowledgedGeneration(records);
128
+ const dir = cfg.routesPath.slice(0, cfg.routesPath.lastIndexOf('/'));
129
+ const existing = await readRoutesFile(cfg.routesPath, deps);
130
+ // The file carries no identity/generation of its own (C3) — its content
131
+ // hash, folded with THIS client's identity, stands in for the stored
132
+ // fingerprint. A foreign file (wrong identity, or any content this client
133
+ // never wrote) hashes to neither the desired nor the last-acknowledged
134
+ // generation and is refused exactly as before. Unparseable/`proxies`-less
135
+ // content is ALWAYS foreign (N4) — never folded into that hash comparison,
136
+ // where it could coincidentally match a legitimately empty generation.
137
+ const existingGeneration = existing.kind === 'ok'
138
+ ? computeGeneration(cfg.identity, existing.proxies)
139
+ : undefined;
140
+ const isForeign = existing.kind === 'malformed' ||
141
+ (existing.kind === 'ok' &&
142
+ existingGeneration !== desiredGeneration &&
143
+ existingGeneration !== lastGeneration);
144
+ if (isForeign) {
145
+ // Foreign file (or one this client didn't write, or doesn't recognise
146
+ // from the last acknowledged/current generation) — refuse to clobber it.
147
+ ok = false;
148
+ const okKeys = new Set(proxyByKey.keys());
149
+ for (const record of active) {
150
+ const key = `${record.worktree}:${record.key}`;
151
+ if (!okKeys.has(key))
152
+ continue;
153
+ next = upsertRecord(next, {
154
+ ...record,
155
+ applied: 'error',
156
+ error: `${cfg.routesPath} does not match this client's identity/generation — refusing to overwrite a file it may not own`,
157
+ });
158
+ }
159
+ return { records: next, ok };
160
+ }
161
+ // 2) Write the complete desired set atomically (temp file + rename, 0600
162
+ // — same atomic-write idiom as `tunnel-state.ts`'s `writeMachineTunnelState`).
163
+ try {
164
+ await writeRoutesFile(cfg.routesPath, dir, { proxies }, deps);
165
+ }
166
+ catch (err) {
167
+ ok = false;
168
+ const message = err instanceof Error ? err.message : String(err);
169
+ for (const record of active) {
170
+ if (!proxyByKey.has(`${record.worktree}:${record.key}`))
171
+ continue;
172
+ next = upsertRecord(next, {
173
+ ...record,
174
+ applied: 'pending',
175
+ error: message,
176
+ });
177
+ }
178
+ return { records: next, ok };
179
+ }
180
+ // The file write succeeded: every submitted record carries this
181
+ // generation from now on, and stays `pending` until status-confirmed.
182
+ for (const record of active) {
183
+ if (!proxyByKey.has(`${record.worktree}:${record.key}`))
184
+ continue;
185
+ next = upsertRecord(next, {
186
+ ...record,
187
+ applied: 'pending',
188
+ error: undefined,
189
+ data: { generation: desiredGeneration },
190
+ });
191
+ }
192
+ // BB-681 review M5: an unhandled checkpoint failure used to throw
193
+ // straight through `bringUp` into an unhandled rejection at the CLI/inbox
194
+ // entry point, instead of reporting a normal failed-record result — the
195
+ // routes.json write above already succeeded, so the intent is durable on
196
+ // disk; only the durable ACKNOWLEDGEMENT of it failed here, same failure
197
+ // class as Quick's own checkpoint-failure handling.
198
+ try {
199
+ await context.checkpoint(next);
200
+ }
201
+ catch (err) {
202
+ ok = false;
203
+ const message = err instanceof Error ? err.message : String(err);
204
+ for (const record of active) {
205
+ if (!proxyByKey.has(`${record.worktree}:${record.key}`))
206
+ continue;
207
+ next = upsertRecord(next, {
208
+ ...record,
209
+ applied: 'error',
210
+ error: message,
211
+ });
212
+ }
213
+ return { records: next, ok };
214
+ }
215
+ // 3) Reload, then poll `/api/status` with a bounded deadline. A reload
216
+ // HTTP 200 is NOT success by itself — only a subsequent status poll that
217
+ // confirms every intended proxy running/absent marks intent applied.
218
+ const authHeader = basicAuthHeader(cfg.adminUser, cfg.adminPassword);
219
+ try {
220
+ const res = await deps.fetch(`${cfg.adminBase}/api/reload?strictConfig=true`, {
221
+ method: 'GET',
222
+ headers: { authorization: authHeader },
223
+ });
224
+ if (!res.ok)
225
+ throw new Error(`reload failed: HTTP ${res.status}`);
226
+ }
227
+ catch (err) {
228
+ ok = false;
229
+ const message = err instanceof Error ? err.message : String(err);
230
+ for (const record of active) {
231
+ if (!proxyByKey.has(`${record.worktree}:${record.key}`))
232
+ continue;
233
+ next = upsertRecord(next, {
234
+ ...record,
235
+ applied: 'pending',
236
+ error: message,
237
+ });
238
+ }
239
+ return { records: next, ok };
240
+ }
241
+ const pollTimeoutMs = cfg.pollTimeoutMs ?? 10_000;
242
+ const pollIntervalMs = cfg.pollIntervalMs ?? 500;
243
+ const deadline = deps.now() + pollTimeoutMs;
244
+ const wantRunningKeys = new Set(proxyByKey.keys());
245
+ const wantGoneNames = new Set(absentWithRoute.map((r) => `${r.ticket}-${r.key}`));
246
+ let statusByName = new Map();
247
+ for (;;) {
248
+ try {
249
+ const res = await deps.fetch(`${cfg.adminBase}/api/status`, {
250
+ method: 'GET',
251
+ headers: { authorization: authHeader },
252
+ });
253
+ if (res.ok)
254
+ statusByName = flattenStatus((await res.json()));
255
+ }
256
+ catch {
257
+ // A transient status-probe failure just means "not confirmed yet" —
258
+ // the bounded loop below decides when to give up, this never throws.
259
+ }
260
+ const allRunning = [...wantRunningKeys].every((key) => {
261
+ const proxy = proxyByKey.get(key);
262
+ return proxy !== undefined && statusByName.get(proxy.name) === 'running';
263
+ });
264
+ const allGone = [...wantGoneNames].every((name) => !statusByName.has(name));
265
+ if (allRunning && allGone)
266
+ break;
267
+ if (deps.now() >= deadline)
268
+ break;
269
+ await deps.delay(pollIntervalMs);
270
+ }
271
+ for (const record of active) {
272
+ const key = `${record.worktree}:${record.key}`;
273
+ const proxy = proxyByKey.get(key);
274
+ if (!proxy)
275
+ continue;
276
+ if (statusByName.get(proxy.name) === 'running') {
277
+ next = upsertRecord(next, {
278
+ ...record,
279
+ applied: 'applied',
280
+ error: undefined,
281
+ url: `https://${record.hostname}`,
282
+ data: { generation: desiredGeneration },
283
+ });
284
+ }
285
+ else {
286
+ ok = false;
287
+ next = upsertRecord(next, {
288
+ ...record,
289
+ applied: 'pending',
290
+ error: 'proxy not confirmed running within deadline',
291
+ data: { generation: desiredGeneration },
292
+ });
293
+ }
294
+ }
295
+ for (const record of absentWithRoute) {
296
+ const name = `${record.ticket}-${record.key}`;
297
+ if (!statusByName.has(name)) {
298
+ // Confirmed gone — clear the tombstone entirely.
299
+ next = next.filter((r) => !(r.worktree === record.worktree && r.key === record.key));
300
+ }
301
+ else {
302
+ ok = false;
303
+ next = upsertRecord(next, {
304
+ ...record,
305
+ applied: 'pending',
306
+ error: 'proxy still reported running — removal not yet confirmed',
307
+ });
308
+ }
309
+ }
310
+ return { records: next, ok };
311
+ }
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-ports-frp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-next.38",
4
+ "description": "bakers.build ports transport: a self-hosted frp (frpc) client over an operator-owned config, reconciled via its local admin API. Implements @bakers-build/addon-ports's PortsTransport contract.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./dist/src/index.js"
9
+ },
10
+ "files": [
11
+ "dist/src"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://git.key-tec.de/bakers-build/bakers-build.git",
19
+ "directory": "bakers-cli/addons/ports-frp"
20
+ },
21
+ "peerDependencies": {
22
+ "@bakers-build/addon-ports": "^0.1.0-next.38",
23
+ "@bakers-build/core": "^0.1.0-next.38"
24
+ }
25
+ }