Skip to content

Commit f37d80f

Browse files
authored
waf: named IP lists — lists page, ipInList builder support, list chips (#329)
* waf: named IP lists — lists page, ipInList builder support, tests Console part of SPEC-waf-ip-lists.md (§8): - waf/lists page: NAME|TYPE|ENTRIES|REFERENCED BY|UPDATED table, referenced-by links to each zone's manage page, create/edit modal (name immutable on edit; live per-line entry validation + duplicate flag + count via the new pure helper $lib/waf/lists.ts parseIPListEntries), delete with the server's in-use referent error surfaced verbatim. Writes gated on wafList.set / wafList.delete per the permission pre-flight convention (waf.* does not imply wafList.*). - $lib/waf/expression.ts: in_ip_list / not_in_ip_list ip operators (OperatorMeta.valueKind 'listName'), buildExpression emits the platform macro ipInList(<accessor>, "<name>") (never an invalid or escaped name — the macro's name literal admits no escapes), parseCondition round-trips both polarities at the same fidelity bar as in_cidr, and wafListRefs — the TS twin of api's waflistmacro.go scanner (string-literal/raw/bytes/triple/comment aware; malformed usage carries no ref). Keep in sync with the Go scanner. - Condition builder: the list picker Select is fed by wafList.list, keyed on the project param (refetches on SPA project switch); without wafList.list it renders disabled with the missing-permission hint. Works unchanged for rule expressions and limit filters (same builder). - waf index gains an "IP lists" link + palette action; manage page shows a chip on rules/limits whose expression references a list (client-side wafListRefs), linking to the lists page. - api.d.ts: Api.WafListItem / WafListListResult / WafListType. - mock.ts (dev): session-mutable wafList.* handlers; the seed zone's allow rule now references office-ips so referencedBy and the in-use delete guard are exercisable offline. - tests: unit coverage for build/parse round-trip, scanner literal-skipping, name/entry validation; Playwright specs for lists CRUD, validation gating, permission gating, the builder picker end-to-end (asserts the stored waf.set expression is the unexpanded macro), round-trip into the visual builder, and manage-page chips. Deviations from the spec text: the edit modal's name input is readonly (not disabled) so the value stays legible — immutability + hint intact; the "Lists" link lives on the firewall index page-head while the reference chips live on the manage page, which is where rules/limits actually render. Composes with apiserver#238 (waf-test, server-side CEL compile validation): the console stores and round-trips the UNEXPANDED macro form only — any compile validation must run on the expanded expression server-side. No shared files, no expected merge conflict. Suite: bun lint + bun check clean; bun run test 364 passed. * waf: review fixes — stale-guard + catch on list fetch, save error surface, gate IP-lists entry points - WafConditionBuilder: reset + stale-response guard on the project-keyed wafList.list fetch (out-of-order responses across an SPA project switch can no longer populate another project's names) and a .catch so a network-level failure doesn't escape as an unhandled rejection. - WafListModal: catch network-level save failures so the user sees an error instead of a silently cleared spinner. - /waf: the "IP lists" header button and palette action are now gated on wafList.list (GuardedButton / can()), matching the permission pre-flight convention. - expression.ts: comments documenting the intentional strict-parser vs tolerant-chip-scanner asymmetry and the one scanner behavior that must stay in sync with the Go twin (token-in-literal false positives). Rollout note: depends on the apiserver wafList.* + macro-expansion PR; that PR must expand ipInList(...) BEFORE apiserver#238's CEL compile validation runs (the raw macro form is not valid engine CEL).
1 parent a2ea908 commit f37d80f

16 files changed

Lines changed: 1457 additions & 25 deletions

File tree

src/lib/components/WafConditionBuilder.svelte

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,44 @@
11
<script lang="ts">
22
import { untrack } from 'svelte'
3+
import api from '$lib/api'
4+
import { getPermissionContext } from '$lib/permission'
35
import WafConditionRow from '$lib/components/WafConditionRow.svelte'
46
import { buildGroup, parseExpression } from '$lib/waf/expression'
57
import type { ExpressionSpec, Combinator } from '$lib/waf/expression'
68
79
interface Props {
810
expression?: string // bindable CEL expression — kept in two-way sync with the rows
11+
project?: string // enables the named-IP-list picker (fed by wafList.list)
912
}
1013
11-
let { expression = $bindable('') }: Props = $props()
14+
let { expression = $bindable(''), project = '' }: Props = $props()
15+
16+
const { can } = getPermissionContext()
17+
// Console permission pre-flight: without wafList.list the picker renders
18+
// disabled with a permission hint instead of failing the fetch.
19+
const listsDenied = $derived(!can('wafList.list'))
20+
21+
// The project's named IP lists, feeding the in_ip_list / not_in_ip_list
22+
// value Select. Keyed on `project` (not fetched in onMount) so an SPA
23+
// project switch refetches instead of showing the previous project's lists;
24+
// the cleanup flag drops an out-of-order response from the previous project.
25+
let listNames = $state<string[]>([])
26+
$effect(() => {
27+
const p = project
28+
listNames = []
29+
if (!p || listsDenied) return
30+
let stale = false
31+
api.invoke<Api.WafListListResult>('wafList.list', { project: p }, fetch).then((res) => {
32+
if (stale) return
33+
listNames = (res.result?.items ?? []).map((it) => it.name)
34+
}).catch(() => {
35+
// network-level failure — best-effort picker stays empty (a name an
36+
// existing rule already references remains selectable via listOptions).
37+
})
38+
return () => {
39+
stale = true
40+
}
41+
})
1242
1343
/** A fresh blank condition row. */
1444
function blankCondition (): ExpressionSpec {
@@ -114,7 +144,8 @@
114144
{combinator === 'or' ? 'OR' : 'AND'}
115145
</div>
116146
{/if}
117-
<WafConditionRow bind:condition={conditions[i]} onremove={() => removeCondition(i)} />
147+
<WafConditionRow bind:condition={conditions[i]} onremove={() => removeCondition(i)}
148+
{project} {listNames} {listsDenied} />
118149
</div>
119150
{/each}
120151
</div>

src/lib/components/WafConditionRow.svelte

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,19 @@
1111
parseList
1212
} from '$lib/waf/expression'
1313
import type { ExpressionSpec } from '$lib/waf/expression'
14+
import { validListName } from '$lib/waf/lists'
15+
import { denyTooltip } from '$lib/permission'
1416
1517
interface Props {
1618
condition: ExpressionSpec // bindable structured condition
1719
onremove: () => void // remove this row
1820
removable?: boolean // show the remove button (default true)
21+
project?: string // for the manage-lists hint link
22+
listNames?: string[] // the project's named IP lists (wafList.list)
23+
listsDenied?: boolean // caller lacks wafList.list → picker disabled with a hint
1924
}
2025
21-
let { condition = $bindable(), onremove, removable = true }: Props = $props()
26+
let { condition = $bindable(), onremove, removable = true, project = '', listNames = [], listsDenied = false }: Props = $props()
2227
2328
const fieldMeta = $derived(getField(condition.field))
2429
const fieldType = $derived(fieldMeta?.type ?? 'string')
@@ -36,6 +41,22 @@
3641
!!fieldMeta?.suggestions &&
3742
(condition.operator === 'equals' || condition.operator === 'not_equals')
3843
)
44+
// Named-IP-list membership — the value is a list name picked from the
45+
// project's wafList.list result rather than free text.
46+
const isListName = $derived(
47+
operators.find((o) => o.value === condition.operator)?.valueKind === 'listName'
48+
)
49+
// Keep an already-referenced name selectable even when it's missing from the
50+
// fetched set (e.g. still loading, or the caller can't list) so opening an
51+
// existing rule never silently rewrites its list reference. Only a valid
52+
// list NAME is injected — a leftover value from another operator (e.g. a
53+
// CIDR) must not masquerade as a list.
54+
const listOptions = $derived.by(() => {
55+
const names = [...listNames]
56+
const v = (condition.value ?? '').trim()
57+
if (v && validListName(v) && !names.includes(v)) names.unshift(v)
58+
return names.map((n) => ({ value: n, label: n }))
59+
})
3960
4061
const fieldOptions = fields.map((f) => ({ value: f.value, label: f.label }))
4162
const operatorOptions = $derived(operators.map((o) => ({ value: o.value, label: o.label })))
@@ -128,6 +149,25 @@
128149
? 'e.g. 13335, press Enter to add'
129150
: 'Type a value, press Enter to add'} />
130151
</div>
152+
{:else if isListName}
153+
<div class="field">
154+
<label for="waf-list-name">IP list</label>
155+
{#if listsDenied}
156+
<span class="inline-flex" title={denyTooltip('wafList.list')}>
157+
<Select id="waf-list-name" disabled options={[]} placeholder="No permission to view IP lists" />
158+
</span>
159+
{:else}
160+
<Select id="waf-list-name" value={condition.value ?? ''}
161+
options={listOptions}
162+
disabled={listOptions.length === 0}
163+
placeholder={listOptions.length ? 'Select an IP list' : 'No IP lists yet'}
164+
onchange={(v) => (condition.value = String(v))} />
165+
<p class="helper">
166+
Reusable named lists, managed on the
167+
<a class="link" href={`/waf/lists?project=${project}`}>IP lists</a> page.
168+
</p>
169+
{/if}
170+
</div>
131171
{:else if useCombobox}
132172
<div class="field">
133173
<label for="waf-value">Value</label>
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
<script lang="ts">
2+
import api from '$lib/api'
3+
import GuardedButton from '$lib/components/GuardedButton.svelte'
4+
import {
5+
WAF_LIST_MAX_ENTRIES,
6+
WAF_LIST_NAME_MAX,
7+
WAF_LIST_NAME_MIN,
8+
parseIPListEntries,
9+
validListName
10+
} from '$lib/waf/lists'
11+
12+
interface Props {
13+
project: string
14+
/** called after a successful wafList.set so the page can reload its data */
15+
onsaved: () => void
16+
}
17+
18+
const { project, onsaved }: Props = $props()
19+
20+
let isActive = $state(false)
21+
// Editing keeps the name immutable — the name is the reference key every
22+
// ipInList macro points at; renaming is delete + recreate by design.
23+
let editing = $state(false)
24+
let name = $state('')
25+
let description = $state('')
26+
let entriesText = $state('')
27+
let saving = $state(false)
28+
let errorMessage = $state('')
29+
30+
const entriesPlaceholder = 'One IP or CIDR per line, e.g.\n203.0.113.0/24\n198.51.100.7\n2001:db8::/48'
31+
32+
const parsed = $derived(parseIPListEntries(entriesText))
33+
const nameOk = $derived(validListName(name.trim()))
34+
const canSave = $derived((editing || nameOk) && parsed.errors.length === 0 && !saving)
35+
36+
// Keep the error list scannable — the textarea can hold hundreds of lines.
37+
const shownErrors = $derived(parsed.errors.slice(0, 5))
38+
const moreErrors = $derived(parsed.errors.length - shownErrors.length)
39+
40+
export function open (list?: Api.WafListItem): void {
41+
editing = !!list
42+
name = list?.name ?? ''
43+
description = list?.description ?? ''
44+
entriesText = (list?.entries ?? []).join('\n')
45+
saving = false
46+
errorMessage = ''
47+
isActive = true
48+
}
49+
50+
function close () {
51+
isActive = false
52+
}
53+
54+
function onBackdrop (e: MouseEvent) {
55+
if (e.target === e.currentTarget) close()
56+
}
57+
58+
async function save (e: Event) {
59+
e.preventDefault()
60+
if (!canSave) return
61+
62+
saving = true
63+
errorMessage = ''
64+
try {
65+
const resp = await api.invoke('wafList.set', {
66+
project,
67+
name: name.trim(),
68+
description,
69+
type: 'ip',
70+
entries: parsed.entries
71+
}, fetch)
72+
if (!resp.ok) {
73+
// Surface the server's message verbatim (e.g. an expanded-size cap
74+
// naming the zone/rule the update would overflow).
75+
errorMessage = (resp.error?.validate ?? [resp.error?.message ?? 'Failed to save the list.']).join('\n')
76+
return
77+
}
78+
close()
79+
onsaved()
80+
} catch {
81+
// api.invoke only rejects on a network-level fetch failure (non-JSON
82+
// bodies are already normalized into an error envelope).
83+
errorMessage = 'Failed to save the list — network error. Try again.'
84+
} finally {
85+
saving = false
86+
}
87+
}
88+
</script>
89+
90+
<div class="modal" onclick={onBackdrop} class:is-active={isActive} aria-hidden={!isActive}>
91+
<div class="modal-panel">
92+
<div class="modal-close" onclick={close} onkeypress={close} tabindex="0" role="button">✕</div>
93+
<h4><strong>{editing ? 'Edit IP list' : 'New IP list'}</strong></h4>
94+
<p class="text-content/50 text-sm mt-1">
95+
A named set of IPs/CIDRs, referenced from rule conditions and rate-limit
96+
filters as <code class="font-mono">ipInList(request.remote_ip, "{name.trim() || 'name'}")</code>.
97+
Editing re-applies every firewall that references it.
98+
</p>
99+
100+
<form class="grid gap-4 mt-4" onsubmit={save}>
101+
<div class="field">
102+
<label for="waf-list-modal-name">Name</label>
103+
<div class="input">
104+
<input id="waf-list-modal-name" class="font-mono" bind:value={name}
105+
readonly={editing} placeholder="office-ips">
106+
</div>
107+
{#if editing}
108+
<p class="text-content/50 text-sm mt-1">
109+
The name is what rules reference — it can't change. Create a new
110+
list to rename.
111+
</p>
112+
{:else}
113+
<p class="{name.trim() !== '' && !nameOk ? 'text-negative' : 'text-content/50'} text-sm mt-1">
114+
{WAF_LIST_NAME_MIN}–{WAF_LIST_NAME_MAX} characters; lowercase
115+
letters, numbers, and hyphens, starting with a letter and ending
116+
with a letter or number.
117+
</p>
118+
{/if}
119+
</div>
120+
121+
<div class="field">
122+
<label for="waf-list-modal-description">Description</label>
123+
<div class="input">
124+
<input id="waf-list-modal-description" bind:value={description} placeholder="Optional description">
125+
</div>
126+
</div>
127+
128+
<div class="field">
129+
<label for="waf-list-modal-entries">Entries</label>
130+
<div class="textarea">
131+
<textarea id="waf-list-modal-entries" class="font-mono" rows="8" bind:value={entriesText}
132+
placeholder={entriesPlaceholder}></textarea>
133+
</div>
134+
<p class="text-content/50 text-sm mt-1">
135+
{parsed.entries.length} {parsed.entries.length === 1 ? 'entry' : 'entries'}
136+
(max {WAF_LIST_MAX_ENTRIES}). An empty list never matches.
137+
</p>
138+
{#if shownErrors.length}
139+
<ul class="text-negative text-sm mt-1 grid gap-0.5">
140+
{#each shownErrors as err (err)}
141+
<li>{err}</li>
142+
{/each}
143+
{#if moreErrors > 0}
144+
<li class="text-content/50">…and {moreErrors} more.</li>
145+
{/if}
146+
</ul>
147+
{/if}
148+
</div>
149+
150+
{#if errorMessage}
151+
<p class="text-negative text-sm whitespace-pre-line">{errorMessage}</p>
152+
{/if}
153+
154+
<div class="flex items-center gap-3 mt-2">
155+
<GuardedButton permission="wafList.set" type="submit" loading={saving} disabled={!canSave}
156+
title={canSave ? undefined : 'Enter a valid name and fix the entry errors first'}>
157+
Save
158+
</GuardedButton>
159+
<button type="button" class="button is-variant-secondary" disabled={saving} onclick={close}>
160+
Cancel
161+
</button>
162+
</div>
163+
</form>
164+
</div>
165+
</div>
166+
167+
<style>
168+
.modal-panel {
169+
width: 100%;
170+
max-width: 36rem;
171+
}
172+
</style>

0 commit comments

Comments
 (0)