fbi-proxy 1.9.0 → 1.10.0
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/README.md +71 -0
- package/dist/cli.js +772 -29
- package/package.json +16 -10
- package/release/fbi-proxy-linux-arm64 +0 -0
- package/release/fbi-proxy-linux-x64 +0 -0
- package/release/fbi-proxy-macos-arm64 +0 -0
- package/release/fbi-proxy-macos-x64 +0 -0
- package/release/fbi-proxy-windows-arm64.exe +0 -0
- package/release/fbi-proxy-windows-x64.exe +0 -0
- package/rs/fbi-proxy.rs +123 -93
- package/rs/lib.rs +12 -0
- package/rs/routes.rs +976 -0
- package/ts/auth/authConfig.ts +141 -0
- package/ts/auth/caddyfileGen.test.ts +156 -0
- package/ts/auth/caddyfileGen.ts +142 -0
- package/ts/auth/downloadCaddy.test.ts +131 -0
- package/ts/auth/downloadCaddy.ts +213 -0
- package/ts/auth/setupWizard.ts +183 -0
- package/ts/auth/spawnCaddy.ts +125 -0
- package/ts/auth/spawnFbiAuth.ts +43 -0
- package/ts/buildFbiProxy.ts +3 -11
- package/ts/cli.ts +190 -7
- package/ts/dSpawn.ts +4 -9
- package/ts/getProxyFilename.ts +11 -9
- package/ts/routes.test.ts +182 -0
- package/ts/routes.ts +238 -0
|
@@ -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
|
+
});
|