Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,24 @@ LOG_FILE="/var/log/monitor-agent.log"
SERVER=""
TOKEN=""
REGISTER=""
INTERVAL=1
IFACE=""
IFACE_SET=""
INTERVAL=""
INSECURE=""
UNINSTALL=""

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 ;;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 13 additions & 8 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}
Expand Down
162 changes: 130 additions & 32 deletions web-admin/src/components/Admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 (
<Row className={`flex items-center justify-between gap-4 rounded-lg border bg-muted/30 px-3 py-2.5 text-sm ${toggle ? "cursor-pointer" : ""}`}>
<span>
<span className="block font-medium">{title}</span>
{hint && <span className="mt-0.5 block text-xs text-muted-foreground">{hint}</span>}
</span>
{children}
</Row>
<div className="flex flex-col rounded-lg border bg-muted/30 text-sm">
{/* flex-1: stretched by a grid, the row fills the card and stays clickable. */}
<Row className={`flex flex-1 items-center justify-between gap-4 px-3 py-2.5 ${toggle ? "cursor-pointer" : ""}`}>
<span>
<span className="block font-medium">{title}</span>
{hint && <span className="mt-0.5 block text-xs text-muted-foreground">{hint}</span>}
</span>
{children}
</Row>
{below && <div className="border-t px-3 pt-3 pb-3.5">{below}</div>}
</div>
)
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -568,7 +580,8 @@ function RegisterDialog({ site, reg, onClose }: {
reg: ReturnType<typeof useRegisterWindow>
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 (
Expand All @@ -577,18 +590,26 @@ function RegisterDialog({ site, reg, onClose }: {
<DialogHeader>
<DialogTitle>批量添加</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-5">
{/* One string: JSX turns a line break inside CJK text into a visible space. */}
<p className="text-sm text-muted-foreground">
开一个一小时的注册窗口。期间这条命令在任意机器上跑一次,那台机器就会自己出现在
列表里,名字取自它的 hostname。命令里没有任何一台机器的凭证,可以直接进循环。
{"开一个一小时的注册窗口。期间这条命令在任意机器上跑一次,那台机器就会自己出现在列表里," +
"名字取自它的 hostname。命令里没有任何一台机器的凭证,可以直接进循环。"}
</p>
{command ? (
<Field label="安装命令">
<Command className="h-24">{command}</Command>
<section className="space-y-3">
<h3 className="text-sm font-medium">安装选项</h3>
<IfaceOption option={iface} batch />
</section>
{reg.left > 0 ? (
<section className="space-y-2 border-t pt-5">
<h3 className="text-sm font-medium">安装命令</h3>
<Command className={`max-h-40 min-h-24 ${command ? "" : "text-muted-foreground"}`}>
{command || "网卡名有误,改正后显示命令"}
</Command>
<OptionRow title={`窗口 ${clock} 后自动关闭`} hint="到点自动失效,装完了也可以现在就关">
<Button variant="outline" size="sm" onClick={reg.close}>立即关闭</Button>
</OptionRow>
</Field>
</section>
) : (
<Button onClick={reg.open}>开启一小时窗口</Button>
)}
Expand All @@ -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>(() => 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<typeof useIfaceOption>; batch?: boolean }) {
const { on, setOn, choice, setChoice, current, bad } = option
const field = (list: keyof IfaceChoice, label: string, placeholder: string) => (
<Field label={label}>
<Input
value={choice[list]}
onChange={(e) => setChoice({ ...choice, [list]: e.target.value })}
placeholder={placeholder}
spellCheck={false}
aria-invalid={bad?.list === list}
className="bg-background font-mono text-xs"
/>
</Field>
)
const hint = on
? batch ? "每台机器都按这里的设置统计" : "覆盖这台机器原有的设置"
: batch ? "关闭时各台机器沿用原有设置,新机器按默认规则" : "关闭时沿用机器上原有的设置,转发流量的机器才需要指定"
return (
<OptionRow
title="指定统计的网卡"
hint={<>{hint}{current !== undefined && <span className="mt-0.5 block">当前:{describeIface(current)}</span>}</>}
toggle
below={on && (
<div className="space-y-2.5">
<div className="grid gap-3 sm:grid-cols-2">
{field("only", "只统计", "如 WAN 口 eth1 或 pppoe-wan")}
{field("skip", "不统计", "如 LAN 口 eth0")}
</div>
<p className={`text-xs leading-relaxed ${bad ? "text-destructive" : "text-muted-foreground"}`}>
{bad
? `「${bad.name}」不是有效的网卡名:写完整的名字,多个用逗号分隔`
: "写完整的网卡名,多个用逗号分隔。两项都留空即恢复默认规则。"}
</p>
</div>
)}
>
<Switch checked={on} onCheckedChange={setOn} />
</OptionRow>
)
}

function InstallDialog({ node, site, onClose, onRotated }: {
node: Node
site: string
Expand All @@ -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)
Expand All @@ -640,16 +722,32 @@ function InstallDialog({ node, site, onClose, onRotated }: {
<DialogTitle>{node.name}</DialogTitle>
</DialogHeader>
<div className="space-y-5">
<Field label="上报间隔(秒)" hint="1–3600,默认 1 秒">
<Input type="number" min={1} max={3600} value={interval} onChange={(e) => setInterval(e.target.value)} />
</Field>
<Field label="安装命令">
<Command className="h-28">
{/* A node added before the hub kept tokens has nothing to show
until one is reissued. */}
{command || "旧版本创建的凭证不可读取,换发后显示"}
<section className="space-y-3">
<h3 className="text-sm font-medium">安装选项</h3>
<OptionRow title="上报间隔" hint="1–3600 秒,默认 1 秒">
<span className="flex shrink-0 items-center gap-2 text-muted-foreground">
{/* Text rather than number: no spinner arrows, and no wheel
changing the value under a passing scroll. */}
<Input
inputMode="numeric"
value={interval}
onChange={(e) => setInterval(e.target.value.replace(/\D/g, ""))}
aria-label="上报间隔(秒)"
className="tnum h-8 w-20 bg-background text-right"
/>
秒
</span>
</OptionRow>
<IfaceOption option={iface} />
</section>
<section className="space-y-2 border-t pt-5">
<h3 className="text-sm font-medium">安装命令</h3>
{/* A node added before the hub kept tokens has nothing to show
until one is reissued. */}
<Command className={`max-h-40 min-h-24 ${command ? "" : "text-muted-foreground"}`}>
{command || (token ? "网卡名有误,改正后显示命令" : "旧版本创建的凭证不可读取,换发后显示")}
</Command>
</Field>
</section>
<OptionRow title="换发凭证" hint="旧凭证立即作废,agent 掉线,需用新命令重装">
<Button variant="outline" size="sm" disabled={rotating} onClick={() => setConfirmRotate(true)}>
换发
Expand Down
Loading
Loading