mirror of
https://github.com/tale/headplane.git
synced 2026-08-30 16:59:56 +00:00
feat(ui): structured editor for ACL rules, tags and groups (#608)
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
import { scanHuJson } from "~/utils/node-info";
|
||||
|
||||
// A structured view over the Headscale ACL policy (HuJSON), which Headscale
|
||||
// stores as an opaque string. What Headplane does not model is kept in `extra`.
|
||||
|
||||
// `action` is kept verbatim: rewriting an unknown action into `accept` would
|
||||
// turn a rule we do not understand into one that allows traffic.
|
||||
export interface AclRule {
|
||||
action: string;
|
||||
src: string[];
|
||||
dst: string[];
|
||||
proto?: string;
|
||||
extra: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SshRule {
|
||||
action: string;
|
||||
src: string[];
|
||||
dst: string[];
|
||||
users: string[];
|
||||
checkPeriod?: string;
|
||||
extra: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// The SSH actions the editor offers; anything else is kept and shown as-is.
|
||||
export const KNOWN_SSH_ACTIONS = ["accept", "check"];
|
||||
|
||||
export interface Policy {
|
||||
groups: Record<string, string[]>;
|
||||
tagOwners: Record<string, string[]>;
|
||||
hosts: Record<string, string>;
|
||||
acls: AclRule[];
|
||||
ssh: SshRule[];
|
||||
// Top-level keys Headplane does not model (autoApprovers, nodeAttrs, ...)
|
||||
extra: Record<string, unknown>;
|
||||
// The order the top-level keys appeared in, so serializing keeps it.
|
||||
keyOrder: string[];
|
||||
}
|
||||
|
||||
export type ParseResult =
|
||||
| { ok: true; policy: Policy; hasComments: boolean }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export const EMPTY_POLICY: Policy = {
|
||||
groups: {},
|
||||
tagOwners: {},
|
||||
hosts: {},
|
||||
acls: [],
|
||||
ssh: [],
|
||||
extra: {},
|
||||
keyOrder: [],
|
||||
};
|
||||
|
||||
const KNOWN_KEYS = ["groups", "tagOwners", "hosts", "acls", "ssh"];
|
||||
|
||||
export function parsePolicy(raw: string): ParseResult {
|
||||
if (raw.trim().length === 0) {
|
||||
return { ok: true, policy: structuredClone(EMPTY_POLICY), hasComments: false };
|
||||
}
|
||||
|
||||
const { stripped, hasComments } = scanHuJson(raw);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(stripped);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : "The policy is not valid HuJSON",
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return { ok: false, error: "The policy must be a JSON object" };
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const extra: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (!KNOWN_KEYS.includes(key)) {
|
||||
extra[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
hasComments,
|
||||
policy: {
|
||||
groups: toStringListMap(record.groups),
|
||||
tagOwners: toStringListMap(record.tagOwners),
|
||||
hosts: toStringMap(record.hosts),
|
||||
acls: toAclRules(record.acls),
|
||||
ssh: toSshRules(record.ssh),
|
||||
extra,
|
||||
keyOrder: Object.keys(record),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function serializePolicy(policy: Policy): string {
|
||||
const sections: Record<string, unknown> = {};
|
||||
|
||||
// Insertion order is preserved so an edit does not reshuffle the rest.
|
||||
if (Object.keys(policy.groups).length > 0) sections.groups = policy.groups;
|
||||
if (Object.keys(policy.tagOwners).length > 0) sections.tagOwners = policy.tagOwners;
|
||||
if (Object.keys(policy.hosts).length > 0) sections.hosts = policy.hosts;
|
||||
if (policy.acls.length > 0) sections.acls = policy.acls.map(compactAclRule);
|
||||
if (policy.ssh.length > 0) sections.ssh = policy.ssh.map(compactSshRule);
|
||||
for (const [key, value] of Object.entries(policy.extra)) {
|
||||
sections[key] = value;
|
||||
}
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of policy.keyOrder) {
|
||||
if (key in sections) {
|
||||
out[key] = sections[key];
|
||||
}
|
||||
}
|
||||
// Sections that did not exist before are appended.
|
||||
for (const [key, value] of Object.entries(sections)) {
|
||||
if (!(key in out)) {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return `${format(out, 0)}\n`;
|
||||
}
|
||||
|
||||
// MARK: Catalog helpers
|
||||
|
||||
export function policySources(policy: Policy, users: string[]): string[] {
|
||||
return unique([
|
||||
"*",
|
||||
"autogroup:member",
|
||||
"autogroup:admin",
|
||||
...Object.keys(policy.groups),
|
||||
...Object.keys(policy.tagOwners),
|
||||
...Object.keys(policy.hosts),
|
||||
...users.map(asUserReference),
|
||||
]);
|
||||
}
|
||||
|
||||
// Destinations without their port spec; the rule editor appends it.
|
||||
export function policyDestinations(policy: Policy, users: string[]): string[] {
|
||||
return unique([
|
||||
"*",
|
||||
"autogroup:internet",
|
||||
"autogroup:self",
|
||||
...Object.keys(policy.groups),
|
||||
...Object.keys(policy.tagOwners),
|
||||
...Object.keys(policy.hosts),
|
||||
...users.map(asUserReference),
|
||||
]);
|
||||
}
|
||||
|
||||
// Headscale references users as "name@" in policies.
|
||||
export function asUserReference(user: string): string {
|
||||
return user.endsWith("@") ? user : `${user}@`;
|
||||
}
|
||||
|
||||
// `*`, a single port, a range, or a comma separated list of either.
|
||||
const PORT_SPEC = /^(\*|\d{1,5}(-\d{1,5})?(,\d{1,5}(-\d{1,5})?)*)$/;
|
||||
|
||||
// Headscale splits a destination on its *last* colon, so `fd7a::1:22` is
|
||||
// `fd7a::1` on port 22. The tail only counts as a port when what precedes it is
|
||||
// a destination in its own right, which keeps `fd7a::1` (head `fd7a:`) intact.
|
||||
export function hasPortSpec(destination: string): boolean {
|
||||
const lastColon = destination.lastIndexOf(":");
|
||||
if (lastColon <= 0 || lastColon === destination.length - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!PORT_SPEC.test(destination.slice(lastColon + 1))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isCompleteDestination(destination.slice(0, lastColon));
|
||||
}
|
||||
|
||||
const ALIAS_PREFIXES = ["tag:", "group:", "autogroup:"];
|
||||
|
||||
// Only a prefixed alias or an IPv6 address carries an inner colon.
|
||||
function isCompleteDestination(value: string): boolean {
|
||||
if (value.length === 0 || value.endsWith(":")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ALIAS_PREFIXES.some((prefix) => value.startsWith(prefix)) || !value.includes(":")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Headscale does not accept the bracketed form, but a hand-written policy
|
||||
// may use it and appending a port would only make it worse.
|
||||
if (value.startsWith("[") && value.endsWith("]")) {
|
||||
return isIpv6(value.slice(1, -1));
|
||||
}
|
||||
|
||||
return isIpv6(value);
|
||||
}
|
||||
|
||||
const IPV6_GROUP = /^[0-9a-fA-F]{1,4}$/;
|
||||
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
|
||||
|
||||
// Enough to tell an address or prefix apart from an alias, not a validator.
|
||||
function isIpv6(value: string): boolean {
|
||||
const [address, prefixLength, ...rest] = value.split("/");
|
||||
if (rest.length > 0 || (prefixLength !== undefined && !/^\d{1,3}$/.test(prefixLength))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const halves = address.split("::");
|
||||
if (halves.length > 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const groups = halves.flatMap((half) => (half.length === 0 ? [] : half.split(":")));
|
||||
if (groups.length === 0) {
|
||||
// The unspecified address, `::`.
|
||||
return halves.length === 2;
|
||||
}
|
||||
|
||||
const last = groups[groups.length - 1];
|
||||
const head = IPV4.test(last) ? groups.slice(0, -1) : groups;
|
||||
if (!head.every((group) => IPV6_GROUP.test(group))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// An embedded IPv4 tail fills the last two groups.
|
||||
const width = IPV4.test(last) ? head.length + 2 : groups.length;
|
||||
return halves.length === 2 ? width <= 7 : width === 8;
|
||||
}
|
||||
|
||||
// Headscale rejects a destination without a port, so one gets `:*`.
|
||||
export function withDefaultPort(destination: string): string {
|
||||
const trimmed = destination.trim();
|
||||
if (trimmed.length === 0 || hasPortSpec(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return `${trimmed}:*`;
|
||||
}
|
||||
|
||||
export function groupsForUser(policy: Policy, userName: string): string[] {
|
||||
const reference = asUserReference(userName);
|
||||
return Object.entries(policy.groups)
|
||||
.filter(([, members]) => members.includes(reference) || members.includes(userName))
|
||||
.map(([group]) => group)
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function setUserGroups(policy: Policy, userName: string, groups: string[]): Policy {
|
||||
const reference = asUserReference(userName);
|
||||
const next: Record<string, string[]> = {};
|
||||
|
||||
for (const [group, members] of Object.entries(policy.groups)) {
|
||||
const isMember = members.includes(reference) || members.includes(userName);
|
||||
const shouldBeMember = groups.includes(group);
|
||||
|
||||
if (isMember === shouldBeMember) {
|
||||
// Leave the member list untouched so the policy diff stays minimal.
|
||||
next[group] = members;
|
||||
continue;
|
||||
}
|
||||
|
||||
next[group] = shouldBeMember
|
||||
? [...members, reference]
|
||||
: members.filter((member) => member !== reference && member !== userName);
|
||||
}
|
||||
|
||||
// Groups that don't exist yet are created with this user as the only member.
|
||||
for (const group of groups) {
|
||||
if (!(group in next)) {
|
||||
next[group] = [reference];
|
||||
}
|
||||
}
|
||||
|
||||
return { ...policy, groups: next };
|
||||
}
|
||||
|
||||
// MARK: Validation
|
||||
|
||||
export function isValidGroupName(name: string): boolean {
|
||||
return /^group:[a-z0-9][a-z0-9-]*$/.test(name);
|
||||
}
|
||||
|
||||
export function isValidTagName(name: string): boolean {
|
||||
return /^tag:[a-z0-9][a-z0-9-]*$/.test(name);
|
||||
}
|
||||
|
||||
export function isValidHostName(name: string): boolean {
|
||||
return /^[a-z0-9][a-z0-9-]*$/.test(name);
|
||||
}
|
||||
|
||||
// MARK: Internals
|
||||
|
||||
function toStringListMap(value: unknown): Record<string, string[]> {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const out: Record<string, string[]> = {};
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] = toStringList(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toStringMap(value: unknown): Record<string, string> {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof entry === "string") {
|
||||
out[key] = entry;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toStringList(value: unknown): string[] {
|
||||
if (typeof value === "string") {
|
||||
return [value];
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((entry): entry is string => typeof entry === "string");
|
||||
}
|
||||
|
||||
const ACL_RULE_KEYS = ["action", "src", "dst", "proto"];
|
||||
const SSH_RULE_KEYS = ["action", "src", "dst", "users", "checkPeriod"];
|
||||
|
||||
function toAclRules(value: unknown): AclRule[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.filter((entry): entry is Record<string, unknown> => entry != null && typeof entry === "object")
|
||||
.map((entry) => {
|
||||
const rule: AclRule = {
|
||||
action: typeof entry.action === "string" ? entry.action : "accept",
|
||||
src: toStringList(entry.src),
|
||||
dst: toStringList(entry.dst),
|
||||
extra: extraKeys(entry, ACL_RULE_KEYS),
|
||||
};
|
||||
if (typeof entry.proto === "string" && entry.proto.length > 0) {
|
||||
rule.proto = entry.proto;
|
||||
}
|
||||
return rule;
|
||||
});
|
||||
}
|
||||
|
||||
function toSshRules(value: unknown): SshRule[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.filter((entry): entry is Record<string, unknown> => entry != null && typeof entry === "object")
|
||||
.map((entry) => {
|
||||
const rule: SshRule = {
|
||||
action: typeof entry.action === "string" ? entry.action : "accept",
|
||||
src: toStringList(entry.src),
|
||||
dst: toStringList(entry.dst),
|
||||
users: toStringList(entry.users),
|
||||
extra: extraKeys(entry, SSH_RULE_KEYS),
|
||||
};
|
||||
if (typeof entry.checkPeriod === "string" && entry.checkPeriod.length > 0) {
|
||||
rule.checkPeriod = entry.checkPeriod;
|
||||
}
|
||||
return rule;
|
||||
});
|
||||
}
|
||||
|
||||
// Fields with no editor — `srcPosture`, `acceptEnv` — ride along untouched.
|
||||
function extraKeys(entry: Record<string, unknown>, known: string[]): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(entry)) {
|
||||
if (!known.includes(key)) {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function compactAclRule(rule: AclRule): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { action: rule.action, src: rule.src, dst: rule.dst };
|
||||
if (rule.proto) out.proto = rule.proto;
|
||||
return { ...out, ...rule.extra };
|
||||
}
|
||||
|
||||
function compactSshRule(rule: SshRule): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {
|
||||
action: rule.action,
|
||||
src: rule.src,
|
||||
dst: rule.dst,
|
||||
users: rule.users,
|
||||
};
|
||||
if (rule.checkPeriod) out.checkPeriod = rule.checkPeriod;
|
||||
return { ...out, ...rule.extra };
|
||||
}
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return Array.from(new Set(values.filter((value) => value.length > 0)));
|
||||
}
|
||||
|
||||
// Rules wider than this are broken across multiple lines.
|
||||
const INLINE_WIDTH = 120;
|
||||
|
||||
// Keeps arrays of primitives, and short rule objects, on one line, the way
|
||||
// Tailscale and Headscale policy examples are written.
|
||||
function format(value: unknown, depth: number, allowInline = false): string {
|
||||
const indent = " ".repeat(depth);
|
||||
const inner = " ".repeat(depth + 1);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return "[]";
|
||||
}
|
||||
if (value.every(isPrimitive)) {
|
||||
return `[${value.map((entry) => JSON.stringify(entry)).join(", ")}]`;
|
||||
}
|
||||
// Rules live inside arrays, and those are the objects worth inlining.
|
||||
const entries = value.map((entry) => `${inner}${format(entry, depth + 1, true)}`);
|
||||
return `[\n${entries.join(",\n")}\n${indent}]`;
|
||||
}
|
||||
|
||||
if (value != null && typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 0) {
|
||||
return "{}";
|
||||
}
|
||||
|
||||
const inline = allowInline ? inlineObject(entries) : undefined;
|
||||
if (inline !== undefined && indent.length + inline.length <= INLINE_WIDTH) {
|
||||
return inline;
|
||||
}
|
||||
|
||||
const body = entries.map(
|
||||
([key, entry]) => `${inner}${JSON.stringify(key)}: ${format(entry, depth + 1)}`,
|
||||
);
|
||||
return `{\n${body.join(",\n")}\n${indent}}`;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// Returns undefined when the object has to be expanded.
|
||||
function inlineObject(entries: [string, unknown][]): string | undefined {
|
||||
const parts: string[] = [];
|
||||
for (const [key, value] of entries) {
|
||||
if (isPrimitive(value)) {
|
||||
parts.push(`${JSON.stringify(key)}: ${JSON.stringify(value)}`);
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value) && value.every(isPrimitive)) {
|
||||
parts.push(
|
||||
`${JSON.stringify(key)}: [${value.map((entry) => JSON.stringify(entry)).join(", ")}]`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `{ ${parts.join(", ")} }`;
|
||||
}
|
||||
|
||||
function isPrimitive(value: unknown): boolean {
|
||||
return value === null || typeof value !== "object";
|
||||
}
|
||||
+37
-18
@@ -50,35 +50,52 @@ export function sortNodeTags(nodes: Machine[]): string[] {
|
||||
}
|
||||
|
||||
export function sortAssignableTags(nodes: Machine[], policy?: string): string[] {
|
||||
return Array.from(new Set([...sortNodeTags(nodes), ...extractTagOwnerTags(policy)])).sort();
|
||||
return Array.from(
|
||||
new Set([...sortNodeTags(nodes), ...(extractTagOwnerTags(policy) ?? [])]),
|
||||
).sort();
|
||||
}
|
||||
|
||||
export function extractTagOwnerTags(policy: string | undefined): string[] {
|
||||
if (!policy) {
|
||||
// The tags declared under `tagOwners`. An empty list means the policy declares
|
||||
// none; `undefined` means the policy could not be read or parsed.
|
||||
export function extractTagOwnerTags(policy: string | undefined): string[] | undefined {
|
||||
if (policy === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (policy.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(policy)) as unknown;
|
||||
if (parsed == null || typeof parsed !== "object" || !("tagOwners" in parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tagOwners = (parsed as { tagOwners?: unknown }).tagOwners;
|
||||
if (tagOwners == null || typeof tagOwners !== "object" || Array.isArray(tagOwners)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.keys(tagOwners)
|
||||
.filter((tag) => tag.startsWith("tag:"))
|
||||
.sort();
|
||||
parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(policy));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (parsed == null || typeof parsed !== "object" || !("tagOwners" in parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tagOwners = (parsed as { tagOwners?: unknown }).tagOwners;
|
||||
if (tagOwners == null || typeof tagOwners !== "object" || Array.isArray(tagOwners)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.keys(tagOwners)
|
||||
.filter((tag) => tag.startsWith("tag:"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function stripJsonCommentsAndTrailingCommas(input: string): string {
|
||||
export function stripJsonCommentsAndTrailingCommas(input: string): string {
|
||||
return scanHuJson(input).stripped;
|
||||
}
|
||||
|
||||
// Strips comments and trailing commas, and reports whether the input carried
|
||||
// comments — a rewrite of the policy drops those, so callers warn first.
|
||||
export function scanHuJson(input: string): { stripped: string; hasComments: boolean } {
|
||||
let output = "";
|
||||
let hasComments = false;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let inLineComment = false;
|
||||
@@ -124,12 +141,14 @@ function stripJsonCommentsAndTrailingCommas(input: string): string {
|
||||
|
||||
if (char === "/" && next === "/") {
|
||||
inLineComment = true;
|
||||
hasComments = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "*") {
|
||||
inBlockComment = true;
|
||||
hasComments = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -137,5 +156,5 @@ function stripJsonCommentsAndTrailingCommas(input: string): string {
|
||||
output += char;
|
||||
}
|
||||
|
||||
return output.replace(/,\s*([}\]])/g, "$1");
|
||||
return { stripped: output.replace(/,\s*([}\]])/g, "$1"), hasComments };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user