-
-
- {#if title !== undefined}
- {title}
- {:else if childTitle !== undefined}
- {@render childTitle()}
- {/if}
+ {#if onclick !== undefined}
+
+
+
+ {#if title !== undefined}
+ {title}
+ {:else if childTitle !== undefined}
+ {@render childTitle()}
+ {/if}
+
+
+
+ {#if value !== undefined}
+ {value}
+ {:else if children !== undefined}
+ {@render children()}
+ {/if}
+
+
+ {:else}
+
+
+
+ {#if title !== undefined}
+ {title}
+ {:else if childTitle !== undefined}
+ {@render childTitle()}
+ {/if}
+
+
+
+ {#if value !== undefined}
+ {value}
+ {:else if children !== undefined}
+ {@render children()}
+ {/if}
+
-
-
- {#if value !== undefined}
- {value}
- {:else if children !== undefined}
- {@render children()}
- {/if}
-
-
+ {/if}
{#if childBottom}
{@render childBottom()}
{/if}
diff --git a/src/lib/cards/node/NodeExpiresAt.svelte b/src/lib/cards/node/NodeExpiresAt.svelte
index a04b449..d44d1c8 100644
--- a/src/lib/cards/node/NodeExpiresAt.svelte
+++ b/src/lib/cards/node/NodeExpiresAt.svelte
@@ -14,11 +14,12 @@
let { node, loading = $bindable(false) }: NodeExpiresAtProps = $props()
- let diff = $state(getTimeDifference(getTime(node.expiry)));
+ const getDiff = () => getTimeDifference(getTime(node.expiry))
+ let diff = $state(getDiff());
onMount(() => {
const interval = setInterval(() => {
- diff = getTimeDifference(getTime(node.expiry));
+ diff = getDiff();
}, 1000);
return () => {
diff --git a/src/lib/cards/node/NodeLastSeen.svelte b/src/lib/cards/node/NodeLastSeen.svelte
index 8bc9e85..8c1542b 100644
--- a/src/lib/cards/node/NodeLastSeen.svelte
+++ b/src/lib/cards/node/NodeLastSeen.svelte
@@ -9,11 +9,12 @@
}
let { node }: NodeLastSeenProps = $props()
- let lastSeen = $state(getTimeDifferenceMessage(getTime(node.lastSeen)));
+ const getLastSeen = () => getTimeDifferenceMessage(getTime(node.lastSeen))
+ let lastSeen = $state(getLastSeen());
onMount(() => {
const interval = setInterval(() => {
- lastSeen = getTimeDifferenceMessage(getTime(node.lastSeen));
+ lastSeen = getLastSeen();
}, 1000);
return () => {
clearInterval(interval);
diff --git a/src/lib/cards/preauth/PreAuthKeyDetails.svelte b/src/lib/cards/preauth/PreAuthKeyDetails.svelte
index 5b78ad9..d603d07 100644
--- a/src/lib/cards/preauth/PreAuthKeyDetails.svelte
+++ b/src/lib/cards/preauth/PreAuthKeyDetails.svelte
@@ -9,7 +9,8 @@
}
let { preAuthKey }: PreAuthKeyDetailsProps = $props()
- let pakIsExpired = $state(isExpired(preAuthKey))
+ const getPakIsExpired = () => isExpired(preAuthKey)
+ let pakIsExpired = $state(getPakIsExpired())
const ownershipLabel = $derived(preAuthKey.user ? 'User:' : 'Tags:')
const ownershipValue = $derived(preAuthKey.user ? preAuthKey.user.name : preAuthKey.aclTags.join(', '))
@@ -19,7 +20,7 @@
onMount(()=>{
const interval = setInterval(() => {
- pakIsExpired = isExpired(preAuthKey)
+ pakIsExpired = getPakIsExpired()
}, 1000)
return () => {
diff --git a/src/lib/cards/user/UserListPreAuthKey.svelte b/src/lib/cards/user/UserListPreAuthKey.svelte
index f941481..0e7c0a3 100644
--- a/src/lib/cards/user/UserListPreAuthKey.svelte
+++ b/src/lib/cards/user/UserListPreAuthKey.svelte
@@ -12,7 +12,8 @@
let { preAuthKey }: UserListPreAuthKeyProps = $props()
const toastStore = getToastStore();
- let pakIsExpired = $state(isExpired(preAuthKey))
+ const getPakIsExpired = () => isExpired(preAuthKey)
+ let pakIsExpired = $state(getPakIsExpired())
function isExpired(preAuthKey: PreAuthKey): boolean {
return new Date() > new Date(preAuthKey.expiration);
@@ -20,7 +21,7 @@
onMount(()=>{
const interval = setInterval(() => {
- pakIsExpired = isExpired(preAuthKey)
+ pakIsExpired = getPakIsExpired()
}, 1000)
return () => {
diff --git a/src/lib/common/acl.svelte.ts b/src/lib/common/acl.svelte.ts
index 4bb2569..dfc1b5a 100644
--- a/src/lib/common/acl.svelte.ts
+++ b/src/lib/common/acl.svelte.ts
@@ -1,9 +1,31 @@
import JWCC from 'json5'
-import { isValidCIDR, isValidIP, toastError, toastSuccess } from "$lib/common/funcs"
-import { setPolicy } from './api'
-import type { ToastStore } from '@skeletonlabs/skeleton'
-import { debug } from './debug'
import { parsePolicyDocument, serialisePolicyDocument, type LegacyPolicySection } from './policy-document'
+import type { PolicyGrant, PolicyNodeAttr, PolicyPosture, PolicySshTest, PolicyTest } from './policy-types'
+import {
+ addPrefix,
+ createGroupInPolicy,
+ createHostInPolicy,
+ createTagInPolicy,
+ deleteGroupFromPolicy,
+ deleteHostFromPolicy,
+ deleteTagFromPolicy,
+ getPolicyDstHost,
+ getPolicyDstPorts,
+ getPrefix,
+ normalizePrefix,
+ renameGroupInPolicy,
+ renameHostInPolicy,
+ renameTagInPolicy,
+ setGroupMembersInPolicy,
+ setHostInPolicy,
+ setTagOwnersInPolicy,
+ stripPrefix,
+ validateGroupName,
+ validateHostName,
+ validateHostValue,
+ validateTagName,
+ type PrefixType,
+} from './policy-domain'
export type TagOwners = string[]
export type TagOwnersTyped = { users: string[], groups: string[], tags: string[] }
@@ -55,19 +77,14 @@ export type ACL = {
hosts: AclHosts, // keys are DNS-style hostnames
acls: AclPolicies,
ssh?: AclSshRules,
+ grants?: PolicyGrant[],
+ nodeAttrs?: PolicyNodeAttr[],
+ tests?: PolicyTest[],
+ sshTests?: PolicySshTest[],
+ postures?: PolicyPosture[],
+ randomizeClientPort?: boolean,
}
-export type PrefixType = "group" | "tag"
-
-const Prefixes: Record
= {
- "group": "group:",
- "tag": "tag:",
-}
-
-const RegexGroupName = /^[a-z0-9-\.]+$/
-const RegexTagName = /^[^\s:]+$/
-const RegexHostName = /^[a-z0-9-\.]+$/
-
export class ACLBuilder implements ACL {
#policyRaw: Record | undefined
#unsupportedPolicyFields: string[]
@@ -77,6 +94,12 @@ export class ACLBuilder implements ACL {
hosts = $state({})
acls = $state([])
ssh = $state(undefined)
+ grants = $state(undefined)
+ nodeAttrs = $state(undefined)
+ tests = $state(undefined)
+ sshTests = $state(undefined)
+ postures = $state(undefined)
+ randomizeClientPort = $state(undefined)
constructor(
groups: AclGroups,
@@ -84,6 +107,12 @@ export class ACLBuilder implements ACL {
hosts: AclHosts,
acls: AclPolicies,
ssh?: AclSshRules,
+ grants?: PolicyGrant[],
+ nodeAttrs?: PolicyNodeAttr[],
+ tests?: PolicyTest[],
+ sshTests?: PolicySshTest[],
+ postures?: PolicyPosture[],
+ randomizeClientPort?: boolean,
policyRaw?: Record,
unsupportedPolicyFields: string[] = [],
) {
@@ -92,6 +121,12 @@ export class ACLBuilder implements ACL {
this.hosts = hosts
this.acls = acls
this.ssh = ssh
+ this.grants = grants
+ this.nodeAttrs = nodeAttrs
+ this.tests = tests
+ this.sshTests = sshTests
+ this.postures = postures
+ this.randomizeClientPort = randomizeClientPort
this.#policyRaw = policyRaw
this.#unsupportedPolicyFields = unsupportedPolicyFields
}
@@ -103,6 +138,12 @@ export class ACLBuilder implements ACL {
hosts: this.hosts,
acls: this.acls,
ssh: this.ssh,
+ grants: this.grants,
+ nodeAttrs: this.nodeAttrs,
+ tests: this.tests,
+ sshTests: this.sshTests,
+ postures: this.postures,
+ randomizeClientPort: this.randomizeClientPort,
}
if (this.#policyRaw !== undefined) {
@@ -121,7 +162,7 @@ export class ACLBuilder implements ACL {
}
static emptyACL(): ACLBuilder {
- return new ACLBuilder({}, {}, {}, [], [])
+ return new ACLBuilder({}, {}, {}, [], [], [], [], [], [], [], undefined)
}
static defaultACL(): ACLBuilder {
@@ -130,7 +171,7 @@ export class ACLBuilder implements ACL {
action: "accept",
src: ["*"],
dst: ["*:*"],
- }], [])
+ }], [], [], [], [], [], [], undefined)
}
static addPolicyMeta(policy: AclPolicy): boolean {
@@ -140,7 +181,7 @@ export class ACLBuilder implements ACL {
return policy["#ha-meta"] !== undefined
}
- static fromPolicy(acl: ACL | string): ACLBuilder {
+ static fromPolicy(acl: ACL | string | Record): ACLBuilder {
const doc = parsePolicyDocument(acl)
const ssh = doc.legacy.ssh ? [...doc.legacy.ssh as AclSshRules] : []
@@ -150,39 +191,80 @@ export class ACLBuilder implements ACL {
{ ...doc.legacy.hosts },
[...doc.legacy.acls as AclPolicies],
[...ssh],
+ doc.legacy.grants ? [...doc.legacy.grants] : undefined,
+ doc.legacy.nodeAttrs ? [...doc.legacy.nodeAttrs] : undefined,
+ doc.legacy.tests ? [...doc.legacy.tests] : undefined,
+ doc.legacy.sshTests ? [...doc.legacy.sshTests] : undefined,
+ doc.legacy.postures ? [...doc.legacy.postures] : undefined,
+ doc.legacy.randomizeClientPort,
doc.raw,
doc.unsupportedFields,
)
}
+ setGrants(grants: PolicyGrant[] | undefined) {
+ this.grants = grants === undefined ? undefined : [...grants]
+ }
+
+ getGrants(): PolicyGrant[] | undefined {
+ return this.grants
+ }
+
+ setNodeAttrs(nodeAttrs: PolicyNodeAttr[] | undefined) {
+ this.nodeAttrs = nodeAttrs === undefined ? undefined : [...nodeAttrs]
+ }
+
+ getNodeAttrs(): PolicyNodeAttr[] | undefined {
+ return this.nodeAttrs
+ }
+
+ setTests(tests: PolicyTest[] | undefined) {
+ this.tests = tests === undefined ? undefined : [...tests]
+ }
+
+ getTests(): PolicyTest[] | undefined {
+ return this.tests
+ }
+
+ setSshTests(sshTests: PolicySshTest[] | undefined) {
+ this.sshTests = sshTests === undefined ? undefined : [...sshTests]
+ }
+
+ getSshTests(): PolicySshTest[] | undefined {
+ return this.sshTests
+ }
+
+ setPostures(postures: PolicyPosture[] | undefined) {
+ this.postures = postures === undefined ? undefined : [...postures]
+ }
+
+ getPostures(): PolicyPosture[] | undefined {
+ return this.postures
+ }
+
+ setRandomizeClientPort(randomizeClientPort: boolean | undefined) {
+ this.randomizeClientPort = randomizeClientPort
+ }
+
+ getRandomizeClientPort(): boolean | undefined {
+ return this.randomizeClientPort
+ }
+
private static getPrefix(name: string): PrefixType | null {
- for (const [prefixType, prefix] of Object.entries(Prefixes)) {
- if (name.startsWith(prefix)) {
- return prefixType as PrefixType
- }
- }
- return null
+ return getPrefix(name)
}
// remove the group: prefix if it exists
private static stripPrefix(name: string): string {
- const nameLower = name.toLowerCase()
- for (const prefix of Object.values(Prefixes)) {
- if (nameLower.startsWith(prefix)) {
- return name.substring(prefix.length)
- }
- }
- return name
+ return stripPrefix(name)
}
private static addPrefix(name: string, type: PrefixType): string {
- return Prefixes[type] + name
+ return addPrefix(name, type)
}
private static normalizePrefix(name: string, type: PrefixType): { prefixed: string, stripped: string } {
- const stripped = this.stripPrefix(name)
- const prefixed = this.addPrefix(stripped, type)
- return { prefixed, stripped }
+ return normalizePrefix(name, type)
}
static normalizeTag(tag: string): {prefixed: string, stripped: string} {
@@ -195,42 +277,21 @@ export class ACLBuilder implements ACL {
// throws an error if the name is invalid, otherwise returns the normalized group name
static validateGroupName(name: string): string {
- name = this.stripPrefix(name)
- if (name.toLowerCase() !== name) {
- throw new Error("Group name must be lowercase")
- }
- if (!RegexGroupName.test(name)) {
- throw new Error("Group name is limited to: lowercase alphabet, digits, dashes, and periods")
- }
- return name
+ return validateGroupName(name)
}
// tag names can contain anything but spaces
static validateTagName(name: string): string {
- name = this.stripPrefix(name)
- if (!RegexTagName.test(name)) {
- throw new Error("Tag name must contain no spaces")
- }
- return name
+ return validateTagName(name)
}
// host names can contain anything but spaces
static validateHostName(name: string): string {
- name = name.toLowerCase()
- if (!RegexHostName.test(name)) {
- throw new Error("Host name is limited to: lowercase alphabet, digits, dashes, and periods")
- }
- return name
+ return validateHostName(name)
}
static validateHostValue(value: string): string {
- if(isValidIP(value)) {
- return value
- }
- if(isValidCIDR(value)) {
- return value
- }
- throw new Error("Invalid Host IP or CIDR")
+ return validateHostValue(value)
}
// deep clone of current ACL
@@ -253,10 +314,7 @@ export class ACLBuilder implements ACL {
createHost(name: string, cidr: string) {
- if(this.getHostCIDR(name) !== undefined) {
- throw new Error(`host "${name}" already exists`)
- }
- this.setHost(name, cidr)
+ createHostInPolicy(this, name, cidr)
}
getHostCIDR(name: string): string | undefined {
@@ -264,31 +322,11 @@ export class ACLBuilder implements ACL {
}
setHost(name: string, value: string) {
- name = ACLBuilder.validateHostName(name)
- value = ACLBuilder.validateHostValue(value)
- this.hosts[name] = value
+ setHostInPolicy(this, name, value)
}
renameHost(nameOld: string, nameNew: string) {
- nameOld = ACLBuilder.validateHostName(nameOld)
- nameNew = ACLBuilder.validateHostName(nameNew)
- if (this.hosts[nameOld] === undefined) {
- throw new Error(`Host '${nameOld}' does not exist`)
- }
- if (this.hosts[nameNew] !== undefined) {
- throw new Error(`Host '${nameNew}' already exists`)
- }
-
- const hosts: AclHosts = {}
- Object.entries(this.hosts).forEach(([name, value])=>{
- hosts[name === nameOld ? nameNew : name] = value
- })
- this.hosts = hosts
-
- this.acls.forEach(acl => {
- acl.src = acl.src.map(src => (src === nameOld ? nameNew : src))
- acl.dst = acl.dst.map(dst => (ACLBuilder.getPolicyDstHost(dst) === nameOld ? nameNew + ":" + ACLBuilder.getPolicyDstPorts(dst) : dst))
- })
+ renameHostInPolicy(this, nameOld, nameNew)
}
getHostNames(): string[] {
@@ -300,25 +338,7 @@ export class ACLBuilder implements ACL {
}
deleteHost(name: string) {
- if (this.hosts[name] === undefined) {
- throw new Error(`Host '${name}' doesn't exist`)
- }
-
- delete this.hosts[name]
-
- // delete host from ACLs
- for (const acl of this.acls) {
- acl.src = acl.src.filter(s => s !== name)
- acl.dst = acl.dst.filter(d => d !== name)
- }
-
- // remove group from SSH
- if (this.ssh !== undefined){
- for (const ssh of this.ssh) {
- ssh.src = ssh.src.filter(s => s !== name)
- ssh.dst = ssh.dst.filter(d => d !== name)
- }
- }
+ deleteHostFromPolicy(this, name)
}
@@ -336,47 +356,15 @@ export class ACLBuilder implements ACL {
*/
createTag(name: string) {
- name = ACLBuilder.validateTagName(name)
- const { prefixed } = ACLBuilder.normalizePrefix(name, 'tag')
- this.tagOwners[prefixed] = []
+ createTagInPolicy(this, name)
}
renameTag(nameOld: string, nameNew: string) {
- nameNew = ACLBuilder.validateTagName(nameNew)
- const { prefixed: prefixedNew } = ACLBuilder.normalizePrefix(nameNew, 'tag')
- const { stripped: strippedOld, prefixed: prefixedOld } = ACLBuilder.normalizePrefix(nameOld, 'tag')
-
- // if names are equal, don't do anything
- if (prefixedNew === prefixedOld) {
- return
- }
-
- if (this.tagOwners[prefixedOld] === undefined) {
- throw new Error(`Tag '${strippedOld}' doesn't exist`)
- }
-
- const tagOwners: AclTagOwners = {}
- Object.entries(this.tagOwners).forEach(([name, owners]) => {
- tagOwners[name === prefixedOld ? prefixedNew : name] = owners
- })
- this.tagOwners = tagOwners
-
- this.acls.forEach(acl => {
- acl.src = acl.src.map(src => (src === prefixedOld ? prefixedNew : src))
- acl.dst = acl.dst.map(dst => (ACLBuilder.getPolicyDstHost(dst) === prefixedOld ? prefixedNew + ":" + ACLBuilder.getPolicyDstPorts(dst) : dst))
- })
- if (this.ssh !== undefined) {
- for (const rule of this.ssh) {
- rule.src = rule.src.map(src => (src === prefixedOld ? prefixedNew : src))
- rule.dst = rule.dst.map(dst => (dst === prefixedOld ? prefixedNew : dst))
- }
- }
+ renameTagInPolicy(this, nameOld, nameNew)
}
setTagOwners(name: string, owners: TagOwners) {
- const { prefixed } = ACLBuilder.normalizePrefix(name, 'tag')
- const ownersAll = [...owners]
- this.tagOwners[prefixed] = ownersAll
+ setTagOwnersInPolicy(this, name, owners)
}
getTagNames(withPrefix: boolean = false): string[] {
@@ -431,27 +419,7 @@ export class ACLBuilder implements ACL {
}
deleteTag(name: string) {
- const { stripped, prefixed } = ACLBuilder.normalizePrefix(name, 'tag')
-
- if (this.tagOwners[prefixed] === undefined) {
- throw new Error(`Tag '${stripped}' doesn't exist within the ACL`)
- }
-
- // remove tag from ACLs
- for (const acl of this.acls){
- acl.src = acl.src.filter(s => s !== prefixed);
- acl.dst = acl.dst.filter(d => d !== prefixed);
- }
-
- // remove tag from SSH
- if (this.ssh !== undefined){
- for (const ssh of this.ssh) {
- ssh.src = ssh.src.filter(s => s !== prefixed)
- ssh.dst = ssh.dst.filter(d => d !== prefixed)
- }
- }
-
- delete this.tagOwners[prefixed]
+ deleteTagFromPolicy(this, name)
}
@@ -467,58 +435,15 @@ export class ACLBuilder implements ACL {
* deleteGroup(name)
*/
createGroup(name: string) {
- name = ACLBuilder.validateGroupName(name)
- const { stripped, prefixed } = ACLBuilder.normalizePrefix(name, 'group')
-
- if (this.groups[prefixed] !== undefined) {
- throw new Error(`Group '${stripped}' already exists`)
- }
-
- this.groups[prefixed] = []
+ createGroupInPolicy(this, name)
}
renameGroup(nameOld: string, nameNew: string) {
- nameNew = ACLBuilder.validateGroupName(nameNew)
- const { prefixed: prefixedNew } = ACLBuilder.normalizePrefix(nameNew, 'group')
- const { stripped: strippedOld, prefixed: prefixedOld } = ACLBuilder.normalizePrefix(nameOld, 'group')
-
- // if names are equal, don't do anything
- if (prefixedNew === prefixedOld) {
- return
- }
-
- if (this.groups[prefixedOld] === undefined) {
- throw new Error(`Group '${strippedOld}' doesn't exist`)
- }
-
- const groups: AclGroups = {}
- Object.entries(this.groups).forEach(([name, members]) => {
- groups[name === prefixedOld ? prefixedNew : name] = members
- })
- this.groups = groups
-
- this.acls.forEach(acl => {
- acl.src = acl.src.map(src => (src === prefixedOld ? prefixedNew : src))
- acl.dst = acl.dst.map(dst => (ACLBuilder.getPolicyDstHost(dst) === prefixedOld ? prefixedNew + ":" + ACLBuilder.getPolicyDstPorts(dst) : dst))
- })
-
- for (const key in this.tagOwners) {
- this.tagOwners[key] = this.tagOwners[key].map(owner =>
- owner === prefixedOld ? prefixedNew : owner
- )
- }
- if (this.ssh !== undefined) {
- for (const rule of this.ssh) {
- rule.src = rule.src.map(src => (src === prefixedOld ? prefixedNew : src))
- rule.dst = rule.dst.map(src => (src === prefixedOld ? prefixedNew : src))
- }
- }
+ renameGroupInPolicy(this, nameOld, nameNew)
}
setGroupMembers(name: string, members: string[]) {
- const { prefixed } = ACLBuilder.normalizePrefix(name, 'group')
-
- this.groups[prefixed] = [...members]
+ setGroupMembersInPolicy(this, name, members)
}
getGroupByName(name: string): string[] {
@@ -546,32 +471,7 @@ export class ACLBuilder implements ACL {
}
deleteGroup(name: string) {
- const { stripped, prefixed } = ACLBuilder.normalizePrefix(name, 'group')
-
- // verify group's existence
- if (this.groups[prefixed] === undefined) {
- throw new Error(`Group '${stripped}' doesn't exist`)
- }
-
- // remove group from tag owners
- for (const tag of Object.keys(this.tagOwners)) {
- this.tagOwners[tag] = this.tagOwners[tag].filter(t => t !== prefixed)
- }
-
- // remove group from ACLs
- for (const acl of this.acls) {
- acl.src = acl.src.filter(s => s !== prefixed)
- acl.dst = acl.dst.filter(d => d !== prefixed)
- }
-
- // remove group from SSH policies
- if (this.ssh !== undefined){
- for (const ssh of this.ssh) {
- ssh.src = ssh.src.filter(s => s !== prefixed)
- }
- }
-
- delete this.groups[prefixed]
+ deleteGroupFromPolicy(this, name)
}
/*
* POLICIES:
@@ -587,13 +487,11 @@ export class ACLBuilder implements ACL {
*/
public static getPolicyDstHost(dst: string): string {
- const i = dst.lastIndexOf(':')
- return i < 0 ? dst : dst.substring(0, i)
+ return getPolicyDstHost(dst)
}
public static getPolicyDstPorts(dst: string): string {
- const i = dst.lastIndexOf(':')
- return i < 0 ? dst : dst.substring(i+1, dst.length)
+ return getPolicyDstPorts(dst)
}
@@ -765,27 +663,3 @@ export class ACLBuilder implements ACL {
}
}
}
-
-export async function saveConfig(acl: ACLBuilder, ToastStore: ToastStore, loading?: {setLoadingTrue: ()=>void, setLoadingFalse: ()=>void}) {
- if(loading !== undefined){
- loading.setLoadingTrue()
- }
- //loading = true
- try {
- await setPolicy(acl)
- if(ToastStore !== undefined){
- toastSuccess('Saved ACL Configuration', ToastStore)
- }
- } catch(err) {
- if (err instanceof Error){
- if(ToastStore !== undefined){
- toastError('', ToastStore, err)
- }
- }
- debug(err)
- } finally {
- if(loading !== undefined){
- loading.setLoadingFalse()
- }
- }
-}
diff --git a/src/lib/common/api/modify.ts b/src/lib/common/api/modify.ts
index b09db52..c907718 100644
--- a/src/lib/common/api/modify.ts
+++ b/src/lib/common/api/modify.ts
@@ -8,13 +8,16 @@ import type {
} from '$lib/common/types';
import { debug } from '../debug';
import { apiPost, apiPut } from './base';
-import type { ACLBuilder } from '../acl.svelte';
import { API_URL_NODE, API_URL_POLICY, API_URL_PREAUTHKEY, API_URL_USER } from './url';
import { createApiKey } from './create';
import { expireApiKey } from './delete';
import { App } from '$lib/States.svelte';
import { setsEqual } from '../funcs';
+type PolicySerializable = {
+ JSON: (space?: number) => string;
+};
+
export async function renameUser(u: User, nameNew: string): Promise {
const path = `${API_URL_USER}/${u.id}/rename/${nameNew}`;
const { user } = await apiPost(path, undefined);
@@ -88,7 +91,7 @@ export async function disableRoutes(node: Node, ...routes: string[]): Promise(path, {"policy": acl.JSON(4)})
}
diff --git a/src/lib/common/debug.ts b/src/lib/common/debug.ts
index 08d135a..30247e7 100644
--- a/src/lib/common/debug.ts
+++ b/src/lib/common/debug.ts
@@ -1,6 +1,6 @@
import { App } from "$lib/States.svelte";
-export const version = '0.29.2/update0';
+export const version = '0.29.2/update1';
export function debug(...data: unknown[]) {
// output if console debugging is enabled
diff --git a/src/lib/common/policy-builder.test.ts b/src/lib/common/policy-builder.test.ts
new file mode 100644
index 0000000..aca8920
--- /dev/null
+++ b/src/lib/common/policy-builder.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest'
+import JWCC from 'json5'
+import { PolicyBuilder } from '$lib/common/policy-builder'
+
+describe('PolicyBuilder bridge', () => {
+ it('loads and serialises modern policy sections', () => {
+ const source = {
+ groups: { 'group:admins': ['alice'] },
+ tagOwners: { 'tag:prod': ['group:admins'] },
+ hosts: { db: '100.64.0.10' },
+ acls: [{ action: 'accept', src: ['*'], dst: ['*:*'] }],
+ grants: [{ src: ['group:admins'], dst: ['tag:prod'], via: ['tag:relay'] }],
+ nodeAttrs: [{ target: ['tag:prod'], attr: ['funnel'] }],
+ tests: [{ src: 'group:admins' }],
+ sshTests: [{ src: 'group:admins' }],
+ postures: [{ name: 'corp-devices', rules: [] }],
+ randomizeClientPort: true,
+ unknownTopLevel: { keep: true },
+ }
+
+ const builder = PolicyBuilder.fromPolicy(source)
+
+ expect(builder.getGrants()).toEqual(source.grants)
+ expect(builder.getNodeAttrs()).toEqual(source.nodeAttrs)
+ expect(builder.getTests()).toEqual(source.tests)
+ expect(builder.getSshTests()).toEqual(source.sshTests)
+ expect(builder.getPostures()).toEqual(source.postures)
+ expect(builder.getRandomizeClientPort()).toBe(true)
+ expect(builder.hasUnsupportedPolicyFields()).toBe(true)
+ expect(builder.getUnsupportedPolicyFields()).toContain('unknownTopLevel')
+
+ const out = JWCC.parse(builder.JSON(2))
+ expect((out as any).grants).toEqual(source.grants)
+ expect((out as any).nodeAttrs).toEqual(source.nodeAttrs)
+ expect((out as any).tests).toEqual(source.tests)
+ expect((out as any).sshTests).toEqual(source.sshTests)
+ expect((out as any).postures).toEqual(source.postures)
+ expect((out as any).randomizeClientPort).toBe(true)
+ expect((out as any).unknownTopLevel).toEqual({ keep: true })
+ })
+
+ it('updates modern sections through bridge setters', () => {
+ const builder = PolicyBuilder.defaultACL()
+
+ builder.setGrants([{ src: ['group:ops'], dst: ['tag:prod'] }])
+ builder.setNodeAttrs([{ target: ['tag:prod'], attr: ['funnel'] }])
+ builder.setTests([{ src: 'group:ops' }])
+ builder.setSshTests([{ src: 'group:ops' }])
+ builder.setPostures([{ name: 'corp', rules: [] }])
+ builder.setRandomizeClientPort(false)
+
+ expect(builder.getGrants()).toEqual([{ src: ['group:ops'], dst: ['tag:prod'] }])
+ expect(builder.getNodeAttrs()).toEqual([{ target: ['tag:prod'], attr: ['funnel'] }])
+ expect(builder.getTests()).toEqual([{ src: 'group:ops' }])
+ expect(builder.getSshTests()).toEqual([{ src: 'group:ops' }])
+ expect(builder.getPostures()).toEqual([{ name: 'corp', rules: [] }])
+ expect(builder.getRandomizeClientPort()).toBe(false)
+ })
+})
diff --git a/src/lib/common/policy-builder.ts b/src/lib/common/policy-builder.ts
new file mode 100644
index 0000000..6fa469a
--- /dev/null
+++ b/src/lib/common/policy-builder.ts
@@ -0,0 +1,8 @@
+import { PolicyEditor } from './policy-editor.svelte'
+
+// Bridge export for the modern policy model.
+// The underlying implementation remains ACL-compatible while supporting
+// additional 0.29.x policy sections (grants, nodeAttrs, tests, sshTests,
+// postures, randomizeClientPort).
+export const PolicyBuilder = PolicyEditor
+export type PolicyBuilder = PolicyEditor
diff --git a/src/lib/common/policy-document.test.ts b/src/lib/common/policy-document.test.ts
index 895b213..fb0d329 100644
--- a/src/lib/common/policy-document.test.ts
+++ b/src/lib/common/policy-document.test.ts
@@ -22,9 +22,12 @@ describe('policy-document parse/serialise', () => {
const out = serialisePolicyDocument(doc.raw, doc.legacy, 2)
expect(JWCC.parse(out)).toEqual(JWCC.parse(source))
- expect(doc.unsupportedFields).toContain('grants')
- expect(doc.unsupportedFields).toContain('nodeAttrs')
- expect(doc.unsupportedFields).toContain('randomizeClientPort')
+ expect(doc.legacy.grants).toBeDefined()
+ expect(doc.legacy.nodeAttrs).toBeDefined()
+ expect(doc.legacy.randomizeClientPort).toBe(true)
+ expect(doc.unsupportedFields).not.toContain('grants')
+ expect(doc.unsupportedFields).not.toContain('nodeAttrs')
+ expect(doc.unsupportedFields).not.toContain('randomizeClientPort')
})
it('merges edited legacy ACL sections while keeping unknown top-level fields', () => {
diff --git a/src/lib/common/policy-document.ts b/src/lib/common/policy-document.ts
index 9351241..2063965 100644
--- a/src/lib/common/policy-document.ts
+++ b/src/lib/common/policy-document.ts
@@ -1,14 +1,16 @@
import JWCC from 'json5'
-
-type JsonObject = Record
-
-export type LegacyPolicySection = {
- groups: Record
- tagOwners: Record
- hosts: Record
- acls: Array>
- ssh?: Array>
-}
+import type {
+ JsonObject,
+ PolicyGrant,
+ PolicyNodeAttr,
+ PolicyPosture,
+ PolicySections,
+ PolicySshRule,
+ PolicySshTest,
+ PolicyTest,
+} from './policy-types'
+
+export type LegacyPolicySection = PolicySections
export type PolicyDocument = {
raw: JsonObject
@@ -19,6 +21,14 @@ export type PolicyDocument = {
const LEGACY_TOP_LEVEL_KEYS = new Set(['groups', 'tagOwners', 'hosts', 'acls', 'ssh'])
const LEGACY_ACL_RULE_KEYS = new Set(['#ha-meta', 'action', 'proto', 'src', 'dst'])
const LEGACY_SSH_RULE_KEYS = new Set(['action', 'src', 'dst', 'users'])
+const MODERN_TOP_LEVEL_KEYS = new Set([
+ 'grants',
+ 'nodeAttrs',
+ 'tests',
+ 'sshTests',
+ 'postures',
+ 'randomizeClientPort',
+])
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -32,18 +42,18 @@ function asObject(value: unknown, fallback: JsonObject = {}): JsonObject {
return isObject(value) ? value : fallback
}
-function asArray(value: unknown, fallback: Array> = []): Array> {
+function asArray>(value: unknown, fallback: T[] = []): T[] {
if (!Array.isArray(value)) {
return fallback
}
- return value.filter(isObject)
+ return value.filter(isObject) as T[]
}
function collectUnsupportedFields(raw: JsonObject): string[] {
const unsupported = new Set()
for (const key of Object.keys(raw)) {
- if (!LEGACY_TOP_LEVEL_KEYS.has(key)) {
+ if (!LEGACY_TOP_LEVEL_KEYS.has(key) && !MODERN_TOP_LEVEL_KEYS.has(key)) {
unsupported.add(key)
}
}
@@ -89,7 +99,15 @@ export function parsePolicyDocument(input: string | unknown): PolicyDocument {
tagOwners: asObject(parsed.tagOwners) as Record,
hosts: asObject(parsed.hosts) as Record,
acls: asArray(parsed.acls),
- ssh: Array.isArray(parsed.ssh) ? asArray(parsed.ssh) : undefined,
+ ssh: Array.isArray(parsed.ssh) ? asArray(parsed.ssh) : undefined,
+ grants: Array.isArray(parsed.grants) ? asArray(parsed.grants) : undefined,
+ nodeAttrs: Array.isArray(parsed.nodeAttrs) ? asArray(parsed.nodeAttrs) : undefined,
+ tests: Array.isArray(parsed.tests) ? asArray(parsed.tests) : undefined,
+ sshTests: Array.isArray(parsed.sshTests) ? asArray(parsed.sshTests) : undefined,
+ postures: Array.isArray(parsed.postures) ? asArray(parsed.postures) : undefined,
+ randomizeClientPort: typeof parsed.randomizeClientPort === 'boolean'
+ ? parsed.randomizeClientPort
+ : undefined,
}
return {
@@ -112,6 +130,42 @@ export function mergeLegacyPolicyDocument(raw: JsonObject, legacy: LegacyPolicyS
merged.ssh = clone(legacy.ssh)
}
+ if (legacy.grants === undefined) {
+ delete merged.grants
+ } else {
+ merged.grants = clone(legacy.grants)
+ }
+
+ if (legacy.nodeAttrs === undefined) {
+ delete merged.nodeAttrs
+ } else {
+ merged.nodeAttrs = clone(legacy.nodeAttrs)
+ }
+
+ if (legacy.tests === undefined) {
+ delete merged.tests
+ } else {
+ merged.tests = clone(legacy.tests)
+ }
+
+ if (legacy.sshTests === undefined) {
+ delete merged.sshTests
+ } else {
+ merged.sshTests = clone(legacy.sshTests)
+ }
+
+ if (legacy.postures === undefined) {
+ delete merged.postures
+ } else {
+ merged.postures = clone(legacy.postures)
+ }
+
+ if (legacy.randomizeClientPort === undefined) {
+ delete merged.randomizeClientPort
+ } else {
+ merged.randomizeClientPort = legacy.randomizeClientPort
+ }
+
return merged
}
diff --git a/src/lib/common/policy-domain.test.ts b/src/lib/common/policy-domain.test.ts
new file mode 100644
index 0000000..e1b3a6d
--- /dev/null
+++ b/src/lib/common/policy-domain.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from 'vitest'
+import {
+ createGroupInPolicy,
+ createHostInPolicy,
+ createTagInPolicy,
+ deleteGroupFromPolicy,
+ deleteHostFromPolicy,
+ deleteTagFromPolicy,
+ renameGroupInPolicy,
+ renameHostInPolicy,
+ renameTagInPolicy,
+ setGroupMembersInPolicy,
+ setTagOwnersInPolicy,
+ type PolicyDomainState,
+} from '$lib/common/policy-domain'
+
+function makeState(): PolicyDomainState {
+ return {
+ groups: { 'group:admins': ['alice'] },
+ tagOwners: { 'tag:prod': ['group:admins'] },
+ hosts: { db: '100.64.0.10' },
+ acls: [
+ {
+ src: ['group:admins', 'db', 'tag:prod'],
+ dst: ['db:5432', 'tag:prod:443', 'group:admins:*'],
+ },
+ ],
+ ssh: [
+ {
+ src: ['group:admins', 'db', 'tag:prod'],
+ dst: ['db', 'tag:prod'],
+ },
+ ],
+ }
+}
+
+describe('policy-domain extracted mutations', () => {
+ it('renames host and updates ACL/SSH references', () => {
+ const state = makeState()
+ renameHostInPolicy(state, 'db', 'database')
+
+ expect(state.hosts).toEqual({ database: '100.64.0.10' })
+ expect(state.acls[0].src).toContain('database')
+ expect(state.acls[0].dst).toContain('database:5432')
+ // Existing ACLBuilder behaviour keeps SSH references unchanged on host rename.
+ expect(state.ssh?.[0].src).toContain('db')
+ expect(state.ssh?.[0].dst).toContain('db')
+ })
+
+ it('renames tag and updates ACL/SSH references', () => {
+ const state = makeState()
+ renameTagInPolicy(state, 'prod', 'production')
+
+ expect(state.tagOwners['tag:production']).toEqual(['group:admins'])
+ expect(state.tagOwners['tag:prod']).toBeUndefined()
+ expect(state.acls[0].src).toContain('tag:production')
+ expect(state.acls[0].dst).toContain('tag:production:443')
+ expect(state.ssh?.[0].src).toContain('tag:production')
+ expect(state.ssh?.[0].dst).toContain('tag:production')
+ })
+
+ it('renames group and updates references in ACL, tagOwners, and SSH', () => {
+ const state = makeState()
+ renameGroupInPolicy(state, 'admins', 'ops')
+
+ expect(state.groups['group:ops']).toEqual(['alice'])
+ expect(state.groups['group:admins']).toBeUndefined()
+ expect(state.tagOwners['tag:prod']).toContain('group:ops')
+ expect(state.acls[0].src).toContain('group:ops')
+ expect(state.acls[0].dst).toContain('group:ops:*')
+ expect(state.ssh?.[0].src).toContain('group:ops')
+ })
+
+ it('deletes group/tag/host and removes linked ACL/SSH references', () => {
+ const state = makeState()
+ deleteGroupFromPolicy(state, 'admins')
+ deleteTagFromPolicy(state, 'prod')
+ deleteHostFromPolicy(state, 'db')
+
+ expect(state.groups).toEqual({})
+ expect(state.tagOwners).toEqual({})
+ expect(state.hosts).toEqual({})
+ expect(state.acls[0].src).toEqual([])
+ // Existing ACLBuilder behaviour only removes exact destination matches.
+ expect(state.acls[0].dst).toEqual(['db:5432', 'tag:prod:443', 'group:admins:*'])
+ expect(state.ssh?.[0].src).toEqual([])
+ expect(state.ssh?.[0].dst).toEqual([])
+ })
+
+ it('creates and sets group/tag data with normalised keys', () => {
+ const state: PolicyDomainState = {
+ groups: {},
+ tagOwners: {},
+ hosts: {},
+ acls: [],
+ }
+
+ createGroupInPolicy(state, 'new-team')
+ setGroupMembersInPolicy(state, 'new-team', ['bob'])
+ createTagInPolicy(state, 'backend')
+ setTagOwnersInPolicy(state, 'backend', ['group:new-team'])
+ createHostInPolicy(state, 'api', '100.64.0.20')
+
+ expect(state.groups['group:new-team']).toEqual(['bob'])
+ expect(state.tagOwners['tag:backend']).toEqual(['group:new-team'])
+ expect(state.hosts.api).toEqual('100.64.0.20')
+ })
+})
diff --git a/src/lib/common/policy-domain.ts b/src/lib/common/policy-domain.ts
new file mode 100644
index 0000000..0f4ceab
--- /dev/null
+++ b/src/lib/common/policy-domain.ts
@@ -0,0 +1,301 @@
+import { isValidCIDR, isValidIP } from './funcs'
+
+export type PrefixType = 'group' | 'tag'
+
+const PREFIXES: Record = {
+ group: 'group:',
+ tag: 'tag:',
+}
+
+const REGEX_GROUP_NAME = /^[a-z0-9-\.]+$/
+const REGEX_TAG_NAME = /^[^\s:]+$/
+const REGEX_HOST_NAME = /^[a-z0-9-\.]+$/
+
+export type PolicyAclReferenceRule = {
+ src: string[]
+ dst: string[]
+}
+
+export type PolicySshReferenceRule = {
+ src: string[]
+ dst: string[]
+}
+
+export type PolicyDomainState = {
+ groups: Record
+ tagOwners: Record
+ hosts: Record
+ acls: PolicyAclReferenceRule[]
+ ssh?: PolicySshReferenceRule[]
+}
+
+export function getPrefix(name: string): PrefixType | null {
+ for (const [prefixType, prefix] of Object.entries(PREFIXES)) {
+ if (name.startsWith(prefix)) {
+ return prefixType as PrefixType
+ }
+ }
+ return null
+}
+
+export function stripPrefix(name: string): string {
+ const lower = name.toLowerCase()
+ for (const prefix of Object.values(PREFIXES)) {
+ if (lower.startsWith(prefix)) {
+ return name.substring(prefix.length)
+ }
+ }
+ return name
+}
+
+export function addPrefix(name: string, type: PrefixType): string {
+ return PREFIXES[type] + name
+}
+
+export function normalizePrefix(name: string, type: PrefixType): { prefixed: string, stripped: string } {
+ const stripped = stripPrefix(name)
+ const prefixed = addPrefix(stripped, type)
+ return { prefixed, stripped }
+}
+
+export function validateGroupName(name: string): string {
+ name = stripPrefix(name)
+ if (name.toLowerCase() !== name) {
+ throw new Error('Group name must be lowercase')
+ }
+ if (!REGEX_GROUP_NAME.test(name)) {
+ throw new Error('Group name is limited to: lowercase alphabet, digits, dashes, and periods')
+ }
+ return name
+}
+
+export function validateTagName(name: string): string {
+ name = stripPrefix(name)
+ if (!REGEX_TAG_NAME.test(name)) {
+ throw new Error('Tag name must contain no spaces')
+ }
+ return name
+}
+
+export function validateHostName(name: string): string {
+ name = name.toLowerCase()
+ if (!REGEX_HOST_NAME.test(name)) {
+ throw new Error('Host name is limited to: lowercase alphabet, digits, dashes, and periods')
+ }
+ return name
+}
+
+export function validateHostValue(value: string): string {
+ if (isValidIP(value) || isValidCIDR(value)) {
+ return value
+ }
+ throw new Error('Invalid Host IP or CIDR')
+}
+
+export function getPolicyDstHost(dst: string): string {
+ const i = dst.lastIndexOf(':')
+ return i < 0 ? dst : dst.substring(0, i)
+}
+
+export function getPolicyDstPorts(dst: string): string {
+ const i = dst.lastIndexOf(':')
+ return i < 0 ? dst : dst.substring(i + 1, dst.length)
+}
+
+export function createHostInPolicy(state: PolicyDomainState, name: string, cidr: string) {
+ if (state.hosts[name] !== undefined) {
+ throw new Error(`host "${name}" already exists`)
+ }
+ setHostInPolicy(state, name, cidr)
+}
+
+export function setHostInPolicy(state: PolicyDomainState, name: string, value: string) {
+ name = validateHostName(name)
+ value = validateHostValue(value)
+ state.hosts[name] = value
+}
+
+export function renameHostInPolicy(state: PolicyDomainState, nameOld: string, nameNew: string) {
+ nameOld = validateHostName(nameOld)
+ nameNew = validateHostName(nameNew)
+
+ if (state.hosts[nameOld] === undefined) {
+ throw new Error(`Host '${nameOld}' does not exist`)
+ }
+ if (state.hosts[nameNew] !== undefined) {
+ throw new Error(`Host '${nameNew}' already exists`)
+ }
+
+ const hosts: Record = {}
+ Object.entries(state.hosts).forEach(([name, value]) => {
+ hosts[name === nameOld ? nameNew : name] = value
+ })
+ state.hosts = hosts
+
+ state.acls.forEach((acl) => {
+ acl.src = acl.src.map((src) => (src === nameOld ? nameNew : src))
+ acl.dst = acl.dst.map((dst) => (getPolicyDstHost(dst) === nameOld ? `${nameNew}:${getPolicyDstPorts(dst)}` : dst))
+ })
+}
+
+export function deleteHostFromPolicy(state: PolicyDomainState, name: string) {
+ if (state.hosts[name] === undefined) {
+ throw new Error(`Host '${name}' doesn't exist`)
+ }
+
+ delete state.hosts[name]
+
+ for (const acl of state.acls) {
+ acl.src = acl.src.filter((s) => s !== name)
+ acl.dst = acl.dst.filter((d) => d !== name)
+ }
+
+ if (state.ssh !== undefined) {
+ for (const ssh of state.ssh) {
+ ssh.src = ssh.src.filter((s) => s !== name)
+ ssh.dst = ssh.dst.filter((d) => d !== name)
+ }
+ }
+}
+
+export function createTagInPolicy(state: PolicyDomainState, name: string) {
+ name = validateTagName(name)
+ const { prefixed } = normalizePrefix(name, 'tag')
+ state.tagOwners[prefixed] = []
+}
+
+export function renameTagInPolicy(state: PolicyDomainState, nameOld: string, nameNew: string) {
+ nameNew = validateTagName(nameNew)
+ const { prefixed: prefixedNew } = normalizePrefix(nameNew, 'tag')
+ const { stripped: strippedOld, prefixed: prefixedOld } = normalizePrefix(nameOld, 'tag')
+
+ if (prefixedNew === prefixedOld) {
+ return
+ }
+
+ if (state.tagOwners[prefixedOld] === undefined) {
+ throw new Error(`Tag '${strippedOld}' doesn't exist`)
+ }
+
+ const tagOwners: Record = {}
+ Object.entries(state.tagOwners).forEach(([name, owners]) => {
+ tagOwners[name === prefixedOld ? prefixedNew : name] = owners
+ })
+ state.tagOwners = tagOwners
+
+ state.acls.forEach((acl) => {
+ acl.src = acl.src.map((src) => (src === prefixedOld ? prefixedNew : src))
+ acl.dst = acl.dst.map((dst) => (getPolicyDstHost(dst) === prefixedOld ? `${prefixedNew}:${getPolicyDstPorts(dst)}` : dst))
+ })
+
+ if (state.ssh !== undefined) {
+ for (const rule of state.ssh) {
+ rule.src = rule.src.map((src) => (src === prefixedOld ? prefixedNew : src))
+ rule.dst = rule.dst.map((dst) => (dst === prefixedOld ? prefixedNew : dst))
+ }
+ }
+}
+
+export function setTagOwnersInPolicy(state: PolicyDomainState, name: string, owners: string[]) {
+ const { prefixed } = normalizePrefix(name, 'tag')
+ state.tagOwners[prefixed] = [...owners]
+}
+
+export function deleteTagFromPolicy(state: PolicyDomainState, name: string) {
+ const { stripped, prefixed } = normalizePrefix(name, 'tag')
+
+ if (state.tagOwners[prefixed] === undefined) {
+ throw new Error(`Tag '${stripped}' doesn't exist within the ACL`)
+ }
+
+ for (const acl of state.acls) {
+ acl.src = acl.src.filter((s) => s !== prefixed)
+ acl.dst = acl.dst.filter((d) => d !== prefixed)
+ }
+
+ if (state.ssh !== undefined) {
+ for (const ssh of state.ssh) {
+ ssh.src = ssh.src.filter((s) => s !== prefixed)
+ ssh.dst = ssh.dst.filter((d) => d !== prefixed)
+ }
+ }
+
+ delete state.tagOwners[prefixed]
+}
+
+export function createGroupInPolicy(state: PolicyDomainState, name: string) {
+ name = validateGroupName(name)
+ const { stripped, prefixed } = normalizePrefix(name, 'group')
+
+ if (state.groups[prefixed] !== undefined) {
+ throw new Error(`Group '${stripped}' already exists`)
+ }
+
+ state.groups[prefixed] = []
+}
+
+export function renameGroupInPolicy(state: PolicyDomainState, nameOld: string, nameNew: string) {
+ nameNew = validateGroupName(nameNew)
+ const { prefixed: prefixedNew } = normalizePrefix(nameNew, 'group')
+ const { stripped: strippedOld, prefixed: prefixedOld } = normalizePrefix(nameOld, 'group')
+
+ if (prefixedNew === prefixedOld) {
+ return
+ }
+
+ if (state.groups[prefixedOld] === undefined) {
+ throw new Error(`Group '${strippedOld}' doesn't exist`)
+ }
+
+ const groups: Record = {}
+ Object.entries(state.groups).forEach(([name, members]) => {
+ groups[name === prefixedOld ? prefixedNew : name] = members
+ })
+ state.groups = groups
+
+ state.acls.forEach((acl) => {
+ acl.src = acl.src.map((src) => (src === prefixedOld ? prefixedNew : src))
+ acl.dst = acl.dst.map((dst) => (getPolicyDstHost(dst) === prefixedOld ? `${prefixedNew}:${getPolicyDstPorts(dst)}` : dst))
+ })
+
+ for (const key in state.tagOwners) {
+ state.tagOwners[key] = state.tagOwners[key].map((owner) => owner === prefixedOld ? prefixedNew : owner)
+ }
+
+ if (state.ssh !== undefined) {
+ for (const rule of state.ssh) {
+ rule.src = rule.src.map((src) => (src === prefixedOld ? prefixedNew : src))
+ rule.dst = rule.dst.map((src) => (src === prefixedOld ? prefixedNew : src))
+ }
+ }
+}
+
+export function setGroupMembersInPolicy(state: PolicyDomainState, name: string, members: string[]) {
+ const { prefixed } = normalizePrefix(name, 'group')
+ state.groups[prefixed] = [...members]
+}
+
+export function deleteGroupFromPolicy(state: PolicyDomainState, name: string) {
+ const { stripped, prefixed } = normalizePrefix(name, 'group')
+
+ if (state.groups[prefixed] === undefined) {
+ throw new Error(`Group '${stripped}' doesn't exist`)
+ }
+
+ for (const tag of Object.keys(state.tagOwners)) {
+ state.tagOwners[tag] = state.tagOwners[tag].filter((t) => t !== prefixed)
+ }
+
+ for (const acl of state.acls) {
+ acl.src = acl.src.filter((s) => s !== prefixed)
+ acl.dst = acl.dst.filter((d) => d !== prefixed)
+ }
+
+ if (state.ssh !== undefined) {
+ for (const ssh of state.ssh) {
+ ssh.src = ssh.src.filter((s) => s !== prefixed)
+ }
+ }
+
+ delete state.groups[prefixed]
+}
\ No newline at end of file
diff --git a/src/lib/common/policy-editor.svelte.ts b/src/lib/common/policy-editor.svelte.ts
new file mode 100644
index 0000000..58ab2ee
--- /dev/null
+++ b/src/lib/common/policy-editor.svelte.ts
@@ -0,0 +1,15 @@
+import { ACLBuilder } from './acl.svelte'
+
+export class PolicyEditor extends ACLBuilder {
+ static fromPolicy(policy: Parameters[0]): PolicyEditor {
+ return ACLBuilder.fromPolicy(policy) as PolicyEditor
+ }
+
+ static defaultPolicy(): PolicyEditor {
+ return ACLBuilder.defaultACL() as PolicyEditor
+ }
+
+ static emptyPolicy(): PolicyEditor {
+ return ACLBuilder.emptyACL() as PolicyEditor
+ }
+}
diff --git a/src/lib/common/policy-persistence.ts b/src/lib/common/policy-persistence.ts
new file mode 100644
index 0000000..304c628
--- /dev/null
+++ b/src/lib/common/policy-persistence.ts
@@ -0,0 +1,13 @@
+import { getPolicy, setPolicy } from './api'
+
+export type PolicySerializable = {
+ JSON: (space?: number) => string
+}
+
+export async function loadPolicyDocumentText(): Promise {
+ return await getPolicy()
+}
+
+export async function savePolicyDocument(policy: PolicySerializable): Promise {
+ await setPolicy(policy)
+}
diff --git a/src/lib/common/policy-types.ts b/src/lib/common/policy-types.ts
new file mode 100644
index 0000000..8d93f73
--- /dev/null
+++ b/src/lib/common/policy-types.ts
@@ -0,0 +1,53 @@
+export type JsonObject = Record
+
+export type PolicyRuleMeta = {
+ name: string
+ open: boolean
+}
+
+export type PolicyAclRule = {
+ '#ha-meta'?: PolicyRuleMeta
+ action: 'accept'
+ proto?: string
+ src: string[]
+ dst: string[]
+}
+
+export type PolicySshRule = {
+ action: 'accept'
+ src: string[]
+ dst: string[]
+ users: string[]
+}
+
+export type PolicyGrant = {
+ src: string[]
+ dst: string[]
+ ip?: string[]
+ app?: Record
+ via?: string[]
+ srcPosture?: string[]
+}
+
+export type PolicyNodeAttr = {
+ target: string[]
+ attr: string[]
+}
+
+export type PolicyTest = Record
+export type PolicySshTest = Record
+export type PolicyPosture = Record
+
+export type PolicySections = {
+ groups: Record
+ tagOwners: Record
+ hosts: Record
+ acls: PolicyAclRule[]
+ ssh?: PolicySshRule[]
+ grants?: PolicyGrant[]
+ nodeAttrs?: PolicyNodeAttr[]
+ tests?: PolicyTest[]
+ sshTests?: PolicySshTest[]
+ postures?: PolicyPosture[]
+ randomizeClientPort?: boolean
+}
diff --git a/src/lib/parts/NodeTagsIcon.svelte b/src/lib/parts/NodeTagsIcon.svelte
index a64e7e3..42cf10e 100644
--- a/src/lib/parts/NodeTagsIcon.svelte
+++ b/src/lib/parts/NodeTagsIcon.svelte
@@ -10,11 +10,11 @@
};
let { tags, id }: NodeTagsIconProps = $props();
- const popupHover: PopupSettings = {
+ const popupHover = $derived({
event: 'hover',
target: `tags-popup-${id}`,
placement: 'bottom',
- };
+ });
{#if tags.length > 0}
@@ -27,6 +27,6 @@
{/each}