fbi-proxy 1.9.1 → 1.10.1

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.
@@ -0,0 +1,141 @@
1
+ import { homedir } from "node:os";
2
+ import { join, dirname } from "node:path";
3
+ import { existsSync } from "node:fs";
4
+ import { mkdir, writeFile, chmod, readFile } from "node:fs/promises";
5
+ import { randomBytes } from "node:crypto";
6
+
7
+ export type FirebaseSubConfig = {
8
+ projectId: string;
9
+ apiKey?: string;
10
+ authDomain?: string;
11
+ };
12
+
13
+ export type AuthConfigShape = {
14
+ version: 1;
15
+ domain: string;
16
+ cookieDomain: string;
17
+ ssoHost: string;
18
+ provider: "google" | "firebase" | "snolab";
19
+ clientId?: string;
20
+ clientSecret?: string;
21
+ firebase?: FirebaseSubConfig;
22
+ sessionSecret: string;
23
+ allowlist: {
24
+ emails?: string[];
25
+ domains?: string[];
26
+ anySignedIn?: boolean;
27
+ };
28
+ };
29
+
30
+ export function defaultConfigPath(): string {
31
+ return (
32
+ process.env.FBI_AUTH_CONFIG_PATH ??
33
+ join(
34
+ process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"),
35
+ "fbi-proxy",
36
+ "auth.json",
37
+ )
38
+ );
39
+ }
40
+
41
+ export async function readConfigOrNull(
42
+ path = defaultConfigPath(),
43
+ ): Promise<AuthConfigShape | null> {
44
+ if (!existsSync(path)) return null;
45
+ try {
46
+ return JSON.parse(await readFile(path, "utf8")) as AuthConfigShape;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ export async function writeConfig(
53
+ cfg: AuthConfigShape,
54
+ path = defaultConfigPath(),
55
+ ): Promise<void> {
56
+ await mkdir(dirname(path), { recursive: true });
57
+ await writeFile(path, JSON.stringify(cfg, null, 2), "utf8");
58
+ await chmod(path, 0o600);
59
+ }
60
+
61
+ export function configFromEnv(domain: string): AuthConfigShape | null {
62
+ const provider =
63
+ (process.env.FBI_AUTH_PROVIDER as AuthConfigShape["provider"]) ?? "google";
64
+ const clientId = process.env.FBI_AUTH_CLIENT_ID;
65
+ const firebaseProjectId = process.env.FBI_AUTH_FIREBASE_PROJECT_ID;
66
+
67
+ if (provider === "firebase") {
68
+ if (!firebaseProjectId) return null;
69
+ } else {
70
+ if (!clientId) return null;
71
+ }
72
+
73
+ const d = domain.startsWith(".") ? domain.slice(1) : domain;
74
+ return {
75
+ version: 1,
76
+ domain: d,
77
+ cookieDomain: `.${d}`,
78
+ ssoHost: `sso.${d}`,
79
+ provider,
80
+ clientId,
81
+ clientSecret: process.env.FBI_AUTH_CLIENT_SECRET,
82
+ firebase: firebaseProjectId
83
+ ? {
84
+ projectId: firebaseProjectId,
85
+ apiKey: process.env.FBI_AUTH_FIREBASE_API_KEY,
86
+ authDomain: process.env.FBI_AUTH_FIREBASE_AUTH_DOMAIN,
87
+ }
88
+ : undefined,
89
+ sessionSecret:
90
+ process.env.FBI_AUTH_SESSION_SECRET ??
91
+ randomBytes(32).toString("base64url"),
92
+ allowlist: parseAllowlistEnv(),
93
+ };
94
+ }
95
+
96
+ function parseAllowlistEnv(): AuthConfigShape["allowlist"] {
97
+ const emails = process.env.FBI_AUTH_ALLOW_EMAILS?.split(",")
98
+ .map((s) => s.trim())
99
+ .filter(Boolean);
100
+ const domains = process.env.FBI_AUTH_ALLOW_DOMAINS?.split(",")
101
+ .map((s) => s.trim())
102
+ .filter(Boolean);
103
+ const anySignedIn = process.env.FBI_AUTH_ALLOW_ANY === "true";
104
+ if (!emails?.length && !domains?.length && !anySignedIn) {
105
+ return { anySignedIn: true };
106
+ }
107
+ return { emails, domains, anySignedIn };
108
+ }
109
+
110
+ export function helpfulSetupMessage(domain: string, path: string): string {
111
+ return [
112
+ "",
113
+ "fbi-auth requires a config file but none was found.",
114
+ `Expected at: ${path}`,
115
+ "",
116
+ "Quick setup (Phase 1 — manual; setup wizard arrives in Phase 2):",
117
+ "",
118
+ " 1. Create a Google OAuth Web client at https://console.cloud.google.com/apis/credentials",
119
+ ` - Authorized redirect URI: https://sso.${domain}/callback`,
120
+ ` - Authorized JS origin: https://sso.${domain}`,
121
+ "",
122
+ " 2. Either write the config file directly:",
123
+ ` mkdir -p $(dirname ${path}) && cat > ${path} <<EOF`,
124
+ " {",
125
+ ' "version": 1,',
126
+ ` "domain": "${domain}",`,
127
+ ` "cookieDomain": ".${domain}",`,
128
+ ` "ssoHost": "sso.${domain}",`,
129
+ ' "provider": "google",',
130
+ ' "clientId": "<your-client-id>",',
131
+ ' "clientSecret": "<your-client-secret>",',
132
+ ' "sessionSecret": "<32+ random chars, base64url preferred>",',
133
+ ' "allowlist": { "anySignedIn": true }',
134
+ " }",
135
+ " EOF",
136
+ "",
137
+ " 3. Or use env vars (auto-creates the config on first run):",
138
+ ` FBI_AUTH_CLIENT_ID=... FBI_AUTH_CLIENT_SECRET=... bunx fbi-proxy --with-auth --domain ${domain}`,
139
+ "",
140
+ ].join("\n");
141
+ }
@@ -0,0 +1,156 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { generateCaddyfile } from "./caddyfileGen";
3
+
4
+ describe("generateCaddyfile", () => {
5
+ it("emits the canonical fbi.com + auth + tls internal layout", () => {
6
+ const out = generateCaddyfile({
7
+ domain: "fbi.com",
8
+ ssoHost: "sso.fbi.com",
9
+ fbiAuthPort: 2433,
10
+ fbiProxyPort: 2432,
11
+ tlsMode: "internal",
12
+ withAuth: true,
13
+ });
14
+
15
+ expect(out).toMatchInlineSnapshot(`
16
+ "sso.fbi.com {
17
+ reverse_proxy 127.0.0.1:2433
18
+ tls internal
19
+ }
20
+
21
+ *.fbi.com {
22
+ @notauth not path /api/auth/* /login /callback /logout
23
+ forward_auth @notauth 127.0.0.1:2433 {
24
+ uri /api/auth/verify
25
+ copy_headers Remote-User Remote-Email Remote-Name
26
+ header_up X-Forwarded-Host {host}
27
+ header_up X-Forwarded-Uri {uri}
28
+ }
29
+ reverse_proxy 127.0.0.1:2432
30
+ tls internal
31
+ }
32
+ "
33
+ `);
34
+ });
35
+
36
+ it("emits ACME email block + auto TLS for a public domain with --with-auth", () => {
37
+ const out = generateCaddyfile({
38
+ domain: "example.dev",
39
+ ssoHost: "sso.example.dev",
40
+ fbiAuthPort: 2433,
41
+ fbiProxyPort: 2432,
42
+ acmeEmail: "ops@example.dev",
43
+ tlsMode: "auto",
44
+ withAuth: true,
45
+ });
46
+
47
+ expect(out).toMatchInlineSnapshot(`
48
+ "{
49
+ email ops@example.dev
50
+ }
51
+
52
+ sso.example.dev {
53
+ reverse_proxy 127.0.0.1:2433
54
+ }
55
+
56
+ *.example.dev {
57
+ @notauth not path /api/auth/* /login /callback /logout
58
+ forward_auth @notauth 127.0.0.1:2433 {
59
+ uri /api/auth/verify
60
+ copy_headers Remote-User Remote-Email Remote-Name
61
+ header_up X-Forwarded-Host {host}
62
+ header_up X-Forwarded-Uri {uri}
63
+ }
64
+ reverse_proxy 127.0.0.1:2432
65
+ }
66
+ "
67
+ `);
68
+ });
69
+
70
+ it("auto TLS = no tls stanza, no global block when no ACME email", () => {
71
+ const out = generateCaddyfile({
72
+ domain: "example.dev",
73
+ ssoHost: "sso.example.dev",
74
+ fbiAuthPort: 2433,
75
+ fbiProxyPort: 2432,
76
+ tlsMode: "auto",
77
+ withAuth: true,
78
+ });
79
+
80
+ expect(out).not.toContain("tls internal");
81
+ expect(out).not.toMatch(/^\{\s*email/);
82
+ expect(out).toContain("sso.example.dev {");
83
+ expect(out).toContain("*.example.dev {");
84
+ expect(out).toContain("forward_auth @notauth 127.0.0.1:2433");
85
+ expect(out).toContain("reverse_proxy 127.0.0.1:2432");
86
+ });
87
+
88
+ it("--with-caddy without --with-auth: only *.domain block, no forward_auth", () => {
89
+ const out = generateCaddyfile({
90
+ domain: "fbi.com",
91
+ fbiProxyPort: 2432,
92
+ tlsMode: "internal",
93
+ withAuth: false,
94
+ });
95
+
96
+ expect(out).toMatchInlineSnapshot(`
97
+ "*.fbi.com {
98
+ reverse_proxy 127.0.0.1:2432
99
+ tls internal
100
+ }
101
+ "
102
+ `);
103
+ expect(out).not.toContain("forward_auth");
104
+ expect(out).not.toContain("sso.fbi.com");
105
+ });
106
+
107
+ it("--with-caddy standalone, public domain + ACME email", () => {
108
+ const out = generateCaddyfile({
109
+ domain: "example.dev",
110
+ fbiProxyPort: 2432,
111
+ acmeEmail: "ops@example.dev",
112
+ tlsMode: "auto",
113
+ withAuth: false,
114
+ });
115
+
116
+ expect(out).toContain("email ops@example.dev");
117
+ expect(out).toContain("*.example.dev {");
118
+ expect(out).toContain("reverse_proxy 127.0.0.1:2432");
119
+ expect(out).not.toContain("forward_auth");
120
+ expect(out).not.toContain("sso.");
121
+ expect(out).not.toContain("tls internal");
122
+ });
123
+
124
+ it("strips a leading dot from the domain", () => {
125
+ const out = generateCaddyfile({
126
+ domain: ".fbi.com",
127
+ fbiProxyPort: 2432,
128
+ tlsMode: "internal",
129
+ withAuth: false,
130
+ });
131
+ expect(out).toContain("*.fbi.com {");
132
+ expect(out).not.toContain("*..fbi.com");
133
+ });
134
+
135
+ it("throws when withAuth is true but fbiAuthPort is missing", () => {
136
+ expect(() =>
137
+ generateCaddyfile({
138
+ domain: "fbi.com",
139
+ fbiProxyPort: 2432,
140
+ withAuth: true,
141
+ tlsMode: "internal",
142
+ }),
143
+ ).toThrow(/fbiAuthPort/);
144
+ });
145
+
146
+ it("omits the global email block when acmeEmail is empty string", () => {
147
+ const out = generateCaddyfile({
148
+ domain: "example.dev",
149
+ fbiProxyPort: 2432,
150
+ acmeEmail: " ",
151
+ tlsMode: "auto",
152
+ withAuth: false,
153
+ });
154
+ expect(out).not.toMatch(/^\{/);
155
+ });
156
+ });
@@ -0,0 +1,142 @@
1
+ import { homedir } from "node:os";
2
+ import { dirname, join } from "node:path";
3
+ import { mkdir, writeFile, chmod } from "node:fs/promises";
4
+
5
+ export type CaddyfileOpts = {
6
+ /** Apex domain (e.g. "example.dev"). A leading "." is stripped. */
7
+ domain: string;
8
+ /** SSO host (e.g. "sso.example.dev"). Only used when `withAuth` is true. */
9
+ ssoHost?: string;
10
+ /** Local port that fbi-auth listens on. Required when `withAuth` is true. */
11
+ fbiAuthPort?: number;
12
+ /** Local port that fbi-proxy (Rust) listens on. */
13
+ fbiProxyPort: number;
14
+ /** Optional Let's Encrypt account email — emits a global `{ email ... }` block. */
15
+ acmeEmail?: string;
16
+ /**
17
+ * TLS strategy:
18
+ * - "auto" (default): empty stanza — Caddy chooses ACME via Let's Encrypt.
19
+ * - "internal": use Caddy's local CA (`tls internal`). Useful for `.fbi.com` etc.
20
+ */
21
+ tlsMode?: "auto" | "internal";
22
+ /**
23
+ * When true, wires the `forward_auth` directive through to fbi-auth and
24
+ * exposes `<ssoHost>` as its own site. When false, only the wildcard
25
+ * `*.<domain>` site is emitted (plain reverse_proxy to fbi-proxy).
26
+ */
27
+ withAuth?: boolean;
28
+ };
29
+
30
+ /**
31
+ * Generate a Caddyfile suitable for `bunx fbi-proxy --with-caddy [--with-auth]`.
32
+ *
33
+ * The shape (when `withAuth=true`):
34
+ *
35
+ * ```caddyfile
36
+ * {
37
+ * email <acmeEmail>
38
+ * }
39
+ *
40
+ * <ssoHost> {
41
+ * reverse_proxy 127.0.0.1:<fbiAuthPort>
42
+ * <tls-stanza>
43
+ * }
44
+ *
45
+ * *.<domain> {
46
+ * @notauth not path /api/auth/* /login /callback /logout
47
+ * forward_auth @notauth 127.0.0.1:<fbiAuthPort> {
48
+ * uri /api/auth/verify
49
+ * copy_headers Remote-User Remote-Email Remote-Name
50
+ * header_up X-Forwarded-Host {host}
51
+ * header_up X-Forwarded-Uri {uri}
52
+ * }
53
+ * reverse_proxy 127.0.0.1:<fbiProxyPort>
54
+ * <tls-stanza>
55
+ * }
56
+ * ```
57
+ *
58
+ * When `withAuth=false`, only the `*.<domain>` block is emitted and it skips
59
+ * the `forward_auth` directive — i.e. plain TLS termination + reverse proxy.
60
+ */
61
+ export function generateCaddyfile(opts: CaddyfileOpts): string {
62
+ const domain = stripLeadingDot(opts.domain);
63
+ const tlsMode = opts.tlsMode ?? "auto";
64
+ const withAuth = opts.withAuth ?? false;
65
+ const tlsStanza = tlsMode === "internal" ? " tls internal\n" : "";
66
+
67
+ const sections: string[] = [];
68
+
69
+ if (opts.acmeEmail && opts.acmeEmail.trim() !== "") {
70
+ sections.push(`{\n email ${opts.acmeEmail.trim()}\n}`);
71
+ }
72
+
73
+ if (withAuth) {
74
+ const ssoHost = opts.ssoHost ?? `sso.${domain}`;
75
+ const fbiAuthPort = opts.fbiAuthPort;
76
+ if (fbiAuthPort === undefined) {
77
+ throw new Error(
78
+ "generateCaddyfile: fbiAuthPort is required when withAuth is true",
79
+ );
80
+ }
81
+ sections.push(
82
+ `${ssoHost} {\n` +
83
+ ` reverse_proxy 127.0.0.1:${fbiAuthPort}\n` +
84
+ tlsStanza +
85
+ `}`,
86
+ );
87
+
88
+ sections.push(
89
+ `*.${domain} {\n` +
90
+ ` @notauth not path /api/auth/* /login /callback /logout\n` +
91
+ ` forward_auth @notauth 127.0.0.1:${fbiAuthPort} {\n` +
92
+ ` uri /api/auth/verify\n` +
93
+ ` copy_headers Remote-User Remote-Email Remote-Name\n` +
94
+ ` header_up X-Forwarded-Host {host}\n` +
95
+ ` header_up X-Forwarded-Uri {uri}\n` +
96
+ ` }\n` +
97
+ ` reverse_proxy 127.0.0.1:${opts.fbiProxyPort}\n` +
98
+ tlsStanza +
99
+ `}`,
100
+ );
101
+ } else {
102
+ sections.push(
103
+ `*.${domain} {\n` +
104
+ ` reverse_proxy 127.0.0.1:${opts.fbiProxyPort}\n` +
105
+ tlsStanza +
106
+ `}`,
107
+ );
108
+ }
109
+
110
+ return sections.join("\n\n") + "\n";
111
+ }
112
+
113
+ export function defaultCaddyfilePath(): string {
114
+ return (
115
+ process.env.FBI_PROXY_CADDYFILE_PATH ??
116
+ join(
117
+ process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"),
118
+ "fbi-proxy",
119
+ "Caddyfile",
120
+ )
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Render and write the Caddyfile to disk. Returns the rendered string and the
126
+ * absolute path it was written to. Creates parent directories as needed and
127
+ * chmods the file to 0644 (it's not a secret).
128
+ */
129
+ export async function writeCaddyfile(
130
+ opts: CaddyfileOpts,
131
+ path = defaultCaddyfilePath(),
132
+ ): Promise<{ content: string; path: string }> {
133
+ const content = generateCaddyfile(opts);
134
+ await mkdir(dirname(path), { recursive: true });
135
+ await writeFile(path, content, "utf8");
136
+ await chmod(path, 0o644);
137
+ return { content, path };
138
+ }
139
+
140
+ function stripLeadingDot(d: string): string {
141
+ return d.startsWith(".") ? d.slice(1) : d;
142
+ }
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ detectPlatform,
4
+ buildAssetName,
5
+ buildAssetUrl,
6
+ buildChecksumsUrl,
7
+ parseChecksums,
8
+ } from "./downloadCaddy";
9
+
10
+ describe("detectPlatform", () => {
11
+ it("maps linux/x64 → linux/amd64/tar.gz", () => {
12
+ expect(detectPlatform("linux", "x64")).toEqual({
13
+ os: "linux",
14
+ arch: "amd64",
15
+ ext: "tar.gz",
16
+ });
17
+ });
18
+
19
+ it("maps linux/arm64 → linux/arm64/tar.gz", () => {
20
+ expect(detectPlatform("linux", "arm64")).toEqual({
21
+ os: "linux",
22
+ arch: "arm64",
23
+ ext: "tar.gz",
24
+ });
25
+ });
26
+
27
+ it("maps darwin/arm64 → darwin/arm64/tar.gz (Apple Silicon)", () => {
28
+ expect(detectPlatform("darwin", "arm64")).toEqual({
29
+ os: "darwin",
30
+ arch: "arm64",
31
+ ext: "tar.gz",
32
+ });
33
+ });
34
+
35
+ it("maps win32/x64 → windows/amd64/zip", () => {
36
+ expect(detectPlatform("win32", "x64")).toEqual({
37
+ os: "windows",
38
+ arch: "amd64",
39
+ ext: "zip",
40
+ });
41
+ });
42
+
43
+ it("throws on unsupported OS", () => {
44
+ expect(() => detectPlatform("freebsd" as NodeJS.Platform, "x64")).toThrow(
45
+ /Unsupported OS/,
46
+ );
47
+ });
48
+
49
+ it("throws on unsupported arch", () => {
50
+ expect(() => detectPlatform("linux", "mips")).toThrow(/Unsupported arch/);
51
+ });
52
+ });
53
+
54
+ describe("buildAssetName", () => {
55
+ it("matches Caddy's release naming for linux amd64", () => {
56
+ expect(
57
+ buildAssetName("v2.11.3", { os: "linux", arch: "amd64", ext: "tar.gz" }),
58
+ ).toBe("caddy_2.11.3_linux_amd64.tar.gz");
59
+ });
60
+
61
+ it("accepts a version without the v prefix", () => {
62
+ expect(
63
+ buildAssetName("2.11.3", { os: "darwin", arch: "arm64", ext: "tar.gz" }),
64
+ ).toBe("caddy_2.11.3_darwin_arm64.tar.gz");
65
+ });
66
+
67
+ it("uses .zip for windows", () => {
68
+ expect(
69
+ buildAssetName("v2.11.3", { os: "windows", arch: "amd64", ext: "zip" }),
70
+ ).toBe("caddy_2.11.3_windows_amd64.zip");
71
+ });
72
+ });
73
+
74
+ describe("buildAssetUrl", () => {
75
+ it("points at the GitHub Releases asset CDN with the v-prefixed tag", () => {
76
+ expect(buildAssetUrl("v2.11.3", "caddy_2.11.3_linux_amd64.tar.gz")).toBe(
77
+ "https://github.com/caddyserver/caddy/releases/download/v2.11.3/caddy_2.11.3_linux_amd64.tar.gz",
78
+ );
79
+ });
80
+
81
+ it("normalizes a bare version into a v-tag", () => {
82
+ expect(buildAssetUrl("2.11.3", "caddy_2.11.3_linux_amd64.tar.gz")).toBe(
83
+ "https://github.com/caddyserver/caddy/releases/download/v2.11.3/caddy_2.11.3_linux_amd64.tar.gz",
84
+ );
85
+ });
86
+ });
87
+
88
+ describe("buildChecksumsUrl", () => {
89
+ it("uses the `caddy_<v>_checksums.txt` naming Caddy ships", () => {
90
+ expect(buildChecksumsUrl("v2.11.3")).toBe(
91
+ "https://github.com/caddyserver/caddy/releases/download/v2.11.3/caddy_2.11.3_checksums.txt",
92
+ );
93
+ });
94
+ });
95
+
96
+ describe("parseChecksums", () => {
97
+ it("parses the real Caddy checksums.txt shape", () => {
98
+ const fixture = [
99
+ "abc123 caddy_2.11.3_buildable-artifact.tar.gz",
100
+ "def456 caddy_2.11.3_linux_amd64.tar.gz",
101
+ "0123456789abcdef caddy_2.11.3_darwin_arm64.tar.gz",
102
+ ].join("\n");
103
+ const map = parseChecksums(fixture);
104
+ expect(map.size).toBe(3);
105
+ expect(map.get("caddy_2.11.3_linux_amd64.tar.gz")).toBe("def456");
106
+ expect(map.get("caddy_2.11.3_darwin_arm64.tar.gz")).toBe(
107
+ "0123456789abcdef",
108
+ );
109
+ });
110
+
111
+ it("skips blank lines and comments", () => {
112
+ const map = parseChecksums(`
113
+ # this is a comment
114
+
115
+ abc some.tar.gz
116
+
117
+ `);
118
+ expect(map.size).toBe(1);
119
+ expect(map.get("some.tar.gz")).toBe("abc");
120
+ });
121
+
122
+ it("normalizes hex to lowercase", () => {
123
+ const map = parseChecksums("ABCDEF file.tar.gz");
124
+ expect(map.get("file.tar.gz")).toBe("abcdef");
125
+ });
126
+
127
+ it("tolerates the BSD '*filename' marker", () => {
128
+ const map = parseChecksums("abc *file.tar.gz");
129
+ expect(map.get("file.tar.gz")).toBe("abc");
130
+ });
131
+ });