Files
shater/panel/src/api.ts
T
omarandClaude Opus 5 a0f6083e28
release / apk aarch64_cortex-a53 (push) Successful in 3m7s
release / apk x86_64 (push) Successful in 3m4s
release / release apk (push) Successful in 8s
fix(panel): show whether a rule is in force, not just what was saved
With two catch-all rules both enabled in UCI and a WAN profile enabling one
and disabling the other, the panel drew BOTH switches on while the engine
ran only one chain. GET /api/config is right to return the raw model — that
is the desired state the panel PUTs back — but Routing.tsx read the row
state and the active count from it too, so the interface claimed a setting
was in force when it was not. Same defect class as the Protected badge.

/api/rules/reachability now carries the effective flag and, where the active
profile changed the outcome, its name and direction. The annotation is a
DIFF of ApplyProfileRuleOverrides output against desired state rather than a
second reading of the profiles name lists, so profile logic is not
duplicated and cannot drift — an unmigrated rule the profile is forbidden to
enable produces no diff and gets no badge, with nothing here needing to know
about LegacyDst.

In the UI the two states stay separate: the switch remains the only carrier
of desired state and still writes UCI, while the effective state drives the
dimmed row, the badge, the banner and the header count. Mirroring the
effective state into the switch would be worse than the original bug — the
operator would be toggling someone elses control, and the profiles decision
would be written back as their own choice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 21:35:06 +03:00

1513 lines
64 KiB
TypeScript

