diff --git a/install.sh b/install.sh index b354b703..5c28bd7d 100755 --- a/install.sh +++ b/install.sh @@ -20,7 +20,9 @@ LOG_FILE="/var/log/monitor-agent.log" SERVER="" TOKEN="" REGISTER="" -INTERVAL=1 +IFACE="" +IFACE_SET="" +INTERVAL="" INSECURE="" UNINSTALL="" @@ -28,13 +30,14 @@ while [ $# -gt 0 ]; do # A flag with no argument: under set -u, `$2` aborts with the shell's own # message rather than the usage below, and `shift 2` cannot proceed. case "$1" in - --server | --token | --register | --interval) + --server | --token | --register | --iface | --interval) [ $# -ge 2 ] || { echo "$1 needs a value" >&2; exit 2; } ;; esac case "$1" in --server) SERVER="$2"; shift 2 ;; --token) TOKEN="$2"; shift 2 ;; --register) REGISTER="$2"; shift 2 ;; + --iface) IFACE="$2"; IFACE_SET=1; shift 2 ;; --interval) INTERVAL="$2"; shift 2 ;; --insecure) INSECURE=1; shift ;; --uninstall) UNINSTALL=1; shift ;; @@ -63,12 +66,44 @@ if [ -n "$UNINSTALL" ]; then fi [ -n "$SERVER" ] && { [ -n "$TOKEN" ] || [ -n "$REGISTER" ]; } || { - echo "usage: install.sh --server URL (--token TOKEN | --register KEY) [--interval SECONDS] [--insecure]" >&2 + echo "usage: install.sh --server URL (--token TOKEN | --register KEY) [--interval SECONDS] [--iface LIST] [--insecure]" >&2 echo " install.sh --uninstall" >&2 exit 2 } +# A setting of this machine, kept by a rerun without the flag for the reason +# given for --iface below: the batch command carries none. It is read back from +# the service definition the last install wrote; a first install takes 1. +if [ -z "$INTERVAL" ]; then + INTERVAL=$(cat "$UNIT_FILE" "$RC_FILE" 2>/dev/null | sed -n \ + -e 's/^ExecStart=.* --interval \([0-9][0-9]*\).*/\1/p' \ + -e 's/^command_args="--interval \([0-9][0-9]*\).*/\1/p' | tail -n 1) + if [ -n "$INTERVAL" ]; then echo "keeping --interval $INTERVAL from the previous install"; else INTERVAL=1; fi +fi case "$INTERVAL" in "" | *[!0-9]*) echo "interval must be an integer from 1 to 3600" >&2; exit 2 ;; esac [ "$INTERVAL" -ge 1 ] && [ "$INTERVAL" -le 3600 ] || { echo "interval must be from 1 to 3600" >&2; exit 2; } +# Which interfaces carry this machine's traffic is known only on the machine, +# and the batch command a fleet shares cannot carry one value per machine. A +# rerun without --iface, the documented upgrade, therefore keeps the value in +# the env file; --iface '' clears it. +# +# A kept value is written back as found, the last assignment being the one +# systemd and OpenRC apply: root wrote it, both already read it, and a hand edit +# with quotes must not block every later upgrade. A value given here is held to +# what the agent accepts -- full names separated by commas, each optionally led +# by one `-` -- since the agent refuses anything else at startup and would +# restart forever while this script reported success. The character set also +# keeps it inert where OpenRC sources the file as shell. +if [ -z "$IFACE_SET" ]; then + IFACE=$(sed -n 's/^MONITOR_IFACE=//p' "$ENV_FILE" 2>/dev/null | tail -n 1) + [ -z "$IFACE" ] || echo "keeping --iface $IFACE from the previous install" +else + case ",$IFACE," in + *[!A-Za-z0-9._,-]* | *,-,* | *,--*) + echo "--iface takes full interface names separated by commas, each optionally led by -, not: $IFACE" >&2 + exit 2 + ;; + esac +fi # A bare host implies TLS, matching the upgrade the agent's ws_url() performs, # and the same reversal under --insecure where the hub has no TLS to upgrade to. # Without this the two diverge: the agent would dial wss:// while curl below @@ -233,6 +268,7 @@ install -m 0755 "$TMP" "$BIN" MONITOR_SERVER=$SERVER MONITOR_TOKEN=$TOKEN ENV + [ -z "$IFACE" ] || printf 'MONITOR_IFACE=%s\n' "$IFACE" >>"$ENV_FILE" ) if [ "$INIT" = openrc ]; then diff --git a/src/db.rs b/src/db.rs index bd816fca..4a85ce8b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -882,9 +882,10 @@ impl Db { /// Folds one report's raw kernel counters into the node's running totals. /// - /// A changed boot_id, or a counter that moved backwards, means the kernel - /// restarted its counting; the total must not follow it downward. `None` - /// denotes a report carrying no readable counters at all -- see below. + /// A changed boot_id, or a counter that moved backwards, means the readings + /// no longer continue the previous ones; the total must not follow them + /// downward. `None` denotes a report carrying no readable counters at all -- + /// see below. /// /// The billing reset day is read here rather than passed in: it is one join /// from a row this already reads, and fetching it separately would cost every @@ -935,7 +936,9 @@ impl Db { // first report has none. A reading that shrank under the same boot lost // one -- an interface included in the sum has disappeared -- so the // reading is the remainder of that history and booking it would count it - // twice. A changed boot_id means the counters restarted or, + // twice. A changed boot_id means the counters restarted, that the agent + // now sums a different set of interfaces (it appends a digest of them, + // so a device joining the sum is caught as well as one leaving it), or, // indistinguishably from here, that a second machine shares the token. // Realigning costs the seconds since the reboot; the alternative costs // hundreds of gigabytes against a total that only increases. @@ -946,11 +949,13 @@ impl Db { let (d_rx, d_tx) = match counters { None => (0, 0), Some(_) if prev_boot.is_empty() || prev_boot != boot_id => { - // Logged in either case: on a healthy node this is a reboot, - // while one every few seconds indicates two machines sharing a - // token. + // Logged in either case: on a healthy node this is a reboot or + // the agent summing a different set of interfaces, while one + // every few seconds indicates two machines sharing a token or + // counted interfaces coming and going. The value stays out of + // the log: it is the agent's text. if !prev_boot.is_empty() { - info!("node {node_id} reports a new boot; re-aligning"); + info!("node {node_id} reports a new boot_id; re-aligning"); } (0, 0) } diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index fb9bbc3a..870fc913 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -12,7 +12,7 @@ import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { addresses, api, changes, GIB, provisioningSite, trafficCorrection, upload, type Node, type PingTask, type Source } from "@/lib/api" +import { addresses, api, badIfaceName, changes, currentIface, GIB, ifaceChoice, ifaceSpec, provisioningSite, trafficCorrection, upload, type IfaceChoice, type Node, type PingTask, type Source } from "@/lib/api" import { bytes, CYCLES, FOREVER, money, uptime } from "@/lib/format" // Counters the panel can correct after migration or an accounting error. @@ -157,22 +157,28 @@ function Field({ label, hint, className = "", children }: { label: string; hint? } // A titled option with its control at the right. `toggle` makes the whole row a -// label, so a click anywhere flips the Switch it holds. -function OptionRow({ title, hint, toggle = false, children }: { +// label, so a click anywhere flips the Switch it holds; `below` opens beneath it +// in the same card. +function OptionRow({ title, hint, toggle = false, below, children }: { title: React.ReactNode hint?: React.ReactNode toggle?: boolean + below?: React.ReactNode children: React.ReactNode }) { const Row = toggle ? "label" : "div" return ( - - - {title} - {hint && {hint}} - - {children} - +
+ {/* flex-1: stretched by a grid, the row fills the card and stays clickable. */} + + + {title} + {hint && {hint}} + + {children} + + {below &&
{below}
} +
) } @@ -505,16 +511,22 @@ function scriptCommand(site: string, args: (site: string) => string[]) { // Built here rather than fetched: the node list already carries the token, so // viewing an install command is a read rather than an action. Reissuing one to // display it would take the running agent offline. -function installCommand(site: string, token: string, seconds: number) { - return scriptCommand(site, (s) => [`--server ${s}`, `--token ${token}`, `--interval ${seconds}`]) +function installCommand(site: string, token: string, seconds: number, iface: string | undefined) { + return scriptCommand(site, (s) => [`--server ${s}`, `--token ${token}`, `--interval ${seconds}`, ...ifaceArg(iface)]) +} + +// '' is how install.sh is told to clear a value it would otherwise keep; a +// name needs no quoting, as ifaceSpec admits no shell metacharacter. +function ifaceArg(iface: string | undefined) { + return iface === undefined ? [] : [`--iface ${iface || "''"}`] } // One command for a batch of machines. The key belongs to the hub, is valid only // within the window it opened, and each machine exchanges it for a token of its // own, so unlike an install command this text is no one's credential and can be // used directly in a loop. -function registerCommand(site: string, key: string) { - return scriptCommand(site, (s) => [`--server ${s}`, `--register ${key}`]) +function registerCommand(site: string, key: string, iface: string | undefined) { + return scriptCommand(site, (s) => [`--server ${s}`, `--register ${key}`, ...ifaceArg(iface)]) } // Carries no token, so it is the same for every node and remains valid after the @@ -568,7 +580,8 @@ function RegisterDialog({ site, reg, onClose }: { reg: ReturnType onClose: () => void }) { - const command = reg.left > 0 ? registerCommand(site, reg.key) : "" + const iface = useIfaceOption(undefined) + const command = reg.left > 0 && iface.valid ? registerCommand(site, reg.key, iface.flag) : "" const clock = `${Math.floor(reg.left / 60)}:${String(reg.left % 60).padStart(2, "0")}` return ( @@ -577,18 +590,26 @@ function RegisterDialog({ site, reg, onClose }: { 批量添加 -
+
+ {/* One string: JSX turns a line break inside CJK text into a visible space. */}

- 开一个一小时的注册窗口。期间这条命令在任意机器上跑一次,那台机器就会自己出现在 - 列表里,名字取自它的 hostname。命令里没有任何一台机器的凭证,可以直接进循环。 + {"开一个一小时的注册窗口。期间这条命令在任意机器上跑一次,那台机器就会自己出现在列表里," + + "名字取自它的 hostname。命令里没有任何一台机器的凭证,可以直接进循环。"}

- {command ? ( - - {command} +
+

安装选项

+ +
+ {reg.left > 0 ? ( +
+

安装命令

+ + {command || "网卡名有误,改正后显示命令"} + - +
) : ( )} @@ -604,6 +625,66 @@ function RegisterDialog({ site, reg, onClose }: { ) } +// The `--iface` part of an install command. Off leaves the flag out, and +// install.sh then keeps whatever the machine already has; on with both lists +// empty passes '' and restores the default rules. It opens on for a node whose +// agent reports a list, so reinstalling from here repeats that list rather than +// relying on the machine to remember it. +function useIfaceOption(current: string | undefined) { + const [on, setOn] = useState(!!current) + const [choice, setChoice] = useState(() => ifaceChoice(current ?? "")) + const bad = on ? badIfaceName(choice) : undefined + const spec = ifaceSpec(choice) + return { on, setOn, choice, setChoice, current, bad, valid: !bad, flag: on && spec !== null ? spec : undefined } +} + +function describeIface(spec: string) { + const { only, skip } = ifaceChoice(spec) + const parts = [only && `只统计 ${only.replaceAll(",", "、")}`, skip && `不统计 ${skip.replaceAll(",", "、")}`] + return parts.filter(Boolean).join(";") || "默认规则" +} + +function IfaceOption({ option, batch = false }: { option: ReturnType; batch?: boolean }) { + const { on, setOn, choice, setChoice, current, bad } = option + const field = (list: keyof IfaceChoice, label: string, placeholder: string) => ( + + setChoice({ ...choice, [list]: e.target.value })} + placeholder={placeholder} + spellCheck={false} + aria-invalid={bad?.list === list} + className="bg-background font-mono text-xs" + /> + + ) + const hint = on + ? batch ? "每台机器都按这里的设置统计" : "覆盖这台机器原有的设置" + : batch ? "关闭时各台机器沿用原有设置,新机器按默认规则" : "关闭时沿用机器上原有的设置,转发流量的机器才需要指定" + return ( + {hint}{current !== undefined && 当前:{describeIface(current)}}} + toggle + below={on && ( +
+
+ {field("only", "只统计", "如 WAN 口 eth1 或 pppoe-wan")} + {field("skip", "不统计", "如 LAN 口 eth0")} +
+

+ {bad + ? `「${bad.name}」不是有效的网卡名:写完整的名字,多个用逗号分隔` + : "写完整的网卡名,多个用逗号分隔。两项都留空即恢复默认规则。"} +

+
+ )} + > + +
+ ) +} + function InstallDialog({ node, site, onClose, onRotated }: { node: Node site: string @@ -614,9 +695,10 @@ function InstallDialog({ node, site, onClose, onRotated }: { const [interval, setInterval] = useState("1") const [rotating, setRotating] = useState(false) const [confirmRotate, setConfirmRotate] = useState(false) + const iface = useIfaceOption(currentIface(node)) const seconds = Math.min(3600, Math.max(1, Math.round(Number(interval) || 1))) - const command = token ? installCommand(site, token, seconds) : "" + const command = token && iface.valid ? installCommand(site, token, seconds, iface.flag) : "" async function rotate() { setRotating(true) @@ -640,16 +722,32 @@ function InstallDialog({ node, site, onClose, onRotated }: { {node.name}
- - setInterval(e.target.value)} /> - - - - {/* A node added before the hub kept tokens has nothing to show - until one is reissued. */} - {command || "旧版本创建的凭证不可读取,换发后显示"} +
+

安装选项

+ + + {/* Text rather than number: no spinner arrows, and no wheel + changing the value under a passing scroll. */} + setInterval(e.target.value.replace(/\D/g, ""))} + aria-label="上报间隔(秒)" + className="tnum h-8 w-20 bg-background text-right" + /> + 秒 + + + +
+
+

安装命令

+ {/* A node added before the hub kept tokens has nothing to show + until one is reissued. */} + + {command || (token ? "网卡名有误,改正后显示命令" : "旧版本创建的凭证不可读取,换发后显示")} - +