mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-08-28 13:54:39 +00:00
Add filter policy playground
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,585 @@
|
||||
.filter-tool {
|
||||
--filter-border: color-mix(in srgb, var(--md-default-fg-color) 18%, transparent);
|
||||
--filter-border-strong: color-mix(in srgb, var(--md-primary-fg-color) 46%, transparent);
|
||||
--filter-soft: color-mix(in srgb, var(--md-default-fg-color) 5%, transparent);
|
||||
--filter-softer: color-mix(in srgb, var(--md-default-fg-color) 2.5%, transparent);
|
||||
--filter-accent: color-mix(in srgb, var(--md-primary-fg-color) 12%, transparent);
|
||||
--filter-warning: #ed9b00;
|
||||
--filter-danger: #d32f2f;
|
||||
margin: 1.5rem 0 2rem;
|
||||
color: var(--md-default-fg-color);
|
||||
}
|
||||
|
||||
.filter-design-warning {
|
||||
margin: 1rem 0;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-left: 0.25rem solid #ed9b00;
|
||||
border-radius: 0.25rem;
|
||||
background: color-mix(in srgb, #ed9b00 11%, transparent);
|
||||
}
|
||||
|
||||
.filter-design-warning strong {
|
||||
display: block;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.filter-design-warning p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filter-tool [hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.filter-tool-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.8rem;
|
||||
border: 1px solid var(--filter-border);
|
||||
border-radius: 0.65rem;
|
||||
background:
|
||||
linear-gradient(120deg, var(--filter-accent), transparent 56%),
|
||||
var(--md-default-bg-color);
|
||||
}
|
||||
|
||||
.filter-tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(18rem, 0.9fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
min-width: 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--filter-border);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--md-default-bg-color);
|
||||
box-shadow: 0 0.15rem 0.65rem color-mix(in srgb, #000 9%, transparent);
|
||||
}
|
||||
|
||||
.filter-panel + .filter-panel,
|
||||
.filter-tool-grid + .filter-panel {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.filter-tool-grid .filter-panel + .filter-panel {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.filter-panel-heading {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--filter-border);
|
||||
}
|
||||
|
||||
.filter-panel-heading h2 {
|
||||
margin: 0.12rem 0 0 !important;
|
||||
font-size: 1.22rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.filter-eyebrow {
|
||||
margin: 0;
|
||||
color: color-mix(in srgb, var(--md-default-fg-color) 62%, transparent);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filter-tool h3 {
|
||||
margin: 1rem 0 0.55rem;
|
||||
font-size: 0.88rem;
|
||||
letter-spacing: 0.015em;
|
||||
}
|
||||
|
||||
.filter-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.72rem;
|
||||
}
|
||||
|
||||
.filter-tool label {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
color: color-mix(in srgb, var(--md-default-fg-color) 82%, transparent);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.filter-tool input,
|
||||
.filter-tool select,
|
||||
.filter-tool textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-top: 0.3rem;
|
||||
border: 1px solid var(--filter-border);
|
||||
border-radius: 0.38rem;
|
||||
color: var(--md-default-fg-color);
|
||||
background: var(--md-code-bg-color);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.filter-tool input,
|
||||
.filter-tool select {
|
||||
min-height: 2.35rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
}
|
||||
|
||||
.filter-tool textarea {
|
||||
min-height: 9rem;
|
||||
padding: 0.72rem;
|
||||
resize: vertical;
|
||||
font: 0.76rem/1.5 var(--md-code-font-family);
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.filter-tool input:focus-visible,
|
||||
.filter-tool select:focus-visible,
|
||||
.filter-tool textarea:focus-visible,
|
||||
.filter-tool button:focus-visible {
|
||||
outline: 0.15rem solid var(--md-accent-fg-color);
|
||||
outline-offset: 0.12rem;
|
||||
}
|
||||
|
||||
.filter-tool input::placeholder,
|
||||
.filter-tool textarea::placeholder {
|
||||
color: color-mix(in srgb, var(--md-code-fg-color) 48%, transparent);
|
||||
}
|
||||
|
||||
.filter-check-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem 1rem;
|
||||
margin-top: 0.8rem;
|
||||
padding: 0.68rem 0.75rem;
|
||||
border: 1px solid var(--filter-border);
|
||||
border-radius: 0.4rem;
|
||||
background: var(--filter-soft);
|
||||
}
|
||||
|
||||
.filter-check-row label {
|
||||
display: inline-flex;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-check-row input {
|
||||
width: 1rem;
|
||||
min-height: 1rem;
|
||||
height: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-tool button {
|
||||
appearance: none;
|
||||
min-height: 2.2rem;
|
||||
padding: 0.4rem 0.72rem;
|
||||
border: 1px solid var(--filter-border);
|
||||
border-radius: 0.35rem;
|
||||
color: var(--md-default-fg-color);
|
||||
background: var(--filter-soft);
|
||||
font: inherit;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-tool button:hover:not(:disabled) {
|
||||
border-color: var(--filter-border-strong);
|
||||
background: var(--filter-accent);
|
||||
}
|
||||
|
||||
.filter-tool button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.filter-tool .filter-primary-action {
|
||||
border-color: var(--md-primary-fg-color);
|
||||
color: var(--md-primary-bg-color);
|
||||
background: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
.filter-tool .filter-primary-action:hover:not(:disabled) {
|
||||
filter: brightness(1.12);
|
||||
background: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
.filter-tool .filter-danger-action {
|
||||
color: color-mix(in srgb, var(--filter-danger) 82%, var(--md-default-fg-color));
|
||||
}
|
||||
|
||||
.filter-field-help {
|
||||
margin: 0.65rem 0;
|
||||
color: color-mix(in srgb, var(--md-default-fg-color) 67%, transparent);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.filter-live-preview {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.8rem;
|
||||
border: 1px solid var(--filter-border-strong);
|
||||
border-radius: 0.48rem;
|
||||
background:
|
||||
linear-gradient(135deg, var(--filter-accent), transparent 62%),
|
||||
var(--filter-softer);
|
||||
}
|
||||
|
||||
.filter-live-preview > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-preview-label {
|
||||
display: block;
|
||||
margin-bottom: 0.32rem;
|
||||
color: color-mix(in srgb, var(--md-default-fg-color) 62%, transparent);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filter-live-preview code,
|
||||
.filter-rule-command,
|
||||
.filter-explain-results code {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
padding: 0.58rem 0.65rem;
|
||||
border: 1px solid var(--filter-border);
|
||||
border-radius: 0.35rem;
|
||||
color: var(--md-code-fg-color);
|
||||
background: var(--md-code-bg-color);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.filter-live-preview p {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.filter-builder-actions,
|
||||
.filter-rule-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-rule-list {
|
||||
display: grid;
|
||||
gap: 0.72rem;
|
||||
margin: 0.85rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.filter-rule-list > li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filter-rule-card {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--filter-border);
|
||||
border-radius: 0.48rem;
|
||||
background: var(--filter-softer);
|
||||
}
|
||||
|
||||
.filter-rule-card-header {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.filter-rule-labels {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.filter-rule-labels strong {
|
||||
font-size: 0.81rem;
|
||||
}
|
||||
|
||||
.filter-rule-labels span,
|
||||
.filter-rule-meta {
|
||||
color: color-mix(in srgb, var(--md-default-fg-color) 62%, transparent);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.filter-rule-meta {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.filter-rule-card p {
|
||||
margin: 0.62rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.filter-rule-actions {
|
||||
margin-top: 0.62rem;
|
||||
}
|
||||
|
||||
.filter-rule-actions button {
|
||||
min-height: 1.9rem;
|
||||
padding: 0.26rem 0.55rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.filter-empty-state {
|
||||
margin-top: 0.85rem;
|
||||
padding: 2.2rem 1rem;
|
||||
border: 1px dashed var(--filter-border-strong);
|
||||
border-radius: 0.48rem;
|
||||
color: color-mix(in srgb, var(--md-default-fg-color) 58%, transparent);
|
||||
background: var(--filter-softer);
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.filter-policy-summary {
|
||||
margin-top: 0.85rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid var(--filter-border);
|
||||
color: color-mix(in srgb, var(--md-default-fg-color) 68%, transparent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.filter-policy-target {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.filter-policy-target label {
|
||||
max-width: 18rem;
|
||||
}
|
||||
|
||||
.filter-inline-warnings {
|
||||
margin: 0.58rem 0 0 1rem;
|
||||
padding: 0;
|
||||
color: color-mix(in srgb, var(--filter-warning) 80%, var(--md-default-fg-color));
|
||||
font-size: 0.71rem;
|
||||
line-height: 1.42;
|
||||
}
|
||||
|
||||
.filter-inline-warnings li + li {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.filter-import textarea,
|
||||
.filter-export textarea {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.filter-error {
|
||||
margin-top: 0.85rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-left: 0.25rem solid var(--filter-danger);
|
||||
border-radius: 0.25rem;
|
||||
color: color-mix(in srgb, var(--filter-danger) 82%, var(--md-default-fg-color));
|
||||
background: color-mix(in srgb, var(--filter-danger) 10%, transparent);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.filter-simulator-facts {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.filter-tool .filter-simulator-check {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
align-self: end;
|
||||
min-height: 2.35rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border: 1px solid var(--filter-border);
|
||||
border-radius: 0.38rem;
|
||||
background: var(--filter-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-tool .filter-simulator-check input {
|
||||
flex: 0 0 auto;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
min-height: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-decision {
|
||||
margin-top: 1rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--filter-border-strong);
|
||||
border-radius: 0.48rem;
|
||||
background: var(--filter-softer);
|
||||
}
|
||||
|
||||
.filter-decision > h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.filter-decision-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.filter-decision-grid > div {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
padding: 0.58rem;
|
||||
border: 1px solid var(--filter-border);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--md-code-bg-color);
|
||||
}
|
||||
|
||||
.filter-decision-grid span {
|
||||
color: color-mix(in srgb, var(--md-default-fg-color) 58%, transparent);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filter-decision-grid strong {
|
||||
font-size: 0.72rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.filter-trace {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.filter-trace li {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
margin: 0;
|
||||
padding: 0.58rem 0.65rem;
|
||||
border-left: 0.22rem solid var(--filter-border-strong);
|
||||
border-radius: 0.28rem;
|
||||
background: var(--filter-soft);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.42;
|
||||
}
|
||||
|
||||
.filter-trace li strong {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filter-trace-match {
|
||||
border-left-color: #2e7d32 !important;
|
||||
}
|
||||
|
||||
.filter-trace-shadow {
|
||||
border-left-color: var(--filter-warning) !important;
|
||||
}
|
||||
|
||||
.filter-trace-miss,
|
||||
.filter-trace-disabled,
|
||||
.filter-trace-stopped {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.filter-explain-results {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--filter-border);
|
||||
}
|
||||
|
||||
.filter-explain-results h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.filter-explain-results ol {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding-left: 1.4rem;
|
||||
}
|
||||
|
||||
.filter-explain-results li {
|
||||
padding-left: 0.2rem;
|
||||
}
|
||||
|
||||
.filter-explain-results p {
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.filter-export-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin: 0.85rem 0;
|
||||
padding-bottom: 0.65rem;
|
||||
border-bottom: 1px solid var(--filter-border);
|
||||
}
|
||||
|
||||
.filter-export-tabs button[aria-selected="true"] {
|
||||
border-color: var(--md-primary-fg-color);
|
||||
color: var(--md-primary-bg-color);
|
||||
background: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
@media (max-width: 62rem) {
|
||||
.filter-tool-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-tool-grid .filter-panel + .filter-panel {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 44rem) {
|
||||
.filter-panel {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-panel-heading,
|
||||
.filter-rule-card-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-rule-meta {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.filter-tool-toolbar button,
|
||||
.filter-builder-actions button {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
# Filter policy playground
|
||||
|
||||
Design and test policies for the proposed ground-up MeshCore forwarding engine.
|
||||
The playground models phased evaluation, immutable receive-time matches,
|
||||
explicit priority and stop behavior, ACL ownership, compact typed conditions,
|
||||
and accumulated forwarding decisions.
|
||||
|
||||
Everything runs locally in this browser. Channel keys, packet facts, and policy
|
||||
drafts are not uploaded anywhere.
|
||||
|
||||
<div class="filter-design-warning" role="note">
|
||||
<strong>Engine design preview</strong>
|
||||
<p>
|
||||
This page targets the new policy-engine idea, not today's FPF7 file or
|
||||
existing <code>set flood.*</code> commands. Its readable policy language,
|
||||
JSON, and Base64 bundle are a prototype for design testing. Current
|
||||
firmware cannot install these policies yet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## Build a policy
|
||||
|
||||
<div class="filter-tool" data-filter-tool>
|
||||
<div class="filter-tool-toolbar" aria-label="Policy examples">
|
||||
<strong>Load an example:</strong>
|
||||
<button type="button" data-example="blackhole">BlackHole86 rewrite</button>
|
||||
<button type="button" data-example="wildcards">Login and other classes</button>
|
||||
<button type="button" data-example="moderation">Content moderation</button>
|
||||
<button type="button" data-example="system">System forwarding gates</button>
|
||||
<button type="button" data-example="mixed">Full mixed policy</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-tool-grid">
|
||||
<section class="filter-panel filter-builder" aria-labelledby="filter-builder-title">
|
||||
<div class="filter-panel-heading">
|
||||
<div>
|
||||
<p class="filter-eyebrow">Rule builder</p>
|
||||
<h2 id="filter-builder-title">Create a policy rule</h2>
|
||||
</div>
|
||||
<button type="button" data-role="reset-form">Reset form</button>
|
||||
</div>
|
||||
|
||||
<h3>Identity and execution</h3>
|
||||
<div class="filter-form-grid">
|
||||
<label>
|
||||
Stable rule ID
|
||||
<input data-field="id" value="rule-1" maxlength="32" placeholder="blackhole-rewrite">
|
||||
</label>
|
||||
<label>
|
||||
Processing phase
|
||||
<select data-field="phase">
|
||||
<option value="scope_gate">1 - Incoming scope gate</option>
|
||||
<option value="rewrite">2 - Scope rewrite</option>
|
||||
<option value="forward">3 - Forwarding decision</option>
|
||||
<option value="content">4 - Decrypted content</option>
|
||||
<option value="schedule">5 - Scheduling and retry</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Rule owner / capability
|
||||
<select data-field="owner">
|
||||
<option value="scope">ACL 4 - Region/scope manager</option>
|
||||
<option value="filter">ACL 5 - Filter manager</option>
|
||||
<option value="admin">Administrator</option>
|
||||
<option value="system">Firmware-managed system rule</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Priority
|
||||
<input data-field="priority" type="number" min="0" max="255" inputmode="numeric" value="100">
|
||||
</label>
|
||||
<label>
|
||||
Mode
|
||||
<select data-field="mode">
|
||||
<option value="active">Active</option>
|
||||
<option value="shadow">Shadow - observe only</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Stop behavior after a match
|
||||
<select data-field="stop">
|
||||
<option value="none">Continue processing</option>
|
||||
<option value="phase">Stop this phase</option>
|
||||
<option value="policy">Stop later policy phases</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h3>Match immutable receive-time facts</h3>
|
||||
<div class="filter-form-grid">
|
||||
<label>
|
||||
Route class
|
||||
<select data-field="route">
|
||||
<option value="flood">Any flood route</option>
|
||||
<option value="unscoped_flood">Unscoped flood only</option>
|
||||
<option value="scoped_flood">Transport-scoped flood only</option>
|
||||
<option value="direct">Direct routes</option>
|
||||
<option value="any">Any route</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Payload type or class
|
||||
<select data-field="type">
|
||||
<option value="any">Any payload type</option>
|
||||
<option value="class:group">Class: group text and data</option>
|
||||
<option value="class:login">Class: login/admin family</option>
|
||||
<option value="class:other">Class: every other payload</option>
|
||||
<option value="req">REQ</option>
|
||||
<option value="response">RESPONSE</option>
|
||||
<option value="txt_msg">TXT_MSG</option>
|
||||
<option value="ack">ACK</option>
|
||||
<option value="advert">ADVERT</option>
|
||||
<option value="grp_txt">GRP_TXT</option>
|
||||
<option value="grp_data">GRP_DATA</option>
|
||||
<option value="anon_req">ANON_REQ</option>
|
||||
<option value="path">PATH</option>
|
||||
<option value="trace">TRACE</option>
|
||||
<option value="multipart">MULTIPART</option>
|
||||
<option value="control">CONTROL</option>
|
||||
<option value="ota">OTA</option>
|
||||
<option value="13">Reserved type 13</option>
|
||||
<option value="14">Reserved type 14</option>
|
||||
<option value="raw_custom">RAW_CUSTOM</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Received hops
|
||||
<input data-field="hops" value="all" placeholder="all, 5+, 2-6, or 3">
|
||||
</label>
|
||||
<label>
|
||||
Authenticated channel (optional)
|
||||
<input data-field="channel" placeholder="#rgdata, public, or key">
|
||||
</label>
|
||||
<label>
|
||||
Original incoming scope
|
||||
<input data-field="incoming" list="policy-incoming-options" value="any" placeholder="any">
|
||||
<datalist id="policy-incoming-options">
|
||||
<option value="any"></option>
|
||||
<option value="none"></option>
|
||||
<option value="scoped"></option>
|
||||
<option value="allowed"></option>
|
||||
<option value="unknown"></option>
|
||||
<option value="scope:usa"></option>
|
||||
<option value="region:west"></option>
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
Path matcher
|
||||
<select data-field="path-kind">
|
||||
<option value="none">No path condition</option>
|
||||
<option value="prefix">Ordered 1/2/3-byte pbyte prefix</option>
|
||||
<option value="blacklist">Passive blacklist</option>
|
||||
<option value="bucket:1">Path bucket 1</option>
|
||||
<option value="bucket:2">Path bucket 2</option>
|
||||
<option value="bucket:3">Path bucket 3</option>
|
||||
<option value="bucket:4">Path bucket 4</option>
|
||||
<option value="bucket:5">Path bucket 5</option>
|
||||
<option value="bucket:6">Path bucket 6</option>
|
||||
<option value="loop:strict">Own-ID loop: strict</option>
|
||||
<option value="loop:moderate">Own-ID loop: moderate</option>
|
||||
<option value="loop:minimal">Own-ID loop: minimal</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Ordered pbyte prefix
|
||||
<input data-field="path-prefix" placeholder="86, 860C, or 860C,12A4">
|
||||
</label>
|
||||
<label>
|
||||
Decrypted sender (optional)
|
||||
<input data-field="sender" maxlength="31" placeholder="Noisy User">
|
||||
</label>
|
||||
<label>
|
||||
Temporary-radio state
|
||||
<select data-field="temp-radio">
|
||||
<option value="any">Either state</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="active">Active</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Minimum SNR dB (optional)
|
||||
<input data-field="snr-min" type="number" min="-30" max="30" step="0.25" placeholder="no minimum">
|
||||
</label>
|
||||
<label>
|
||||
Maximum SNR dB (optional)
|
||||
<input data-field="snr-max" type="number" min="-30" max="30" step="0.25" placeholder="no maximum">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h3>Accumulate forwarding actions</h3>
|
||||
<div class="filter-form-grid">
|
||||
<label>
|
||||
Forwarding verdict
|
||||
<select data-field="verdict">
|
||||
<option value="continue">No verdict change</option>
|
||||
<option value="drop">Drop / do not retransmit</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Region-gate decision
|
||||
<select data-field="scope-gate">
|
||||
<option value="unchanged">Leave global gate unchanged</option>
|
||||
<option value="require_allowed">Require original allowed region</option>
|
||||
<option value="bypass_global">Bypass global gate for this packet</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Scope rewrite target
|
||||
<select data-field="target-kind">
|
||||
<option value="none">Do not rewrite scope</option>
|
||||
<option value="scope">Regionless public scope</option>
|
||||
<option value="region">Configured region</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Target name
|
||||
<input data-field="target" placeholder="BlackHole86">
|
||||
</label>
|
||||
<label>
|
||||
Token rate per minute (optional)
|
||||
<input data-field="rate" type="number" min="1" max="65534" inputmode="numeric" placeholder="unlimited">
|
||||
</label>
|
||||
<label>
|
||||
Burst tokens
|
||||
<input data-field="burst" type="number" min="1" max="65534" inputmode="numeric" placeholder="defaults to rate">
|
||||
</label>
|
||||
<label>
|
||||
Rewrite timing
|
||||
<select data-field="timing">
|
||||
<option value="inherit">Inherit normal behavior</option>
|
||||
<option value="fast">Fast-track rewrite</option>
|
||||
<option value="normal">Normal queue timing</option>
|
||||
<option value="slow">Slow receive/transmit timing</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Queue priority
|
||||
<select data-field="queue">
|
||||
<option value="inherit">Inherit</option>
|
||||
<option value="high">High</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Retry path bucket
|
||||
<select data-field="retry-bucket">
|
||||
<option value="none">No retry action</option>
|
||||
<option value="bucket:1">Bucket 1</option>
|
||||
<option value="bucket:2">Bucket 2</option>
|
||||
<option value="bucket:3">Bucket 3</option>
|
||||
<option value="bucket:4">Bucket 4</option>
|
||||
<option value="bucket:5">Bucket 5</option>
|
||||
<option value="bucket:6">Bucket 6</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Retry attempts
|
||||
<input data-field="retry-attempts" type="number" min="1" max="10" inputmode="numeric" placeholder="1">
|
||||
</label>
|
||||
<label>
|
||||
Decision tag (optional)
|
||||
<input data-field="tag" maxlength="24" placeholder="blackhole-ingress">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="filter-live-preview">
|
||||
<div>
|
||||
<span class="filter-preview-label">Readable policy definition</span>
|
||||
<code data-role="live-command"></code>
|
||||
</div>
|
||||
<p data-role="live-explanation"></p>
|
||||
<ul class="filter-inline-warnings" data-role="live-warnings" hidden></ul>
|
||||
</div>
|
||||
|
||||
<div class="filter-builder-actions">
|
||||
<button class="filter-primary-action" type="button" data-role="save-rule">Add rule to policy</button>
|
||||
<button type="button" data-role="copy-live">Copy definition</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="filter-panel filter-policy" aria-labelledby="filter-policy-title">
|
||||
<div class="filter-panel-heading">
|
||||
<div>
|
||||
<p class="filter-eyebrow">Policy draft</p>
|
||||
<h2 id="filter-policy-title">Rules in execution order</h2>
|
||||
</div>
|
||||
<button type="button" data-role="clear-policy">Clear policy</button>
|
||||
</div>
|
||||
<div class="filter-policy-target">
|
||||
<label>
|
||||
Approximate target budget
|
||||
<select data-role="target-profile">
|
||||
<option value="stm32">Compact STM32 - 2 KB</option>
|
||||
<option value="nrf52" selected>nRF52 - 8 KB</option>
|
||||
<option value="esp32">Classic ESP32 - 16 KB</option>
|
||||
<option value="esp32_roomy">Roomy ESP32 - 64 KB</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p class="filter-field-help">
|
||||
Rules match the same immutable packet facts. Ordering is phase, then
|
||||
descending priority, then stable rule ID. Drop decisions are sticky.
|
||||
</p>
|
||||
<div class="filter-empty-state" data-role="empty-policy">
|
||||
Add a rule or load an example to start exploring.
|
||||
</div>
|
||||
<ol class="filter-rule-list" data-role="rule-list"></ol>
|
||||
<div class="filter-policy-summary" data-role="policy-summary"></div>
|
||||
<ul class="filter-inline-warnings" data-role="policy-warnings" hidden></ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="filter-panel filter-simulator" aria-labelledby="filter-simulator-title">
|
||||
<div class="filter-panel-heading">
|
||||
<div>
|
||||
<p class="filter-eyebrow">Immutable packet simulator</p>
|
||||
<h2 id="filter-simulator-title">Explain an evaluation</h2>
|
||||
</div>
|
||||
<button type="button" data-role="reset-packet">Reset packet</button>
|
||||
</div>
|
||||
<div class="filter-form-grid filter-simulator-facts">
|
||||
<label>
|
||||
Received route
|
||||
<select data-packet="route">
|
||||
<option value="unscoped_flood">Unscoped flood</option>
|
||||
<option value="scoped_flood">Transport-scoped flood</option>
|
||||
<option value="direct">Direct route</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Payload type
|
||||
<select data-packet="type">
|
||||
<option value="req">REQ</option>
|
||||
<option value="response">RESPONSE</option>
|
||||
<option value="txt_msg">TXT_MSG</option>
|
||||
<option value="ack">ACK</option>
|
||||
<option value="advert">ADVERT</option>
|
||||
<option value="grp_txt">GRP_TXT</option>
|
||||
<option value="grp_data" selected>GRP_DATA</option>
|
||||
<option value="anon_req">ANON_REQ</option>
|
||||
<option value="path">PATH</option>
|
||||
<option value="trace">TRACE</option>
|
||||
<option value="multipart">MULTIPART</option>
|
||||
<option value="control">CONTROL</option>
|
||||
<option value="ota">OTA</option>
|
||||
<option value="13">Reserved type 13</option>
|
||||
<option value="14">Reserved type 14</option>
|
||||
<option value="raw_custom">RAW_CUSTOM</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Received hops
|
||||
<input data-packet="hops" type="number" min="0" max="63" inputmode="numeric" value="4">
|
||||
</label>
|
||||
<label>
|
||||
Authenticated channel
|
||||
<input data-packet="channel" value="#rgdata" placeholder="blank if none">
|
||||
</label>
|
||||
<label>
|
||||
Received pbyte path
|
||||
<input data-packet="path" value="860C,12A4" placeholder="same-width IDs">
|
||||
</label>
|
||||
<label>
|
||||
Original scope status
|
||||
<select data-packet="scope-status">
|
||||
<option value="none">Unscoped</option>
|
||||
<option value="allowed">Allowed region</option>
|
||||
<option value="unknown">Unknown or denied scope</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Original scope name
|
||||
<input data-packet="scope-name" placeholder="usa">
|
||||
</label>
|
||||
<label>
|
||||
Resolved region name
|
||||
<input data-packet="region-name" placeholder="west">
|
||||
</label>
|
||||
<label>
|
||||
Decrypted sender
|
||||
<input data-packet="sender" placeholder="Noisy User">
|
||||
</label>
|
||||
<label>
|
||||
Received SNR dB
|
||||
<input data-packet="snr" type="number" min="-30" max="30" step="0.25" value="6">
|
||||
</label>
|
||||
<label>
|
||||
Passive blacklist result
|
||||
<select data-packet="blacklist">
|
||||
<option value="no">No match</option>
|
||||
<option value="yes">Matched</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Matching path buckets
|
||||
<input data-packet="buckets" placeholder="1,3,6">
|
||||
</label>
|
||||
<label>
|
||||
Own-ID loop result
|
||||
<select data-packet="loop-level">
|
||||
<option value="0">No loop match</option>
|
||||
<option value="1">Strict only</option>
|
||||
<option value="2">Moderate and strict</option>
|
||||
<option value="3">Minimal, moderate, and strict</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="filter-simulator-check">
|
||||
<input data-packet="temp-radio" type="checkbox">
|
||||
Temporary radio is active
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-builder-actions">
|
||||
<button class="filter-primary-action" type="button" data-role="simulate">Run policy simulation</button>
|
||||
</div>
|
||||
<div class="filter-error" data-role="simulation-error" role="alert" hidden></div>
|
||||
<div class="filter-decision" data-role="simulation-result" hidden></div>
|
||||
</section>
|
||||
|
||||
<section class="filter-panel filter-import" aria-labelledby="filter-import-title">
|
||||
<div class="filter-panel-heading">
|
||||
<div>
|
||||
<p class="filter-eyebrow">Import and explain</p>
|
||||
<h2 id="filter-import-title">Paste readable policy or a saved draft</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="filter-field-help">
|
||||
Accepts one-line <code>policy set ... when ... do ...</code> definitions,
|
||||
playground JSON, or a playground Base64 bundle.
|
||||
</p>
|
||||
<textarea data-role="import-input" spellcheck="false" placeholder="policy set blackhole phase=rewrite owner=scope priority=150 when route=flood type=grp_data hops=4+ channel=#rgdata rx.scope=none do scope=#BlackHole86 timing=fast stop=phase"></textarea>
|
||||
<div class="filter-builder-actions">
|
||||
<button class="filter-primary-action" type="button" data-role="explain-input">Explain input</button>
|
||||
<button type="button" data-role="load-input">Load into builder</button>
|
||||
</div>
|
||||
<div class="filter-error" data-role="import-error" role="alert" hidden></div>
|
||||
<div class="filter-explain-results" data-role="explain-results" hidden></div>
|
||||
</section>
|
||||
|
||||
<section class="filter-panel filter-export" aria-labelledby="filter-export-title">
|
||||
<div class="filter-panel-heading">
|
||||
<div>
|
||||
<p class="filter-eyebrow">Export</p>
|
||||
<h2 id="filter-export-title">Move or save this design</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-export-tabs" role="tablist" aria-label="Policy export format">
|
||||
<button type="button" data-export-tab="dsl" aria-selected="true">Readable policy</button>
|
||||
<button type="button" data-export-tab="json" aria-selected="false">Policy JSON</button>
|
||||
<button type="button" data-export-tab="bundle" aria-selected="false">Playground Base64</button>
|
||||
</div>
|
||||
<div data-export-panel="dsl">
|
||||
<p class="filter-field-help">Proposed human-readable definition. Current firmware does not accept it yet.</p>
|
||||
<textarea data-role="export-dsl" readonly spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div data-export-panel="json" hidden>
|
||||
<p class="filter-field-help">Structured draft used by this page.</p>
|
||||
<textarea data-role="export-json" readonly spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div data-export-panel="bundle" hidden>
|
||||
<p class="filter-field-help">Browser-playground interchange only. It is not the final packed firmware codec.</p>
|
||||
<textarea data-role="export-bundle" readonly spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="filter-builder-actions">
|
||||
<button type="button" data-role="copy-export">Copy visible export</button>
|
||||
<button type="button" data-role="download-json">Download JSON</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
## Proposed evaluation contract
|
||||
|
||||
The simulator uses these rules:
|
||||
|
||||
1. Every matcher reads the same immutable receive-time packet facts.
|
||||
2. Rules run by phase, descending priority, then stable rule ID.
|
||||
3. A drop decision is sticky and cannot be undone by a later rule.
|
||||
4. The first matching scope, timing, queue, and retry action in execution order
|
||||
wins.
|
||||
5. All matching token-bucket rate constraints remain attached to the decision.
|
||||
6. `stop=phase` skips later rules in that phase. `stop=policy` skips later
|
||||
configurable rules, but never mandatory packet validation or radio safety.
|
||||
7. Shadow rules report what they would do without changing the decision or
|
||||
stopping other rules.
|
||||
8. Expensive facts such as channel authentication, decryption, and path-table
|
||||
lookup are resolved once per packet and reused by every matching rule.
|
||||
|
||||
The byte-budget display is deliberately approximate until the packed firmware
|
||||
codec exists. It demonstrates why simple mappings should not reserve a maximum-
|
||||
sized structure for every possible condition and action.
|
||||
@@ -6,6 +6,7 @@ Below are a few quick start guides.
|
||||
|
||||
- [Frequently Asked Questions](./faq.md)
|
||||
- [CLI Commands](./cli_commands.md)
|
||||
- [Filter Policy Playground](./filter_tool.md)
|
||||
- [Telemetry History Decoder](./telemetry_decoder.md)
|
||||
- [CLI Availability by Firmware Build](./cli_build_matrix.md)
|
||||
- [Easy LoRa OTA: ESP32 full images and nRF52 deltas](./ota_easy.md)
|
||||
|
||||
@@ -18,6 +18,8 @@ theme:
|
||||
extra_css:
|
||||
- _stylesheets/extra.css
|
||||
- _stylesheets/telemetry_decoder.css
|
||||
- _stylesheets/filter_tool.css
|
||||
|
||||
extra_javascript:
|
||||
- _javascript/telemetry_decoder.js
|
||||
- _javascript/filter_tool.js
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const tool = require("../docs/_javascript/filter_tool.js");
|
||||
|
||||
let passed = 0;
|
||||
|
||||
function test(name, callback) {
|
||||
callback();
|
||||
passed += 1;
|
||||
process.stdout.write(`ok ${passed} - ${name}\n`);
|
||||
}
|
||||
|
||||
function assertToolError(callback, pattern) {
|
||||
assert.throws(callback, (error) => error instanceof tool.FilterToolError && pattern.test(error.message));
|
||||
}
|
||||
|
||||
function packet(overrides) {
|
||||
return {
|
||||
route: "unscoped_flood",
|
||||
type: "grp_data",
|
||||
hops: 4,
|
||||
channel: "#rgdata",
|
||||
path: "860C,12A4",
|
||||
scopeStatus: "none",
|
||||
scopeName: "",
|
||||
regionName: "",
|
||||
sender: "",
|
||||
tempRadio: false,
|
||||
snr: 6,
|
||||
blacklist: false,
|
||||
buckets: [],
|
||||
loopLevel: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("builds and parses the BlackHole86 policy definition", () => {
|
||||
const definition = "policy set blackhole-rewrite phase=rewrite owner=scope priority=160 when route=flood type=grp_data hops=4+ channel=#rgdata rx.scope=none path=prefix:860C do scope=#BlackHole86 timing=fast stop=phase";
|
||||
const rule = tool.parseDefinition(definition);
|
||||
assert.strictEqual(rule.targetKind, "scope");
|
||||
assert.strictEqual(rule.target, "BlackHole86");
|
||||
assert.strictEqual(tool.buildDefinition(rule), definition);
|
||||
});
|
||||
|
||||
test("keeps a payload class and path bucket in one rule", () => {
|
||||
const definition = "policy set other-bucket phase=rewrite owner=scope priority=130 when route=flood type=class:other hops=all path=bucket:2 do scope=#BlackHole86 timing=slow";
|
||||
const rule = tool.parseDefinition(definition);
|
||||
assert.strictEqual(rule.type, "class:other");
|
||||
assert.strictEqual(rule.pathKind, "bucket:2");
|
||||
assert.strictEqual(tool.buildDefinition(rule), definition);
|
||||
assert.match(tool.explainRule(rule), /catch-all non-group, non-login class/);
|
||||
});
|
||||
|
||||
test("enforces ACL 4 scope-manager boundaries", () => {
|
||||
assertToolError(
|
||||
() => tool.parseDefinition("policy set bad-drop phase=rewrite owner=scope priority=1 when route=flood type=any hops=all do drop"),
|
||||
/ACL 4 cannot create general drop/
|
||||
);
|
||||
assertToolError(
|
||||
() => tool.parseDefinition("policy set bad-stop phase=rewrite owner=scope priority=1 when route=flood type=any hops=all do scope=#x stop=policy"),
|
||||
/ACL 4 may stop only its current phase/
|
||||
);
|
||||
});
|
||||
|
||||
test("enforces ACL 5 filter-manager boundaries", () => {
|
||||
assertToolError(
|
||||
() => tool.parseDefinition("policy set bad-region phase=rewrite owner=filter priority=1 when route=flood type=any hops=all do region=usa"),
|
||||
/ACL 5 cannot select a configured region target/
|
||||
);
|
||||
const rule = tool.parseDefinition(
|
||||
"policy set public-scope phase=rewrite owner=filter priority=1 when route=flood type=any hops=all do scope=#BlackHole86"
|
||||
);
|
||||
assert.strictEqual(rule.targetKind, "scope");
|
||||
});
|
||||
|
||||
test("limits decrypted sender matching to the content phase", () => {
|
||||
assertToolError(
|
||||
() => tool.parseDefinition('policy set early-sender phase=forward owner=filter priority=1 when route=flood type=grp_txt hops=all sender="Noisy User" do drop'),
|
||||
/only in the content phase/
|
||||
);
|
||||
const definition = 'policy set noisy-user phase=content owner=filter priority=150 when route=flood type=grp_txt hops=all channel=public sender="Noisy User" do rate=5/min burst=5 tag=public-rate';
|
||||
assert.strictEqual(tool.buildDefinition(tool.parseDefinition(definition)), definition);
|
||||
const quoted = 'policy set quoted-user phase=content owner=filter priority=1 when route=flood type=grp_txt hops=all sender="Noisy \\"User\\"" do drop';
|
||||
assert.strictEqual(tool.buildDefinition(tool.parseDefinition(quoted)), quoted);
|
||||
});
|
||||
|
||||
test("round trips token rate and burst actions", () => {
|
||||
const rule = tool.parseDefinition(
|
||||
"policy set rate-limit phase=forward owner=filter priority=90 when route=flood type=grp_data hops=3+ channel=#rgdata do rate=10/min burst=3"
|
||||
);
|
||||
assert.strictEqual(rule.rate, 10);
|
||||
assert.strictEqual(rule.burst, 3);
|
||||
assert.strictEqual(tool.buildDefinition(rule).endsWith("rate=10/min burst=3"), true);
|
||||
assertToolError(
|
||||
() => tool.parseDefinition("policy set zero-rate phase=forward owner=filter priority=1 when route=flood type=any hops=all do rate=0/min"),
|
||||
/Token rate must be 1-65534/
|
||||
);
|
||||
});
|
||||
|
||||
test("encodes and decodes a complete playground bundle", () => {
|
||||
const rules = tool.EXAMPLES.mixed.map(tool.normalizeRule);
|
||||
const bundle = tool.encodeBundle(rules);
|
||||
assert.ok(bundle.startsWith(tool.BUNDLE_PREFIX));
|
||||
assert.deepStrictEqual(tool.decodeBundle(bundle), tool.sortedRules(rules));
|
||||
assert.deepStrictEqual(tool.parsePolicyInput(bundle), tool.sortedRules(rules));
|
||||
});
|
||||
|
||||
test("imports JSON and multiple readable definitions", () => {
|
||||
const rules = tool.EXAMPLES.blackhole.map(tool.normalizeRule);
|
||||
const json = JSON.stringify(tool.policyDocument(rules));
|
||||
assert.deepStrictEqual(tool.parsePolicyInput(json), rules);
|
||||
const readable = [
|
||||
"# a local note",
|
||||
tool.buildDefinition(tool.EXAMPLES.blackhole[0]),
|
||||
tool.buildDefinition(tool.EXAMPLES.system[0]),
|
||||
].join("\n");
|
||||
assert.strictEqual(tool.parsePolicyInput(readable).length, 2);
|
||||
});
|
||||
|
||||
test("orders by phase, descending priority, and stable ASCII ID", () => {
|
||||
const definitions = [
|
||||
"policy set z-last phase=forward owner=filter priority=20 when route=flood type=any hops=all do tag=z",
|
||||
"policy set rewrite-first phase=rewrite owner=scope priority=1 when route=flood type=any hops=all do scope=#x",
|
||||
"policy set b-middle phase=forward owner=filter priority=30 when route=flood type=any hops=all do tag=b",
|
||||
"policy set A-first phase=forward owner=filter priority=20 when route=flood type=any hops=all do tag=a",
|
||||
];
|
||||
assert.deepStrictEqual(
|
||||
tool.sortedRules(definitions.map(tool.parseDefinition)).map((rule) => rule.id),
|
||||
["rewrite-first", "b-middle", "A-first", "z-last"]
|
||||
);
|
||||
});
|
||||
|
||||
test("matches every rule against immutable receive-time scope", () => {
|
||||
const rules = [
|
||||
tool.parseDefinition("policy set rewrite phase=rewrite owner=scope priority=200 when route=flood type=grp_data hops=all rx.scope=none do scope=#BlackHole86"),
|
||||
tool.parseDefinition("policy set original-none phase=forward owner=filter priority=100 when route=flood type=grp_data hops=all rx.scope=none do drop"),
|
||||
];
|
||||
const result = tool.simulatePolicy(rules, packet());
|
||||
assert.deepStrictEqual(result.decision.scopeTarget, { kind: "scope", name: "BlackHole86", rule: "rewrite" });
|
||||
assert.strictEqual(result.decision.drop, true);
|
||||
assert.deepStrictEqual(result.trace.map((entry) => entry.status), ["match", "match"]);
|
||||
assert.strictEqual(result.packet.scopeStatus, "none");
|
||||
});
|
||||
|
||||
test("stop=phase skips lower rules only in that phase", () => {
|
||||
const rules = [
|
||||
tool.parseDefinition("policy set first phase=forward owner=filter priority=200 when route=flood type=any hops=all do tag=first stop=phase"),
|
||||
tool.parseDefinition("policy set skipped phase=forward owner=filter priority=100 when route=flood type=any hops=all do drop"),
|
||||
tool.parseDefinition("policy set later phase=content owner=filter priority=100 when route=flood type=any hops=all do tag=later"),
|
||||
];
|
||||
const result = tool.simulatePolicy(rules, packet());
|
||||
assert.deepStrictEqual(result.trace.map((entry) => entry.status), ["match", "stopped", "match"]);
|
||||
assert.deepStrictEqual(result.decision.tags, ["first", "later"]);
|
||||
assert.strictEqual(result.decision.drop, false);
|
||||
});
|
||||
|
||||
test("stop=policy skips every later configurable phase", () => {
|
||||
const rules = [
|
||||
tool.parseDefinition("policy set first phase=forward owner=admin priority=200 when route=flood type=any hops=all do tag=first stop=policy"),
|
||||
tool.parseDefinition("policy set skipped phase=content owner=filter priority=100 when route=flood type=any hops=all do drop"),
|
||||
];
|
||||
const result = tool.simulatePolicy(rules, packet());
|
||||
assert.deepStrictEqual(result.trace.map((entry) => entry.status), ["match", "stopped"]);
|
||||
assert.strictEqual(result.decision.drop, false);
|
||||
});
|
||||
|
||||
test("shadow rules neither act nor stop later rules", () => {
|
||||
const rules = [
|
||||
tool.parseDefinition("policy set shadow-drop phase=forward owner=admin priority=200 mode=shadow when route=flood type=any hops=all do drop stop=policy"),
|
||||
tool.parseDefinition("policy set active-drop phase=forward owner=filter priority=100 when route=flood type=any hops=all do drop"),
|
||||
];
|
||||
const result = tool.simulatePolicy(rules, packet());
|
||||
assert.deepStrictEqual(result.trace.map((entry) => entry.status), ["shadow", "match"]);
|
||||
assert.strictEqual(result.decision.drop, true);
|
||||
});
|
||||
|
||||
test("drop is sticky while later non-verdict actions still accumulate", () => {
|
||||
const rules = [
|
||||
tool.parseDefinition("policy set drop-first phase=forward owner=filter priority=200 when route=flood type=any hops=all do drop"),
|
||||
tool.parseDefinition("policy set tag-later phase=forward owner=filter priority=100 when route=flood type=any hops=all do tag=observed"),
|
||||
];
|
||||
const result = tool.simulatePolicy(rules, packet());
|
||||
assert.strictEqual(result.decision.drop, true);
|
||||
assert.deepStrictEqual(result.decision.tags, ["observed"]);
|
||||
});
|
||||
|
||||
test("scope-gate require drops an originally unscoped packet", () => {
|
||||
const rule = tool.parseDefinition(
|
||||
"policy set require-region phase=scope_gate owner=scope priority=200 when route=flood type=class:group hops=all do scope-gate=require stop=phase"
|
||||
);
|
||||
const result = tool.simulatePolicy([rule], packet());
|
||||
assert.strictEqual(result.decision.scopeGate, "require_allowed");
|
||||
assert.strictEqual(result.decision.drop, true);
|
||||
assert.match(result.trace[0].detail, /scope requirement failed/);
|
||||
});
|
||||
|
||||
test("matches login and future-safe other payload classes", () => {
|
||||
const login = tool.parseDefinition("policy set login phase=forward owner=filter priority=1 when route=flood type=class:login hops=all do drop");
|
||||
const other = tool.parseDefinition("policy set other phase=forward owner=filter priority=1 when route=flood type=class:other hops=all do drop");
|
||||
assert.strictEqual(tool.matchRule(login, packet({ type: "req", channel: "" })).matched, true);
|
||||
assert.strictEqual(tool.matchRule(login, packet({ type: "ota", channel: "" })).matched, false);
|
||||
assert.strictEqual(tool.matchRule(other, packet({ type: "ota", channel: "" })).matched, true);
|
||||
assert.strictEqual(tool.matchRule(other, packet({ type: "grp_data" })).matched, false);
|
||||
assertToolError(() => tool.normalizePacket(packet({ type: "class:group", channel: "" })), /exact known type/);
|
||||
assertToolError(() => tool.normalizePacket(packet({ type: "req" })), /authenticated channel/);
|
||||
});
|
||||
|
||||
test("matches ordered pbyte prefixes, buckets, and loop thresholds", () => {
|
||||
const prefix = tool.parseDefinition("policy set path-prefix phase=forward owner=filter priority=1 when route=flood type=any hops=all path=prefix:86,0C do drop");
|
||||
const bucket = tool.parseDefinition("policy set path-bucket phase=forward owner=filter priority=1 when route=flood type=any hops=all path=bucket:2 do drop");
|
||||
const moderate = tool.parseDefinition("policy set loop phase=forward owner=filter priority=1 when route=flood type=any hops=all path=loop:moderate do drop");
|
||||
const facts = packet({ channel: "", path: "86,0C,AA", buckets: [2], loopLevel: 2 });
|
||||
assert.strictEqual(tool.matchRule(prefix, facts).matched, true);
|
||||
assert.strictEqual(tool.matchRule(bucket, facts).matched, true);
|
||||
assert.strictEqual(tool.matchRule(moderate, facts).matched, true);
|
||||
assertToolError(
|
||||
() => tool.parseDefinition("policy set mixed-width phase=forward owner=filter priority=1 when route=flood type=any hops=all path=prefix:86,1234 do drop"),
|
||||
/same 2, 4, or 6 hex-character width/
|
||||
);
|
||||
});
|
||||
|
||||
test("reports duplicate IDs, rewrite conflicts, and target overflow", () => {
|
||||
const first = tool.parseDefinition("policy set duplicate phase=rewrite owner=scope priority=100 when route=flood type=any hops=all do scope=#one");
|
||||
const second = tool.parseDefinition("policy set duplicate phase=rewrite owner=scope priority=100 when route=flood type=any hops=all do scope=#two");
|
||||
const duplicates = tool.policyWarnings([first, second], "nrf52");
|
||||
assert.ok(duplicates.some((warning) => /duplicated/.test(warning)));
|
||||
assert.ok(duplicates.some((warning) => /overlapping core matches/.test(warning)));
|
||||
|
||||
const large = Array.from({ length: 90 }, (_unused, index) => tool.parseDefinition(
|
||||
`policy set size-${index} phase=forward owner=filter priority=1 when route=flood type=any hops=all do tag=tag-${index}`
|
||||
));
|
||||
assert.ok(tool.policyWarnings(large, "stm32").some((warning) => /exceeds/.test(warning)));
|
||||
});
|
||||
|
||||
test("flags lockout, future-type, spoofing, and policy-stop risks", () => {
|
||||
const broad = tool.parseDefinition("policy set broad phase=forward owner=admin priority=255 when route=any type=any hops=all do drop stop=policy");
|
||||
const other = tool.parseDefinition("policy set future phase=forward owner=filter priority=1 when route=flood type=class:other hops=all do drop");
|
||||
const sender = tool.parseDefinition('policy set sender phase=content owner=filter priority=1 when route=flood type=grp_txt hops=all sender="Noisy User" do drop');
|
||||
assert.ok(tool.ruleWarnings(broad).some((warning) => /remote administration/.test(warning)));
|
||||
assert.ok(tool.ruleWarnings(broad).some((warning) => /mandatory protocol/.test(warning)));
|
||||
assert.ok(tool.ruleWarnings(other).some((warning) => /future types/.test(warning)));
|
||||
assert.ok(tool.ruleWarnings(sender).some((warning) => /spoofable/.test(warning)));
|
||||
});
|
||||
|
||||
process.stdout.write(`# ${passed} filter-policy playground tests passed\n`);
|
||||
Reference in New Issue
Block a user