// Typed, same-origin client for the shater panel API (see shater/panel/*.go).
//
// The daemon serves JSON under /api/ on its own port and authenticates with an
// HttpOnly session cookie (minted from a single-use LuCI handoff token). Every
// call is same-origin with `credentials: 'include'` so the cookie rides along.
//
// Shapes are derived from the Go structs:
// - Status → apply.Status + {version} (lower-case json tags)
// - Model → shater/model/model.go (NO json tags ⇒ PascalCase keys;
// empty Go slices marshal as null)
//
// Dev/offline fallback: append `?mock` (or `?dev`) to the URL and the client
// serves in-memory fixtures instead of hitting the network, so `npm run dev`
// and screenshot runs render without a live backend. A real backend in dev is
// reachable instead via the Vite proxy in vite.config.ts (no flag ⇒ real fetch).
import * as mock from './mock'
// --- error type -------------------------------------------------------------
/** Thrown for any non-2xx response. `status === 401` means "not authenticated". */
export class ApiError extends Error {
readonly status: number
constructor(status: number, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
}
/** True when the session cookie is missing/expired (surfaces the unauth plate). */
get unauthenticated(): boolean {
return this.status === 401
}
}
// --- API types (mirror the Go contract) -------------------------------------
/**
* How much of the data plane is actually installed right now.
*
* full — everything applied; the engine is up and carrying traffic.
* hold — the engine did NOT come up and a fail-closed holding plan is in
* place: client traffic to WAN is blocked, LAN-to-LAN still works and
* the router stays manageable. Protection ENGAGED, connectivity lost.
* none — there is no data plane at all. With kill_switch="open" that is the
* operator's documented choice (fail-open). With kill_switch="closed"
* it is a failure: traffic is leaving unproxied and unfiltered.
*
* The distinction between `hold` and `none` is the whole point — one means the
* safety net caught it, the other means there is no safety net. Never merge them
* into a generic "something is wrong".
*/
export type Plane = 'full' | 'hold' | 'none'
/**
* Where the router's traffic actually ENDS UP, decided by the daemon from the
* engine config it is running (apply.Status.traffic ← generate.TrafficOf).
*
* tunnel — the default route goes into a tunnel: everything not matched by a
* more specific rule is proxied.
* split — the default leaves directly, but some rules do tunnel their traffic.
* direct — the default leaves directly and nothing is tunnelled at all.
* blocked — the default is the fail-closed backstop: unmatched traffic is
* dropped, not let out. Nothing leaks.
*
* `plane` DOES NOT ANSWER THIS and must never be read as if it did. `plane` says
* how much of the data plane is installed (nft table, policy routing, engine up);
* a router whose only rule is `default → direct` has all of it and sends the whole
* LAN out the plain WAN with its real address. That combination — plane "full",
* traffic "direct" — was live on a user's router under a green "Protected" LED.
*/
export type TrafficVerdict = 'tunnel' | 'split' | 'direct' | 'blocked'
export interface Traffic {
// '' or absent ⇒ not known (daemon that predates this field, nothing applied
// yet, or the plane is on hold). NEVER treat unknown as 'tunnel'.
verdict?: TrafficVerdict | ''
// The outbound tag the engine's default route names, in the engine's own
// vocabulary ("direct", "block", a node/group tag). Diagnostic — wording is
// driven by `verdict`, never by parsing this.
default?: string
// How many of the engine's route rules send their matched traffic into a tunnel.
// Separates "some of your traffic is protected" from "none of it is".
tunnel_rules?: number
}
/**
* One thing the last apply could not do. Deliberately fail-OPEN with a warning
* rather than refusing the whole config (the alternative was taking the network
* down), which is exactly why they must surface in the UI: before this, the only
* record was `logread`.
*
* critical — configured protection is NOT in effect (a list didn't load, a DoH
* resolver was left reachable, an interface isn't kill-switched).
* warning — something degraded, but no protection promise is broken.
* info — an operational note.
*
* `section` + `name` are machine-readable on purpose so the UI can send someone
* to the offending entity instead of printing a wall of text. `name` may be ''.
*/
export interface StatusWarning {
severity: 'critical' | 'warning' | 'info'
section: string // rule|ruleset|blocklist|device|resolver|chain|group|interface|generate|config
name: string // entity name, or '' for a global note
message: string
}
/** GET /api/status — live daemon + data-plane state. */
export interface Status {
running: boolean
enabled: boolean
active: boolean
table: boolean
hash: string
version: string
kill_switch?: string // "closed" (fail-closed) | "open"
panel_port?: number // configured admin-panel port (default 8088)
can_rollback?: boolean // a rollback would revert something (armed snapshot or engine last-good)
// Is the sing-box engine process actually up? Absent on older daemons.
engine_running?: boolean
// How much of the data plane is installed. Absent on older daemons ⇒ unknown,
// in which case the UI shows nothing rather than guessing "full".
plane?: Plane
// Where the traffic actually goes under the running config. Absent on older
// daemons ⇒ unknown; see TrafficVerdict for why this is a separate question
// from `plane`.
traffic?: Traffic
// Findings from the last apply. ALWAYS an array from the daemon (never null);
// empty means the last apply was clean. Pre-sorted critical-first and capped at
// 50, where a truncated list ends with an `info` entry saying "suppressed".
warnings?: StatusWarning[]
// When the DAEMON PROCESS started, and how long it has been up as of this
// response. This is process uptime — NOT "how long ago the config was applied",
// which is a different clock entirely; never label it as such.
//
// The router has no RTC, so its wall clock can sit far from the browser's.
// `started_unix` is therefore only meaningful against the router's own clock:
// tick locally between polls, but re-baseline off `uptime_seconds` on every
// response rather than computing `now - started_unix` client-side.
// Absent on older daemons ⇒ show nothing rather than guessing.
started_unix?: number
uptime_seconds?: number
// Is the `ciadpi` binary (optional `byedpi` package) present on the router?
// A byedpi egress without it is dead (fail-closed), so the Targets editor
// refuses to create NEW byedpi egresses when this is false. Absent on older
// daemons ⇒ unknown, in which case the UI does NOT gate (never lock an
// operator out of a control on a guess).
byedpi_installed?: boolean
}
/** POST /api/apply|confirm|rollback — mirrors the control socket result. */
export interface ApplyResult {
changed: boolean
error?: string
}
/** One row of the live DNS query log (GET /api/stats/log element). */
export interface QueryLogEntry {
time: string // HH:MM:SS
unix: number
domain: string
qtype: string // A | AAAA | HTTPS | ...
rcode: number
blocked: boolean // blocked by the DNS filter — NOT set for failed lookups or an upstream's organic NXDOMAIN
server: string
action: string // block | proxy | pass
device: string // source of the lookup: a LAN device IP/hostname, or "router" for the appliance's own resolutions (urltest probes / sub fetches). "" on older data.
seq: number // monotonic cursor, newest = highest value; used for append/live pagination
}
/**
* One row of the live connection-event log (GET /api/stats/conns element).
* Unlike the DNS query log, a connection event carries the CLIENT: which LAN
* device (`src_ip`, resolved to `src_name`) opened a flow to which destination
* (`dest` — a sniffed domain, else the raw IP) on `port`, over `network`
* (tcp|udp), optionally with a sniffed application `proto` (tls|http|quic|…,
* may be ''), and out which `outbound`. Newest first.
*/
export interface ConnLogEntry {
unix: number
src_ip: string
src_name: string
dest: string // domain or IP
dest_ip: string
port: number
network: string // tcp | udp
proto: string // tls | http | quic | … (may be '')
outbound: string
seq: number // monotonic cursor, newest = highest value; used for append/live pagination
}
/**
* One destination host's connection popularity — a domain, or a raw IP when the
* destination was never sniffed to a name. A row where `host === ip` is that
* domain-less "by IP" case (UDP/TCP/IP-only traffic that Top-domains, being
* DNS-only, can't see). `bytes` is 0 when byte accounting isn't available.
*/
export interface HostStat {
host: string
ip: string
network: string // tcp | udp
proto: string // tls | http | quic | … (may be '')
count: number
bytes: number
}
/**
* Network-wide running totals. The three outcome counters are mutually exclusive
* and sum to `queries`:
* - `blocked` — blocked BY THE DNS FILTER only (D15 blocklist / BlockDoH). An
* upstream's own organic NXDOMAIN is NOT a block; it counts as allowed.
* - `failed` — the lookup produced no usable answer (timeout, SERVFAIL-reject).
* Its own category: not a block, not an allowed answer. Absent on daemons
* older than the blocked/failed split (they folded failures into blocked).
* - `allowed` — a real answer from a resolver.
*/
export interface StatsTotals {
queries: number
blocked: number
failed?: number
allowed: number
}
/** One domain's popularity + how many hits the DNS FILTER blocked (same
* filter-only criterion as StatsTotals.blocked — failures and organic
* NXDOMAINs don't count). */
export interface DomainStat {
domain: string
count: number
blocked: number
}
/**
* One minute of the timeline sparkline. The timeline is SPARSE: a bucket exists
* only for a minute that saw at least one query, so consecutive array elements
* are NOT necessarily adjacent minutes (60 buckets can span hours on a quiet
* router). Position and label by `minute` — never by array index.
*/
export interface TimelineBucket {
minute: number // unix minute (unix seconds / 60)
total: number
blocked: number // filter blocks only (see StatsTotals.blocked)
}
/** Per-client traffic (from the nft accounting sets), IP resolved to a name. */
export interface DeviceStat {
name: string
ip: string
bytes: number
packets: number
}
/** One destination domain a device hit, with the hit count. */
export interface DomainCount {
domain: string
count: number
}
/**
* Per-device destination-domain breakdown ("which client hit which host" —
* DPI-style), keyed by the connection's source IP and resolved to a hostname
* where possible. `domains` is that device's top hosts, highest count first
* (capped server-side). Sourced from the connection-event stream, so it fills
* even before the nft byte counters tick; empty until connections arrive.
*
* LAN clients only get per-IP rows. The router's OWN dials (urltest probes, node
* dials, subscription/DoH fetches) arrive as ONE pseudo-device with
* `name === "router"` and `ip === ""` — the same label the DNS log uses.
*/
export interface DeviceDomains {
ip: string // '' for the router pseudo-device
name: string // 'router' for the router pseudo-device
domains: DomainCount[]
}
/** One named nft counter (per-rule / per-inbound traffic). */
export interface RuleStat {
name: string
kind: string // rule | inbound | other
bytes: number
packets: number
}
/** One DNS-server (or outbound) query count. */
export interface ServerStat {
name: string
count: number
}
/**
* Real per-node reachability, from the engine's urltest/least_test probes. A node
* maps to its health by `tag === node.Name`.
* - tested=false ⇒ the node isn't in any probing group ⇒ status UNKNOWN (not down)
* - tested=true && alive ⇒ up; `delay_ms` is its measured latency
* - tested=true && !alive ⇒ down
* `age_seconds` is how long ago the probe ran.
*
* DO NOT render this as "the node's health" to a person. It is keyed by the
* node's BASE outbound, which a group bound to an egress never dials — that
* group dials a per-group copy, and the copy can be dead while the base is
* alive. One node in two groups with two egresses has two health states, and
* this shape can only carry one. Per-group health is the honest reading:
* {@link GroupHealth} / GET /api/groups/health.
*/
export interface NodeHealth {
tag: string
delay_ms: number
alive: boolean
tested: boolean
age_seconds: number
}
/**
* One member of one group, measured on the outbound THAT GROUP dials.
*
* `node` is the human-facing node name and the only thing to show a person.
* `tag` is the outbound the group actually dialled — identical to `node` for an
* unbound group, and the per-group egress copy `group-<g>-m<i>-<node>` when
* `bound` is true. It exists for debugging/tooltips, never as a label.
*
* `delay_ms` is the last SUCCESSFUL probe's RTT; it is 0 (meaningless) for a
* dead or untested member. `age_seconds` is -1 when there is no measurement.
*/
export interface GroupMemberHealth {
node: string
tag: string
/** Closed set — switch on it exhaustively. `untested` is never "dead". */
state: 'alive' | 'dead' | 'untested'
delay_ms: number
age_seconds: number
/** This row was measured through the group's egress, not the node's own route. */
bound: boolean
}
/**
* One group's membership health (GET /api/groups/health element, and the same
* object under `group_health` on GET /api/stats).
*
* Invariants the daemon guarantees: `tested === alive + dead` and
* `alive + dead + untested === total`. The panel never re-derives them.
*
* READ IT AS "alive out of TESTED", with the untested remainder as a quiet aside
* shown only when it is non-zero:
*
* auto 119 / 122 alive tested 122 of 298
* stealth 2 / 2 alive
*
* Untested must NEVER be folded into dead. The daemon's observatory probes a
* group only when an enabled routing rule can reach it (see `used`), and only a
* probe that RAN and FAILED writes `dead` — so a freshly (re)started engine
* legitimately reads mostly untested for a few seconds, and an unused group
* reads untested forever. Neither is an alarm.
*
* `bound: true` means every member is a per-group egress COPY — this health was
* measured through the group's own egress and is deliberately NOT comparable
* with the same node's numbers in another group, or with the global per-node
* health in `Stats.node_health`. That is the whole point of the endpoint.
*
* `members` is OMITTED (absent, not null) from the cheap summary view. A missing
* key means "not requested", never "no members" — `total` is authoritative.
*/
export interface GroupHealth {
group: string
type: string // "selector" | "urltest"
bound: boolean
/** An enabled routing rule (the Final target, a DNS-resolver detour, a device
* target, …) reaches this group, so the observatory probes its members in
* the background. false ⇒ nothing routes through the group: it is skipped by
* the background probing and every member stays `untested`. That is an
* "unused" note about the ROUTING CONFIG, never a health problem. */
used: boolean
/** Node name the group routes through right now; '' when it hasn't picked. */
selected: string
total: number
tested: number
alive: number
dead: number
untested: number
/** Age of the NEWEST member measurement; -1 when nothing was ever measured. */
freshest_seconds: number
members?: GroupMemberHealth[]
}
/**
* GET /api/groups/health. `groups` is ALWAYS an array, never null.
*
* The numbers fill in on their own: the daemon's observatory probes every group
* and chain an enabled routing rule can reach (a ~10 s tick on the global Probe
* URL / Interval), so a used group's untested members resolve to a real
* alive/dead verdict within seconds. There is no manual run-everything button
* any more and nothing to report here beyond the groups themselves.
*/
/** Per-chain reachability, the chain analogue of {@link GroupHealth}.used (plan
* §5.E): a chain no enabled routing rule routes through is outside the
* observatory's plan, so its exit is never probed and the Targets card renders it
* "unused" instead of an exit-test readout. A chain has no membership counters —
* it is a fixed path, and its end-to-end health is the exit test's job. */
export interface ChainHealth {
name: string
/** An enabled routing rule (the Final target, a DNS-resolver detour, a device
* target, …) reaches this chain, so the observatory probes its exit in the
* background. false ⇒ nothing routes through the chain: it is skipped by the
* background probing and its end-to-end health stays untested. That is an
* "unused" note about the ROUTING CONFIG, never a health problem. */
used: boolean
}
export interface GroupsHealth {
groups: GroupHealth[]
/** Per-chain reachability, same "unused" badge as groups (plan §5.E). Present on
* the summary and `?members=` shapes; the single-group (`?group=`) shape is a
* group detail request and omits it. Always an array when present; absent ⇒ not
* reported by this daemon version (the page treats absence like "not known yet"). */
chains?: ChainHealth[]
}
/**
* GET /api/stats — the live Phase-5 aggregate snapshot. DNS aggregates are
* network-wide; per-query device attribution lives on the log rows
* (QueryLogEntry.device), and per-device TRAFFIC comes from the nft counters
* (`devices`). Go marshals empty slices as null, so every list is nullable.
*/
export interface Stats {
engine_up: boolean
updated_at: string
// The EFFECTIVE running logging/stats backend. When "off", collection is disabled
// and every list below is empty. Absent on older daemons ⇒ treat as collecting.
backend?: string // "off" | "memory" | "sqlite"
totals: StatsTotals
top_domains: DomainStat[] | null
timeline: TimelineBucket[] | null
devices: DeviceStat[] | null
device_domains?: DeviceDomains[] | null
rule_traffic: RuleStat[] | null
// servers/outbounds count DNS RESOLUTIONS: per resolver tag, and per outbound
// tag the resolver's own query egressed through. `outbounds` is NOT traffic or
// connection volume per exit — label it as DNS lookups.
servers: ServerStat[] | null
outbounds: ServerStat[] | null
recent: QueryLogEntry[] | null
node_health?: NodeHealth[] | null
// Per-group membership health, SUMMARY shape (counts only, no member rows) —
// the same objects GET /api/groups/health serves. Overlaid from live engine
// state rather than produced by a stats backend, so it is present even with
// logging switched off. Absent on older daemons.
//
// Prefer THIS over node_health for anything a person reads: node_health is
// keyed by the node's base outbound, which a group bound to an egress does not
// dial. See GroupHealth for why the two are not comparable.
group_health?: GroupHealth[] | null
// Top destination HOSTS by connection count (domain or raw IP). Surfaces the
// UDP/TCP/IP-only traffic that the DNS-only top_domains list can't see.
top_hosts?: HostStat[] | null
}
// --- Model shapes (PascalCase keys; slices may be null) ---------------------
export interface Globals {
Enabled: boolean
// debug|info|warning|error|none. `none` really is silent — it is emitted as the
// engine's own log-disable switch, not as a quieter level. Anything unrecognised
// falls back to warning (an unknown level fails engine start outright).
LogLevel: string
KillSwitch: string // "closed" | "open"
// There is deliberately no DNSMode. It was removed from the Go model (see
// model.go's package comment): "nftset" named the v0.1 dnsmasq architecture that
// v0.2 does not have, and fake-IP is a RESOLVER TYPE (`config resolver` with
// type=fakeip + a pool), not a global mode. PUT /api/config decodes with
// DisallowUnknownFields, so re-adding the key here fails the WHOLE write with 400.
IPv6: boolean
FwmarkBase: number
TableBase: number
ConfirmTimeout: number
ResolverDefault: string
ResolverFallback: string
/**
* `config resolver` used to resolve the SERVER DOMAINS of proxy configs
* (vless/awg endpoints) — sing-box route.default_domain_resolver, a bootstrap
* resolver that is always direct. "" ⇒ not set (model.Globals.EndpointResolver,
* UCI `endpoint_resolver`).
*/
EndpointResolver: string
ProbeURL: string
ProbeInterval: string
SchemaVersion: number
ActiveProfile: string
/**
* What happens to LAN traffic that CANNOT be tunnelled — everything that isn't
* TCP or UDP (ICMP, ESP/AH, GRE, multicast, …).
*
* The kernel's TPROXY hook only handles TCP and UDP because it needs a socket
* to divert to, and proxy protocols carry streams and datagrams — raw ICMP has
* no representation in them. So this traffic can only be dropped or let out
* directly; there is no third physical option, and the setting picks WHICH.
*
* block — (default) drop it all. No ping/traceroute out, no multicast IPTV,
* no client IPsec/PPTP passthrough. Nothing leaks.
* icmp — let ICMP/ICMPv6 echo out directly. Ping and traceroute work; the
* host being pinged sees the real WAN IP. IPTV/VPN passthrough stay
* blocked.
* direct — let all of it out directly. Ping, IPTV and IPsec/PPTP passthrough
* work, and all of it bypasses the tunnel with the real IP.
*
* Absent, empty, or unrecognised ⇒ `block` (the daemon normalises to the safe
* side). Note the policy is inert while KillSwitch is "open", because then
* nothing is being blocked in the first place.
*/
Untunnelable?: string // block|icmp|direct
DNSFilter?: boolean // master enable for the in-engine blocklist filter
DNSIntercept?: boolean // force ALL LAN plaintext DNS (:53) through the engine, incl. router-addressed queries
BlockDoH?: boolean // block known public DoH resolvers (by host + IP:443 + Firefox canary) so clients fall back to plaintext :53
/**
* The daemon's observatory — the background probing that fills the alive /
* dead / tested numbers the Targets page shows for each group (model.Globals
* .GroupHealth, UCI `group_health`). It probes only the groups and chains an
* enabled routing rule can reach, on the global Probe URL / Interval, and
* skips everything else. Default ON.
*
* This gates the observatory and the health/testing UI ONLY. It does NOT
* touch sing-box's own internal urltest / least_test probes: a group always
* keeps picking a live member under the hood regardless of this switch.
* Turning it off stops the background probing and hides the health
* statistics.
*
* ABSENT ⇒ enabled (an older config never wrote the key), so the invariant the
* panel reads by is `globals?.GroupHealth !== false` — never `=== true`.
*/
GroupHealth?: boolean
PanelPort?: number // admin-panel HTTP port (0 = default 8088)
// Statistics retention. For the three sizes: 0 = UNLIMITED (no trimming — that
// aggregate grows without bound, RAM-limited); a positive value is a fixed cap.
// The daemon seeds a fresh config with 200 / 60 / 5000. RetentionDisabled is the
// master switch that turns off trimming for every aggregate at once.
StatsRingSize?: number // query-log + connection-log entries; 0 = unlimited
StatsTimelineMinutes?: number // trailing per-minute sparkline buckets; 0 = unlimited
StatsMaxDomains?: number // network-wide domain-map size before prune; 0 = unlimited
StatsRetentionDisabled?: boolean // master switch: disable trimming for all aggregates
// SQLite-only: hard cap on the on-disk stats.db size, in MB. 0 = unlimited (bounded
// only by the device); a positive N caps the DB at N MB (oldest rows pruned + VACUUM).
// Applies only when StatsBackend === "sqlite"; ignored for off/memory.
StatsDiskLimitMB?: number // stats.db disk cap in MB; 0 = unlimited
// Logging/stats backend selector. "off" collects nothing (all stats lists empty);
// "memory" keeps aggregates in RAM (lost on restart); "sqlite" persists the query
// and connection logs to an on-disk, disk-bounded store that survives a restart
// (the DNS/nft aggregates stay in RAM; if the DB can't be opened it falls back to
// memory and the snapshot honestly reports "memory"). Default "memory". The
// EFFECTIVE running backend is echoed on the /api/stats snapshot.
StatsBackend?: string // "off" | "memory" | "sqlite"
// ---- daemon operational log (shaterd's own log — NOT the DNS query log) ----
// Two independent destinations; BOTH off ⇒ the daemon log is fully silenced.
// ABSENT ⇒ the daemon's default (true), so read as `!== false`, never `=== true`.
LogToSyslog?: boolean // write to the system log (logread); default true
LogToFile?: boolean // write the downloadable log file (GET /api/log); default true
// Where the log FILE lives. false/absent ⇒ tmpfs (/var/log, RAM — lost on every
// reboot); true ⇒ flash (/etc/shater — survives reboot, at the cost of flash
// wear and a slice of the ~33 MB storage budget). Default false.
LogPersist?: boolean
// Log-file size cap in KB (rotated as 2 segments). 128..8192; default 2048.
LogMaxKB?: number
}
export interface Blocklist {
Name: string
Enabled: boolean
Source: 'inline' | 'file' | 'url' | 'geosite'
URL?: string
Path?: string
Entries?: string[] | null
Response?: 'nxdomain' | 'zero'
UpdateInterval?: string
// For source=geosite: one or more sing-geosite categories (e.g. "youtube",
// "category-ads-all"). The backend generates an official remote .srs list for
// each (auto-updating). null/empty for every other source. (Go marshals an
// empty slice as null.)
Categories?: string[] | null
}
export interface Allowlist {
Name: string
Enabled: boolean
Source: 'inline' | 'file' | 'url' | 'geosite'
URL?: string
Path?: string
Entries?: string[] | null
// For source=geosite: one or more sing-geosite categories (see
// Blocklist.Categories). null/empty for every other source.
Categories?: string[] | null
}
export interface Node {
Name: string
Enabled: boolean
URI: string
FromSub: string // "" ⇒ manual; else the owning subscription name
// Multi-WAN: the egress this node dials its OWN server through
// (DialerOptions.Detour on the node's outbound). "" ⇒ default route. Must be an
// `egress.Name`; an unknown value is ignored fail-open by the daemon (logged as a
// warning). Groups/chains/rules referencing this node inherit the binding.
Egress: string
Fingerprint?: string
Stale?: boolean
}
export interface Subscription {
Name: string
Enabled: boolean
URL: string
UpdateInterval?: string
FetchVia?: string // direct|proxy
FetchDetour?: string // detour when FetchVia=proxy: ''|direct | group:<n> | node:<n> | egress:<n>
UA?: string
HWID?: string // auto|<fixed>
DeviceOS?: string
VerOS?: string
DeviceModel?: string
Headers?: string[] | null // "Key: val"
// Which parser reads the fetched body. `auto` sniffs it; the rest force one,
// which is what rescues a feed the sniffer guesses wrong.
Format?: string // auto|clash|xray|singbox|links
Include?: string[] | null // name regex, keep
Exclude?: string[] | null // name regex, drop
FilterProto?: string[] | null // vless/vmess/trojan/ss
FilterCountry?: string[] | null // ISO codes; leading "!" excludes
Dedup?: boolean
ExpireAlertDays?: number
// --- provider-reported account state (model.go Subscription.UserUpload…) ---
// Refreshed from the `subscription-userinfo` response header on every successful
// fetch and PERSISTED, so quota/expiry survive a daemon restart with no refetch.
// Every field is int64 and **0 means "the provider did not report it"** — the
// header is optional and its absence is normal, NOT an error. In particular
// `UserTotal === 0` is indistinguishable between "unlimited" and "not reported",
// so the UI must never render it as a 0-of-0 quota bar.
UserUpload?: number // bytes uploaded (provider counter); 0 = not reported
UserDownload?: number // bytes downloaded (provider counter); 0 = not reported
UserTotal?: number // quota in bytes; 0 = unlimited OR not reported
UserExpire?: number // expiry, UNIX SECONDS; 0 = no expiry reported. A value in
// the past is a valid state (the sub has expired), not an error.
UserInfoAt?: number // when the header was last seen, unix seconds; 0 = never
}
export interface Group {
Name: string
Source: string // "subscription" | "manual"
Subscription?: string
Nodes?: string[] | null // member node names (source=manual)
/**
* How the group picks which member carries a connection. FIVE values map onto
* the engine's `selector`, `urltest` (least_test|round_robin), and random pool:
*
* leastping — urltest/least_test: the member with the best probe time.
* roundrobin — urltest/round_robin: spread across all members in order.
* random — a live engine mode: each connection takes a uniformly random
* member from the currently-live pool (NOT "fastest node").
* failover — priority pool of 1: the first working member in order,
* moving down when it stops responding and RETURNING to a
* higher-priority member once it answers probes again.
* single — selector: always the first member, picked once at start and
* never re-checked. If it dies, this group dies with it.
*
* `leastload` was removed (it silently became "fastest node"). An unknown value
* is warned about (model.ValidateGroups) and built as urltest/least_test.
*/
Strategy?: string // leastping|roundrobin|random|failover|single
Include?: string[] | null // name regex (source=subscription)
Exclude?: string[] | null
FilterProto?: string[] | null
FilterCountry?: string[] | null
Dedup?: boolean
/**
* The egress EVERY node in this group dials its own server through — the same
* binding `Node.Egress` gives one node, applied to the whole group. "" ⇒ the
* normal route.
*
* This is not "where traffic goes after the proxy"; it is how the proxy itself
* reaches its server. Sending the handshakes down a tunnel (AmneziaWG, say)
* leaves the ISP looking at tunnel traffic instead of a VLESS handshake it
* knows how to block.
*
* A node that sets its OWN `Egress` keeps it — the more specific binding wins.
* Must be an `Egress.Name`; the daemon ignores an unknown value fail-open and
* logs a warning.
*
* NOTE: the field is spelled `Egress`, exactly like `Node.Egress`. PUT
* /api/config decodes with DisallowUnknownFields, so any other spelling fails
* the WHOLE write with a 400.
*/
Egress?: string
}
/** A multi-hop chain (config chain): ordered hops, last one exits. */
export interface Chain {
Name: string
Hops?: string[] | null // "group:<n>" | "node:<n>" in L1..Ln order
}
/** A routing rule-set (config ruleset): domain/ipcidr list for rule.DstRuleset. */
export interface Ruleset {
Name: string
Type?: string // domain|ipcidr
Source?: string // inline|file|url|geosite|geoip
URL?: string
Path?: string
Format?: string
UpdateInterval?: string
Entries?: string[] | null
// source=geosite ⇒ one or more sing-geosite categories ("youtube",
// "category-ads-all"); source=geoip ⇒ one or more ISO country codes ("ru",
// "us"). The backend materialises an official remote SagerNet .srs rule-set
// (auto-updating) for each. null/empty for inline/url/file. (Go marshals an
// empty slice as null.)
Categories?: string[] | null
}
/**
* GET /api/ruleset/status element — one installed rule-set / blocklist / allowlist
* as the running engine sees it. Only `remote:true` entries carry a meaningful
* `last_updated` / `interval_seconds` / `rule_count`; inline/file lists report
* `remote:false` and leave those fields empty/zero. Match a UI list to its status
* by (name, kind): a config ruleset `X` ⇒ tag `rs-X` (kind 'ruleset'); a blocklist
* ⇒ `bl-<name>`; an allowlist ⇒ `al-<name>`. A geo list with N categories emits
* N entries that share `name` but differ in `tag`/`category` — group by `name`.
*/
export interface RulesetStatus {
tag: string
name: string
// For a geo (geosite/geoip) list: the single category this entry covers.
// Empty "" for non-geo lists.
category: string
kind: 'ruleset' | 'blocklist' | 'allowlist'
remote: boolean
last_updated: string // RFC3339, "" if never fetched
interval_seconds: number
rule_count: number
}
/** A WAN-mode / failover conditional override (config profile). */
export interface Profile {
Name: string
Enabled: boolean
Priority?: number
// Real condition: cmd/shaterd/profilewatch.go reads the active default-route
// device and pins the matching profile.
MatchIface?: string[] | null // active default-route dev in this set
// There is deliberately no ProbeURL/ProbeMode. Nothing ever probed anything, and
// the selector treated a profile carrying a probe as having an unsatisfiable
// condition — so adding one SWITCHED OFF an otherwise working profile. Both were
// deleted from the Go model; PUT decodes with DisallowUnknownFields, so sending
// either key fails the whole write with 400.
EnableRules?: string[] | null
DisableRules?: string[] | null
/** Per-profile override of the endpoint resolver (keyed by active WAN: SIM→yandex, WiFi→DoH). "" ⇒ no override (model.Profile.EndpointResolver, UCI `endpoint_resolver`). */
EndpointResolver?: string
}
/**
* A `config inbound` — where traffic ENTERS the engine (model.go Inbound).
*
* `Type` selects which fields matter; an absent/empty Type means `tproxy`
* (model.Inbound.EffectiveType). Only an ENABLED `tproxy` inbound is wired into
* the nft TPROXY plane and actually intercepts a LAN network
* (model.IsTproxyInbound); `socks`/`http`/`dokodemo` are plain local listeners
* that intercept nothing on their own.
*
* Field groups, mirroring the Go struct comment. A field outside its own group is
* READ BY NOTHING for that type, which is why the editor hides it rather than
* offering a control that does nothing:
* tproxy — Network (a UCI interface NAME, resolved to its L3 device by
* netplane.IfaceDevice), TproxyPort, TCP, UDP. It ALWAYS binds
* 0.0.0.0:TproxyPort, so Listen/Port mean nothing; it cannot
* authenticate or rewrite a destination, so Auth/User/Pass and
* TargetAddr/TargetPort mean nothing either.
* socks/http — Listen (bind addr, default 127.0.0.1), Port, Auth, User, Pass.
* `http` builds the engine's `mixed` listener, which answers HTTP
* CONNECT *and* SOCKS5 on the same port. The TCP/UDP flags have no
* equivalent in SocksInboundOptions/HTTPMixedInboundOptions — the
* listener always takes TCP and, over SOCKS5, UDP ASSOCIATE.
* dokodemo — Listen, Port, TargetAddr, TargetPort, TargetNetwork. TargetNetwork
* is what binds; the TCP/UDP flags are ignored. Without a target it
* forwards to its OWN listen address — a loop, not a pass-through.
*
* There is deliberately no `Sniff`. Sniffing stopped being an inbound option in
* sing-box 1.11 — it is a leading route ACTION with no inbound matcher, so every
* inbound is always sniffed, and the DNS anti-leak hijack rule depends on it. The
* field was removed from the Go model; PUT decodes with DisallowUnknownFields, so
* sending it fails the whole write with 400.
*/
export interface Inbound {
Name: string
Enabled: boolean
Type?: string // tproxy|socks|http|dokodemo ('' ⇒ tproxy)
// tproxy
Network?: string // UCI interface name (e.g. "lan")
TproxyPort?: number
// socks/http/dokodemo local listener
Listen?: string // bind address; '' ⇒ 127.0.0.1
Port?: number
// socks/http auth
Auth?: string // noauth|password ('' ⇒ noauth)
User?: string
Pass?: string
// dokodemo fixed target
TargetAddr?: string
TargetPort?: number
TargetNetwork?: string // tcp|udp|tcp,udp ('' ⇒ udp)
// tproxy only — see the type table above
TCP?: boolean
UDP?: boolean
}
/**
* A `config egress` — a named way OUT of the router (model.go Egress).
*
* Exactly THREE types produce an outbound, and a type outside them emits nothing,
* which is fail-CLOSED: every binding to it is blocked rather than quietly sent
* over the plain WAN.
*
* interface — a direct outbound bound to that device + the egress routing mark.
* direct — a plain direct outbound; its purpose is to carry a native DPI
* preset (see DPI) on the rules routed to it.
* byedpi — a SOCKS5 outbound to the local ciadpi desync proxy on
* 127.0.0.1:Port.
*
* `proxy` and `block` were removed: neither ever emitted an outbound, so every
* reference to them dangled and that traffic left over the plain WAN with the real
* address. Route to a group/node/chain for the former, and to the `block` TARGET
* for the latter.
*/
export interface Egress {
Name: string
Type: string // interface|direct|byedpi
Interface?: string // type=interface: the UCI interface name
Target?: string // legacy field of the removed `proxy` type; read by nothing
Port?: number // type=byedpi ONLY: the local ciadpi listen port (default 1080)
DPI?: string // type=interface|direct: off|fragment|record|spoof (byedpi desyncs itself)
}
export interface Rule {
Name: string
Enabled: boolean
Order: number
Src?: string[] | null
/**
* WHERE the traffic is going — the rule's only destination matcher. Each entry
* names a {@link Ruleset}; the rule matches when ANY of them matches.
*
* There is no inline domain or address list on a rule. `dst_domain`/`dst_ip`
* were removed in schema v2, and `shaterd migrate` folds every existing one
* into a generated `rule-<name>` ruleset, so a destination list is written and
* edited in exactly one place and compiled once into a .srs that every rule
* referencing it shares.
*/
DstRuleset?: string[] | null
DstPort?: string
/**
* Narrow the rule to one transport or one sniffed application protocol. A
* CLOSED set of 12 values — `tcp`/`udp` match the network, the other ten match
* what the sniffer labelled the connection (route.sniffedProtocols). Anything
* else builds a perfectly valid rule that can never match, so its traffic
* silently follows the rules below it. Empty = any.
*/
Proto?: string // '' | tcp | udp | tls | http | quic | dns | stun | bittorrent | dtls | ssh | rdp | ntp
Target?: string
Egress?: string
/**
* Policy for when the target can't resolve at generate time (dead group,
* broken chain, missing egress/node). The rule is always still emitted — its
* traffic never falls through to the default route. ''/'default'/'closed' and
* anything unrecognised block the traffic (fail-closed); 'open' is an explicit,
* warned kill-switch bypass that sends it direct.
*/
Kill?: string // '' | default | closed | open
SchedEnabled?: boolean
SchedDays?: string[] | null
SchedStart?: string
SchedEnd?: string
/** Minutes east of UTC anchoring SchedStart/SchedEnd/SchedDays — the panel captures the editing browser's offset on save (the router has no tzdata). */
SchedUTCOffset?: number
}
export interface Resolver {
Name: string
Type: string // doh|dot|plain|tcp|local|fakeip
Address?: string
Detour?: string
Pool?: string
}
/**
* A `config dns_rule` (model.go DNSRule) — "resolve THESE names, asked by THESE
* clients, with THAT resolver". Ordered ascending by `Order`, first match wins,
* and anything unmatched falls through to the default resolver.
*
* NOTE: a DNS rule has NO name in the contract — `Order` plus its matchers are
* its whole identity, so the UI keys rows by list index.
* MatchDomain — domain suffixes (a bare "example.com" covers subdomains)
* MatchSrc — source IP/CIDR, the same vocabulary as Rule.Src
* Resolver — the target resolver's `Resolver.Name`
*/
export interface DNSRule {
Order: number
MatchDomain?: string[] | null
MatchSrc?: string[] | null
Resolver: string
}
/** Per-device policy (a `config device`). Identity is MAC-first, else IP. */
export interface Device {
Name: string
MAC?: string
IP?: string
Enabled: boolean
Block?: string[] | null // domains blocked for this device only
Allow?: string[] | null // domains allowed for this device (overrides blocklists)
}
/**
* `config alert` — an out-of-band notification channel (Phase 7). Delivery is
* DIRECT to the internet by default so a kill-switch/engine-down alert reaches
* Telegram even when the tunnel is down. An operator can route it through a
* proxy/group/egress via `Via`; `Fallback` then retries direct if that detour
* send fails. Token/URL are SECRETS: the UI masks them but round-trips the value
* untouched (PUT sends back what GET gave).
*/
export interface Alert {
Name: string
Enabled: boolean
Type: 'telegram' | 'webhook'
Token?: string
ChatID?: string
URL?: string
// The events the daemon actually emits — every one has a live firing path, so a
// subscribed channel can never be silently dead. A retired health-probe event was
// dropped from this set for exactly that reason; the picker only offers these.
Events?: string[] | null // killswitch|apply_fail|new_device|sub_expiry
Via?: string // delivery detour: ''|direct (default) | group:<n> | node:<n> | egress:<n>
Fallback?: boolean // retry direct if the Via detour send fails
}
/** GET /api/interfaces element — one UCI network interface for the egress picker. */
export interface Interface {
name: string // UCI interface name (e.g. "lan", "wan")
device: string // resolved L3 device (e.g. "br-lan")
subnet: string // primary CIDR (e.g. "192.168.1.1/24") or ""
up: boolean // interface currently up
zone: string // fw4 firewall zone (e.g. "lan", "wan"), "" if none/unknown
}
/** GET /api/devices row — a discovered LAN client merged with its config, if any. */
export interface DiscoveredDevice {
ip: string // primary address (most recent lease)
ips: string[] // every known address (v4+v6, multiple leases), primary first
mac: string
hostname: string
online: boolean
state: string // online|idle|offline
configured: boolean
name?: string
blockCount: number
network?: string // OpenWrt network name (lan/guest); '' when unknown
iface?: string // L3 device the IP was seen on (br-lan); '' when unknown
}
/**
* The whole desired-state Model. Only the fields the panel reads today are typed;
* the rest ride through untouched on a round-trip (PUT sends back what GET gave).
* Slices are `T[] | null` because Go marshals an empty slice as JSON null.
*/
export interface Model {
Globals: Globals
Nodes?: Node[] | null
Subscriptions?: Subscription[] | null
Groups?: Group[] | null
Egresses?: Egress[] | null
Rules?: Rule[] | null
Resolvers?: Resolver[] | null
Blocklists?: Blocklist[] | null
Allowlists?: Allowlist[] | null
Devices?: Device[] | null
Chains?: Chain[] | null
Rulesets?: Ruleset[] | null
Profiles?: Profile[] | null
Inbounds?: Inbound[] | null
DNSRules?: DNSRule[] | null
Alerts?: Alert[] | null
// Anything the panel doesn't read yet rides through untouched on a round-trip.
// Keep this in step with model.Model: PUT /api/config decodes with
// DisallowUnknownFields, so an invented key fails the WHOLE write with a 400.
[k: string]: unknown
}
// --- transport --------------------------------------------------------------
/** True when the URL asks for the offline fixture backend (?mock or ?dev). */
export const MOCK: boolean = (() => {
if (typeof location === 'undefined') return false
const q = new URLSearchParams(location.search)
return q.has('mock') || q.has('dev')
})()
/** A decoded response plus the raw Headers, for endpoints whose contract puts
* pagination metadata outside the JSON body (see the stats log endpoints). */
interface Envelope<T> {
body: T
headers: Headers
}
async function reqFull<T>(path: string, init?: RequestInit): Promise<Envelope<T>> {
let res: Response
try {
res = await fetch(path, {
credentials: 'include',
headers: { Accept: 'application/json', ...(init?.headers ?? {}) },
...init,
})
} catch (e) {
// Network / DNS / CORS failure — surface as a 0-status ApiError.
throw new ApiError(0, e instanceof Error ? e.message : 'network error')
}
const text = await res.text()
let body: unknown = undefined
if (text) {
try {
body = JSON.parse(text)
} catch {
body = undefined
}
}
if (!res.ok) {
const msg =
(body && typeof body === 'object' && 'error' in body
? String((body as { error: unknown }).error)
: '') || `request failed (${res.status})`
throw new ApiError(res.status, msg)
}
return { body: body as T, headers: res.headers }
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
return (await reqFull<T>(path, init)).body
}
// --- endpoints --------------------------------------------------------------
export function getStatus(): Promise<Status> {
return MOCK ? mock.getStatus() : req<Status>('api/status')
}
export function getConfig(): Promise<Model> {
return MOCK ? mock.getConfig() : req<Model>('api/config')
}
export function putConfig(m: Model): Promise<{ ok: boolean; applied: boolean }> {
return MOCK
? mock.putConfig(m)
: req('api/config', { method: 'PUT', body: JSON.stringify(m) })
}
export function apply(): Promise<ApplyResult> {
return MOCK ? mock.apply() : req<ApplyResult>('api/apply', { method: 'POST' })
}
export function confirm(): Promise<ApplyResult> {
return MOCK ? mock.confirm() : req<ApplyResult>('api/confirm', { method: 'POST' })
}
export function rollback(): Promise<ApplyResult> {
return MOCK ? mock.rollback() : req<ApplyResult>('api/rollback', { method: 'POST' })
}
export function getStats(): Promise<Stats> {
return MOCK ? mock.getStats() : req<Stats>('api/stats')
}
// --- daemon log download ------------------------------------------------------
/** The slices GET /api/log serves. Time ranges are best-effort cuts of what the
* file still holds — `all` is "everything KEPT", not "everything that ever
* happened" (with the file on tmpfs that is at most since the last reboot). */
export type LogRange = '1d' | '3d' | 'all'
/** Pull the download filename out of a Content-Disposition header, if any. */
function dispositionFilename(h: string | null): string | null {
if (!h) return null
// filename*=UTF-8''enc — the RFC 5987 form — wins over plain filename="…".
const star = /filename\*\s*=\s*(?:UTF-8'')?([^;]+)/i.exec(h)
if (star) {
try {
return decodeURIComponent(star[1].trim().replace(/^"|"$/g, ''))
} catch {
/* fall through to the plain form */
}
}
const plain = /filename\s*=\s*"?([^";]+)"?/i.exec(h)
return plain ? plain[1].trim() : null
}
/** Hand a text blob to the browser as a file download. */
function saveBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
/**
* GET /api/log?range=1d|3d|all — download the daemon's own operational log
* (shaterd's log, NOT the DNS query log) as a text file. Session-gated like
* every other endpoint: the fetch rides the HttpOnly session cookie via
* `credentials: 'include'`. The filename comes from the response's
* Content-Disposition header, falling back to `shater-log-<range>.txt`.
*
* Honesty note the caller should mirror in copy: with file logging OFF the
* daemon still answers 200, but the body opens with a `# note:` line and holds
* at best a slice of the system ring — never treat a completed download as
* proof the file log is on.
*/
export async function downloadLog(range: LogRange): Promise<void> {
if (MOCK) {
saveBlob(new Blob([mock.getLogText(range)], { type: 'text/plain' }), `shater-log-${range}.txt`)
return
}
let res: Response
try {
res = await fetch(`api/log?range=${range}`, { credentials: 'include' })
} catch (e) {
throw new ApiError(0, e instanceof Error ? e.message : 'network error')
}
if (!res.ok) {
throw new ApiError(res.status, `log download failed (${res.status})`)
}
const name = dispositionFilename(res.headers.get('Content-Disposition'))
saveBlob(await res.blob(), name ?? `shater-log-${range}.txt`)
}
/**
* Cursor-paginated log query. All fields optional (`getStatsLog(50)` shorthand ⇒
* `{limit:50}`). With no cursor the server returns the newest `limit` rows
* (default 50, max 5000). `before=<seq>` fetches the next OLDER page (rows with
* seq < before); `after=<seq>` fetches NEW rows since that cursor (seq > after).
* The response is always a newest-first array, so the client derives its cursors
* from the rows it holds: `newest = max(seq)`, `oldest = min(seq)`.
*/
export interface StatsLogQuery {
limit?: number
before?: number
after?: number
}
function statsLogQS(q: StatsLogQuery): string {
const p = new URLSearchParams()
if (q.limit != null) p.set('limit', String(q.limit))
if (q.before != null) p.set('before', String(q.before))
if (q.after != null) p.set('after', String(q.after))
const s = p.toString()
return s ? `?${s}` : ''
}
/**
* One page of a log endpoint, body plus the cursor metadata the server reports in
* response HEADERS (the body is deliberately still a bare array).
*
* `pending` / `more` are meaningful ONLY for an `after=` request — the forward
* (live tail) direction. For a head request (no cursor) and for `before=` the
* server always reports 0 / false.
*
* Contract note, and the reason this shape exists: `after=<seq>` returns the
* OLDEST `limit` rows above the cursor — the next CONTIGUOUS chunk forward — not
* the newest ones. A client that stops after a single page therefore falls behind
* whenever a burst exceeds `limit`, and (before this contract) silently lost every
* row in between. Keep fetching while `more` is true. Row ORDER is unchanged:
* still newest-first, so `rows[0].seq` is the highest seq on the page and the
* cursor for the next request.
*/
export interface StatsLogPage<T> {
rows: T[]
pending: number // rows still newer than rows[0]; 0 when there is no backlog
more: boolean // true ⇒ call again with after = rows[0].seq
}
const PENDING_HEADER = 'X-Stats-Log-Pending'
const MORE_HEADER = 'X-Stats-Log-More'
/** Read the two cursor headers off a log response, defaulting safely when an
* older daemon doesn't send them (no headers ⇒ no backlog ⇒ single page). */
function logPage<T>(env: { body: T[] | null; headers: Headers }): StatsLogPage<T> {
const rows = env.body ?? []
const pendingRaw = Number.parseInt(env.headers.get(PENDING_HEADER) ?? '', 10)
return {
rows,
pending: Number.isFinite(pendingRaw) && pendingRaw > 0 ? pendingRaw : 0,
more: env.headers.get(MORE_HEADER) === 'true',
}
}
/** GET /api/stats/log — one page of the DNS query log with its cursor metadata. */
export function getStatsLogPage(q: StatsLogQuery = {}): Promise<StatsLogPage<QueryLogEntry>> {
return MOCK
? mock.getStatsLogPage(q)
: reqFull<QueryLogEntry[] | null>(`api/stats/log${statsLogQS(q)}`).then(logPage)
}
/** GET /api/stats/conns — one page of the connection log with its cursor metadata. */
export function getStatsConnsPage(q: StatsLogQuery = {}): Promise<StatsLogPage<ConnLogEntry>> {
return MOCK
? mock.getStatsConnsPage(q)
: reqFull<ConnLogEntry[] | null>(`api/stats/conns${statsLogQS(q)}`).then(logPage)
}
/** GET /api/stats/log — the live DNS query log, newest first. See StatsLogQuery.
* Rows only; callers that tail the stream want {@link getStatsLogPage} instead. */
export function getStatsLog(q: number | StatsLogQuery = {}): Promise<QueryLogEntry[]> {
const o: StatsLogQuery = typeof q === 'number' ? { limit: q } : q
return MOCK ? mock.getStatsLog(o) : req<QueryLogEntry[]>(`api/stats/log${statsLogQS(o)}`)
}
/** GET /api/stats/conns — the live connection-event log (device→dest), newest first. */
export function getStatsConns(q: number | StatsLogQuery = {}): Promise<ConnLogEntry[]> {
const o: StatsLogQuery = typeof q === 'number' ? { limit: q } : q
return MOCK ? mock.getStatsConns(o) : req<ConnLogEntry[]>(`api/stats/conns${statsLogQS(o)}`)
}
/**
* One routing rule's reachability verdict — the rule analogue of
* {@link ChainHealth}.used: a quiet note about the ROUTING CONFIG, never a health
* signal.
*
* `unreachable` means the rule can NEVER take effect, whatever the traffic. Today
* the daemon reports exactly one certain case, and it is a subtle one: a rule with
* no conditions at all is not matched in sequence — it becomes the router's
* default. Two such rules therefore retire each other, and the LAST one by Order
* wins, so an earlier "default → direct" is dead even though it sorts first. A
* condition-less rule never retires a rule that HAS conditions: those are matched
* ahead of the default whatever their Order.
*
* `index` is the rule's position in GET /api/config's `Rules`, which is how a
* verdict is matched to a row — rule names are not unique, and the config that
* prompted this had two rules both called `default`. `name`/`order` are echoed so
* a page holding a verdict fetched before an edit can check it still describes the
* row it is about to badge, and drop it silently otherwise.
*/
export interface RuleReach {
index: number
name: string
order: number
unreachable: boolean
/** The rule that supersedes this one; absent when `unreachable` is false. */
shadowed_by?: string
/** Its index in `Rules`, or -1 when there is none. */
shadowed_by_index: number
shadowed_by_order?: number
/** Operator-facing sentence; absent when `unreachable` is false. */
reason?: string
/**
* Whether the rule is IN FORCE right now — `Rule.Enabled` after the active WAN
* profile's overrides. This is NOT `GET /api/config`'s `Enabled`: that one is
* the desired state the page PUTs back, and on a router with profiles the two
* legitimately disagree. Draw rows from this; keep the switch on the other.
*/
effective_enabled: boolean
/** The active profile that CHANGED this rule's state; absent when none did. */
overridden_by?: string
/** Which way it went. Absent together with `overridden_by`. */
override?: 'enabled' | 'disabled'
}
/** GET /api/rules/reachability. `rules` is ALWAYS an array, one entry per rule in
* the same order as GET /api/config's `Rules`. */
export interface RulesReachability {
rules: RuleReach[]
}
/** GET /api/rules/reachability — which routing rules can never fire, and why. */
export function getRulesReachability(): Promise<RulesReachability> {
return MOCK ? mock.getRulesReachability() : req<RulesReachability>('api/rules/reachability')
}
/** GET /api/ruleset/status — remote rule-set / blocklist freshness + rule counts. */
export function getRulesetStatus(): Promise<RulesetStatus[]> {
return MOCK ? mock.getRulesetStatus() : req<RulesetStatus[]>('api/ruleset/status')
}
/**
* POST /api/ruleset/update — force an immediate re-fetch of one remote list.
* `tag` is the engine tag (`rs-<name>` / `bl-<name>` / `al-<name>`). Resolves to
* the refreshed status entry (or `{ok:true}`); rejects as an ApiError on 404
* (unknown tag), 409/503 (engine stopped), or 400 (bad body).
*/
export function updateRuleset(tag: string): Promise<RulesetStatus | { ok: boolean }> {
return MOCK
? mock.updateRuleset(tag)
: req('api/ruleset/update', { method: 'POST', body: JSON.stringify({ tag }) })
}
/**
* POST /api/ruleset/check result — did a geosite category / geoip country code
* resolve to a published SagerNet .srs list?
* - `ok:true` → it exists; `url` is the resolved .srs URL. Save it.
* - `reason:'not_found'` → no such category/code (HTTP 404). Block the save.
* - `reason:'network'` → couldn't verify (router offline / origin down /
* timeout); `error` carries a short detail. Let the user save anyway.
*/
export interface RulesetCheck {
ok: boolean
url?: string
reason?: 'not_found' | 'network'
error?: string
}
/**
* POST /api/ruleset/check — verify a geosite/geoip category BEFORE saving it, so a
* typo can't silently create a dead (never-matching) list. `source` is
* 'geosite' | 'geoip'; `category` is the sing-geosite category or ISO country code.
* Resolves to a {@link RulesetCheck}; a bad source / empty category surfaces as an
* ApiError (HTTP 400). Under `?mock` a small allow-list decides the outcome.
*/
export function checkRulesetCategory(source: string, category: string): Promise<RulesetCheck> {
return MOCK
? mock.checkRulesetCategory(source, category)
: req<RulesetCheck>('api/ruleset/check', {
method: 'POST',
body: JSON.stringify({ source, category }),
})
}
/**
* GET /api/ruleset/categories reply — the auto-suggest list of published
* sing-geosite categories / sing-geoip country codes for one `source`. `error` is
* `'network'` only when the daemon couldn't fetch AND had no cache, in which case
* `categories` is empty and the caller simply shows no suggestions.
*/
export interface RulesetCategories {
source: string
categories: string[]
error?: 'network'
}
/**
* GET /api/ruleset/categories?source=geosite|geoip — the real category list for the
* Category field's `<datalist>` auto-suggest. The daemon caches the list (24h) and
* serves it stale-on-error; a fetch failure with no cache resolves to
* `{categories:[], error:'network'}` (no suggestions, input stays free). A bad
* source surfaces as an ApiError (HTTP 400). Under `?mock` a small static list is
* returned so the datalist works offline.
*/
export function getRulesetCategories(source: string): Promise<RulesetCategories> {
return MOCK
? mock.getRulesetCategories(source)
: req<RulesetCategories>(`api/ruleset/categories?source=${encodeURIComponent(source)}`)
}
/** GET /api/devices — discovered LAN clients merged with per-device config. */
export function getDevices(): Promise<DiscoveredDevice[]> {
return MOCK ? mock.getDevices() : req<DiscoveredDevice[]>('api/devices')
}
/** GET /api/interfaces — the router's UCI network interfaces for the egress picker. */
export function getInterfaces(): Promise<Interface[]> {
return MOCK ? mock.getInterfaces() : req<Interface[]>('api/interfaces')
}
/** POST /api/session — exchange a single-use handoff token for a session cookie. */
export function postSession(token: string): Promise<{ ok: boolean }> {
return MOCK
? Promise.resolve({ ok: true })
: req('api/session', { method: 'POST', body: JSON.stringify({ token }) })
}
/**
* POST /api/import-wg — convert a raw wg-quick / AmneziaWG `.conf` into a
* `wireguard://` share URI the node list can hold. Success → `{uri, name}`;
* a malformed conf comes back as a 400 ApiError carrying a human message.
* Under `?mock` it synthesises a URI from the conf's Endpoint so the offline
* demo can exercise the whole paste→save→apply flow.
*/
export function importWg(conf: string): Promise<{ uri: string; name: string }> {
if (MOCK) {
const endpoint = /Endpoint\s*=\s*(\S+)/i.exec(conf)?.[1] ?? 'wg.example:51820'
const host = endpoint.split(':')[0].replace(/[^\w.-]+/g, '') || 'peer'
return Promise.resolve({ uri: `wireguard://mock@${endpoint}#wg-${host}`, name: `wg-${host}` })
}
return req('api/import-wg', { method: 'POST', body: JSON.stringify({ conf }) })
}
/**
* POST /api/subscription/update — fetch one subscription now and refresh its
* nodes. The daemon pulls through the tunnel when the sub's `FetchVia==='proxy'`
* (+ `FetchDetour`), so this makes proxy-fetch usable from the panel (the CLI
* fetches direct). Resolves to `{ added }` — how many nodes the refresh produced;
* rejects as an ApiError on 404 (unknown sub), 409/503 (engine stopped) or a
* fetch/parse failure carrying a human message.
*/
export function updateSubscription(name: string): Promise<{ added: number }> {
return MOCK
? mock.updateSubscription(name)
: req('api/subscription/update', { method: 'POST', body: JSON.stringify({ name }) })
}
/**
* GET /api/groups/health — per-group membership health (see {@link GroupHealth}).
*
* A pure READ of health the engine already collected: it never dials, so the
* summary is cheap enough to poll every few seconds.
*
* getGroupsHealth() → summary only, no `members` key
* getGroupsHealth({ members: true }) → members for EVERY group (tens of KB on a
* 376-node subscription — avoid)
* getGroupsHealth({ group: 'auto' }) → that one group, ALWAYS with members;
* rejects as a 404 ApiError when the
* running engine has no such group.
*/
export function getGroupsHealth(
opts: { group?: string; members?: boolean } = {},
): Promise<GroupsHealth> {
if (MOCK) return mock.getGroupsHealth(opts)
const p = new URLSearchParams()
if (opts.group) p.set('group', opts.group)
if (opts.members) p.set('members', '1')
const qs = p.toString()
return req<GroupsHealth>(`api/groups/health${qs ? `?${qs}` : ''}`)
}
/**
* One group's (or chain's) last test: which member the balancer picked, how fast
* it answered, and what the internet saw as the source address.
*
* `ok:true` with an EMPTY `exit_ip`/`exit_country` is a valid, successful result,
* not a partial failure: the delay was measured but the exit address could not be
* determined (the lookup service was unreachable, or the answer wasn't parseable).
* Render it as a success with an unknown address — never as an error.
*
* Chains ride the same endpoint. For a chain row, `group` carries the CHAIN's
* name and `selected` the node its last group hop picked ('' when the exit hop
* isn't a group). Everything else reads the same way.
*
* `ok:false` ⇒ the test failed and `error` carries the human reason; every other
* field is meaningless. `tested_unix` is the router's clock, in seconds.
*/
export interface GroupTestResult {
group: string // group name — or a chain name for a chain row
selected: string // the member node the group (or the chain's exit group) chose
delay_ms: number
exit_ip: string // may be '' even when ok
exit_country: string // ISO code; may be '' even when ok
ok: boolean
error: string // '' when ok
tested_unix: number
}
/**
* GET /api/groups/test — progress plus every result so far. `results` is ALWAYS
* an array (never null); `done`/`total` count finished vs targeted groups and
* chains while `running` is true. Idle reads `{running:false}` with the last
* run's results still attached, so a reload after a test still shows what it found.
*/
export interface GroupTestStatus {
running: boolean
done: number
total: number
/**
* The group and chain names THIS run covers. Always an array (never JSON
* null); absent only on daemons older than the split.
*
* It is what makes `running` usable. On its own that flag says only "a group
* test is happening somewhere", which is why pressing Test on one group used to
* put "measuring…" on every card. The rule: show the in-progress indicator on
* card g iff `running && scope.includes(g)`. A run started with a name carries
* exactly that name; a run started with no name carries every group and
* every chain, and then the indicator on every card is correct. The scope
* PERSISTS after the run ends, so displayed results stay attributable to the
* cards they came from.
*/
scope?: string[]
results: GroupTestResult[]
}
/**
* POST /api/groups/test reply. `started:false` means nothing was kicked off and
* `reason` says why ("already running" for the singleton case).
*/
export interface GroupTestStart {
started: boolean
reason?: string
}
/**
* POST /api/groups/test — measure a target's delay and exit address. Pass a
* group or chain name to test one; pass nothing (or '') to test every group
* and every chain. Singleton: a second call while a run is in flight resolves
* to `{started:false, reason:'already running'}` rather than failing.
*/
export function postGroupsTest(name = ''): Promise<GroupTestStart> {
return MOCK
? mock.postGroupsTest(name)
: req<GroupTestStart>('api/groups/test', { method: 'POST', body: JSON.stringify({ name }) })
}
/** GET /api/groups/test — progress + results of the current/last group test. */
export function getGroupsTest(): Promise<GroupTestStatus> {
return MOCK ? mock.getGroupsTest() : req<GroupTestStatus>('api/groups/test')
}