Skip to content

Commit 8fcb5c8

Browse files
committed
feat(acl): add HuJSON policy parsing and manipulation utilities
Add comment-json dependency for parsing HuJSON (JSON with comments and trailing commas) used by Headscale ACL policies. Introduces app/utils/acl-editor.ts with typed interfaces for ACL policy entities and pure functions for parsing, serializing, and mutating policy sections (rules, groups, hosts, tags, SSH rules). Comment metadata is preserved through mutations via comment-json's assign helper. Includes 45 unit tests covering parsing, round-trips, comment preservation, trailing comma handling, and all mutation functions.
1 parent c6b6cbc commit 8fcb5c8

5 files changed

Lines changed: 511 additions & 1 deletion

File tree

app/utils/acl-editor.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { assign, parse, stringify } from "comment-json";
2+
3+
export interface AclRule {
4+
action: "accept";
5+
src: string[];
6+
dst: string[];
7+
proto?: string;
8+
}
9+
10+
export interface SshRule {
11+
action: "accept" | "check";
12+
src: string[];
13+
dst: string[];
14+
users: string[];
15+
checkPeriod?: string;
16+
}
17+
18+
export interface AclPolicy {
19+
acls?: AclRule[];
20+
groups?: Record<string, string[]>;
21+
hosts?: Record<string, string>;
22+
tagOwners?: Record<string, string[]>;
23+
ssh?: SshRule[];
24+
autoApprovers?: { routes?: Record<string, string[]>; exitNode?: string[] };
25+
tests?: unknown[];
26+
}
27+
28+
export function parsePolicy(raw: string): AclPolicy {
29+
if (!raw.trim()) return {};
30+
return parse(raw) as AclPolicy;
31+
}
32+
33+
export function stringifyPolicy(policy: AclPolicy): string {
34+
return stringify(policy, null, 2);
35+
}
36+
37+
// comment-json stores comments as Symbols which get lost in spread.
38+
function patch(policy: AclPolicy, changes: Partial<AclPolicy>): AclPolicy {
39+
return assign(assign({} as AclPolicy, policy), changes) as AclPolicy;
40+
}
41+
42+
// Generic array operations on a policy field
43+
type ArrayField = "acls" | "ssh";
44+
45+
function appendTo<K extends ArrayField>(
46+
policy: AclPolicy,
47+
key: K,
48+
item: NonNullable<AclPolicy[K]>[number],
49+
): AclPolicy {
50+
return patch(policy, {
51+
[key]: [...((policy[key] as unknown[]) ?? []), item],
52+
} as Partial<AclPolicy>);
53+
}
54+
55+
function removeAt<K extends ArrayField>(policy: AclPolicy, key: K, index: number): AclPolicy {
56+
const arr = [...((policy[key] as unknown[]) ?? [])];
57+
if (index < 0 || index >= arr.length) return policy;
58+
arr.splice(index, 1);
59+
return patch(policy, { [key]: arr } as Partial<AclPolicy>);
60+
}
61+
62+
function replaceAt<K extends ArrayField>(
63+
policy: AclPolicy,
64+
key: K,
65+
index: number,
66+
item: NonNullable<AclPolicy[K]>[number],
67+
): AclPolicy {
68+
const arr = [...((policy[key] as unknown[]) ?? [])];
69+
if (index < 0 || index >= arr.length) return policy;
70+
arr[index] = item;
71+
return patch(policy, { [key]: arr } as Partial<AclPolicy>);
72+
}
73+
74+
// Generic record operations on a policy field
75+
type RecordField = "groups" | "hosts" | "tagOwners";
76+
77+
function setEntry<K extends RecordField>(
78+
policy: AclPolicy,
79+
key: K,
80+
entryKey: string,
81+
value: NonNullable<AclPolicy[K]>[string],
82+
): AclPolicy {
83+
return patch(policy, {
84+
[key]: { ...(policy[key] as Record<string, unknown>), [entryKey]: value },
85+
} as Partial<AclPolicy>);
86+
}
87+
88+
function removeEntry<K extends RecordField>(
89+
policy: AclPolicy,
90+
key: K,
91+
entryKey: string,
92+
): AclPolicy {
93+
const map = { ...(policy[key] as Record<string, unknown>) };
94+
delete map[entryKey];
95+
return patch(policy, { [key]: map } as Partial<AclPolicy>);
96+
}
97+
98+
// Prefix helpers
99+
function groupKey(name: string) {
100+
return name.startsWith("group:") ? name : `group:${name}`;
101+
}
102+
103+
function tagKey(name: string) {
104+
return name.startsWith("tag:") ? name : `tag:${name}`;
105+
}
106+
107+
// ACL rules
108+
export const addAclRule = (p: AclPolicy, rule: AclRule) => appendTo(p, "acls", rule);
109+
export const removeAclRule = (p: AclPolicy, i: number) => removeAt(p, "acls", i);
110+
export const updateAclRule = (p: AclPolicy, i: number, rule: AclRule) =>
111+
replaceAt(p, "acls", i, rule);
112+
113+
// SSH rules
114+
export const addSshRule = (p: AclPolicy, rule: SshRule) => appendTo(p, "ssh", rule);
115+
export const removeSshRule = (p: AclPolicy, i: number) => removeAt(p, "ssh", i);
116+
export const updateSshRule = (p: AclPolicy, i: number, rule: SshRule) =>
117+
replaceAt(p, "ssh", i, rule);
118+
119+
// Groups
120+
export const setGroup = (p: AclPolicy, name: string, members: string[]) =>
121+
setEntry(p, "groups", groupKey(name), members);
122+
export const removeGroup = (p: AclPolicy, name: string) => removeEntry(p, "groups", groupKey(name));
123+
124+
// Hosts
125+
export const setHost = (p: AclPolicy, name: string, addr: string) =>
126+
setEntry(p, "hosts", name, addr);
127+
export const removeHost = (p: AclPolicy, name: string) => removeEntry(p, "hosts", name);
128+
129+
// Tag owners
130+
export const setTagOwner = (p: AclPolicy, tag: string, owners: string[]) =>
131+
setEntry(p, "tagOwners", tagKey(tag), owners);
132+
export const removeTagOwner = (p: AclPolicy, tag: string) =>
133+
removeEntry(p, "tagOwners", tagKey(tag));

nix/package.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ in
3333
inherit (finalAttrs) pname version src;
3434
fetcherVersion = 3;
3535
pnpm = pnpm_10;
36-
hash = "sha256-NGIeboj/2kXuWsmTVl1fv4LgU1VYRdO+qSnNLVuneC8=";
36+
hash = "sha256-OTd6+KxPc0NZyPiof6DNAH+bZouSkKHN5YTPjL1ko1E=";
3737
};
3838

3939
buildPhase = ''

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@uiw/react-codemirror": "4.25.9",
3737
"arktype": "^2.2.0",
3838
"clsx": "^2.1.1",
39+
"comment-json": "^5.0.0",
3940
"drizzle-orm": "1.0.0-beta.21",
4041
"isbot": "5.1.37",
4142
"jose": "6.2.2",

pnpm-lock.yaml

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)