Commit Graph
55 Commits
Author SHA1 Message Date
bluebluesoda e3c2b851e4 feat(web-ssh): a small key bar, and fix the paste shortcuts
The key bar carries only what a browser will not hand over: Ctrl+C, Ctrl+Z,
Ctrl+D, Escape, Tab and the arrows. It is a fallback, not the way to use the
terminal — typing works for everything else, and the bar deliberately does not
take focus so the keyboard keeps it. The arrow buttons ask the emulator for the
sequence, so they follow the mode a full-screen program requested just like the
real keys do.

Paste was broken on Windows and Linux. Ctrl+V fell through to the control-key
mapping and sent 0x16 with preventDefault, so the browser never got to fire a
paste event, and Shift+Insert did the same with the Insert sequence. Both, and
Ctrl+Shift+Insert, are now left to the platform; Cmd+V on macOS already worked.

The control keys were wrong in a way only running it showed: a control byte
cannot be written into an HTML attribute, so data-raw="\u0003" arrived as
six literal characters and the buttons typed them instead of sending them.
They carry a name now and the byte is looked up in JavaScript.

Verified against a real container: the bar's bytes (003, 032, 004, 033, ESC[A-D),
arrows inside less, Tab completion, Ctrl+V and Shift+Insert pasting, Enter
running a two-line paste, and Ctrl+C copying when something is selected.
2026-09-17 12:44:36 +00:00
bluebluesoda 5e28fc2983 feat(web-ssh): make the terminal survive a bad connection, and gate it
A browser reaching this panel is often a long way away, so the session is now
built to survive the link rather than be torn down by it.

Liveness first, because the old numbers were wrong: pings every 20s with a 60s
deadline. The previous setting was a single 15-minute timer, and since the
browser answers pings automatically it never fired for an idle user — the one
thing it did do was take fifteen minutes to notice a peer that had vanished.

- the shell outlives the socket. When the client goes away the PTY keeps running
  for 120s with its output landing in a 256 KiB ring (with a notice if anything
  is dropped). A client that comes back with its token resumes the same shell, so
  a tunnel, a cell handover or a sleeping laptop no longer costs a running build.
  The token travels in the first frame rather than the URL, is bound to the user,
  and lives in sessionStorage — reloading resumes, closing the window lets go.
- the page has its own heartbeat: a browser cannot see protocol-level pings, so
  the server also sends a small JSON one and the page reconnects with exponential
  backoff and jitter after 45s of silence, up to eight attempts.
- a handshake that neither completes nor fails would otherwise stall the retry
  loop for good, so an attempt that has not opened in 15s is abandoned.
- at the per-user cap the client is offered its slots back instead of being
  refused, which is the only way to reclaim a window the user can no longer see.
  Where there is room, the count is just a notice.

Three bugs the browser testing turned up, all of them real:

- Pty writes had no deadline, so a wedged Incus connection blocked the handler
  forever — resume hung after any pause long enough to break that socket.
- a chunk whose write failed was neither delivered nor buffered.
- a session stayed resumable between Close and the end of its drain.

An operator can now turn the whole thing off with `vps config set panel.web_ssh
false`: the button is not rendered and the page, the script and the connection
all refuse. It defaults on, including for a config written before the setting
existed.
2026-09-17 12:13:02 +00:00
bluebluesodaandCommandCodeBot bbc90fbf74 fix(bandwidth): name the right month in the quota label; make the reset-day change explicit
Two problems with `panel.bandwidth_reset_day`, both around its interaction
with the quota period:

- The user panel hardcoded "至次月 N 号", which is wrong whenever the reset day
  is still ahead of today: the period in force started on that day of the
  PREVIOUS month, so it ends on that day of THIS one (reset day 22 on the 16th
  ends on the 22nd of this month). mgr.BandwidthNextReset derives the real
  moment, the page flags whether it lands in this calendar month, and the label
  follows. A test also pins it against BandwidthPeriod, so the label and the
  rollover can never disagree about the boundary.

- Changing the day DISCARDS every user's accumulated bandwidth totals. That is
  not an explicit reset: the counters are keyed by a period derived from the
  day, so moving the day moves the key and the next sampler pass sees a new
  period. It stays that way (it is the period model), but it is no longer
  silent: the key is classified ApplyDestructive, so `vps config set` shows a
  confirmation naming the loss, repeats it on the --no-apply path (where the
  loss lands at the next panel restart instead), and prints it after applying.
  docs/configuration.md explains the mechanism and points at the admin panel's
  per-user "重置流量统计" button for a deliberate reset.

TestApplyBandwidthPeriodChangeDiscardsTotals pins the discard so the warning
cannot quietly become wrong, and TestBandwidthResetDayIsDestructive keeps the
classification from regressing to a plain restart.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-16 13:39:39 +00:00
bluebluesodaandCommandCodeBot 8d2d31041f docs(ipv6): pin the pre-responder ndppd upgrade path
A host can sit on a pre-1.6 release (distro ndppd, old oneshot vps-ipv6.service)
for a long time before upgrading, so document and lock down how install.sh
converges it:

- the old ndppd/npd6 daemons are disabled and stopped, the unit is replaced by
  the in-tree responder, and the legacy per-address kernel proxy_ndp entries
  are swept on ipv6-reapply;
- /etc/vpsmgr/ndppd.conf is read AS-IS, so the nested `iface <bridge>` block
  and its indentation written by the old panel must keep parsing. Adds
  TestLoadRulesAcceptsPreResponderFile to a regression guard, since every
  existing container would lose inbound IPv6 if that format ever diverged;
- the old sudoers grants disappear with the rewritten whitelist, and the
  distro ndppd package is left installed but inert.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-16 13:07:13 +00:00
bluebluesodaandCommandCodeBot 4c93ae4be3 feat(haproxy): replace Traefik with a pinned HAProxy domain proxy
The panel now fronts user domains with HAProxy 3.4 LTS (official performance
packages) instead of Traefik: about 100 MB of RSS and 200 MB of disk saved for
a feature set that never used more than Host routing on :80 and SNI passthrough
on :443.

- hpx: publish the proxy configuration as immutable GENERATIONS. Every domain
  mutation renders the whole set from the DB, validates it with the pinned
  `haproxy -c`, and only then repoints the `current` symlink, so a rejected
  candidate leaves the running proxy untouched and a stale domain can never
  survive into a later generation. Routing is carried by two map files
  (Host -> backend, SNI -> backend): per-domain frontend rules cannot work from
  a second file because HAProxy rejects duplicate section names since 3.3 and
  attaches bare directives to the section opened last.
- mgr: republish and reload (SIGUSR2, i.e. `systemctl reload`) after every
  domain change, so a change takes effect without dropping established HTTP or
  TLS connections. Rollback semantics (DB first, then publish) are unchanged.
- cfg,db: `net.traefik` is renamed to `net.haproxy` with identical values. A
  pre-rename config.yaml key and the legacy VPSMGR_TRAEFIK env var are still
  read (new name wins), and schema migration v18 moves the settings mirror from
  `traefik` to `haproxy`, keeping a newer value when both rows exist.
- 30-haproxy.sh: install the pinned branch from the vendor repository and
  migrate a Traefik host in one run — install and validate HAProxy first, then
  remove Traefik's binary, unit, service account and dynamic rules (only its
  config file is kept, inert). No fallback keeps both proxies alive.
- upgrade-haproxy.sh: follow the pinned branch to its newest patch, with a
  configuration check and a graceful reload; `install.sh --update` runs it.
- tests: generation publish/rollback, map rendering, config-key and settings
  migrations, plus real-binary tests (skipped unless
  VPSMGR_TEST_HAPROXY_BIN is set) that run the generated configuration through
  `haproxy -c` and start it in a network namespace.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-16 12:59:13 +00:00
bluebluesodaandCommandCodeBot 1c6df157a5 fix(mgr,db): make an unpersisted CPU cap self-correcting; refuse a gapped schema
Addresses two review findings:

- the dynamic CPU limit applied a cap and only then saved the active state. A
  failed save left the cap in place with no record, so it could never expire
  and switching the rule off would not lift it — the container would stay
  pinned to the reduced quota. The caps applied by a pass are now held back
  until the state is written; if the write fails they are lifted again (each
  back to the user's own quota) and the failure is reported. The quota writer
  is behind a one-method interface so the failure path is covered by a test
  that fails the state write with a trigger and asserts cap-then-rollback, plus
  a test that a failed rollback is reported rather than swallowed
- migrations only compared the highest recorded version, so rows missing from
  schema_migrations silently skipped a step and surfaced later as obscure "no
  such column" failures. The recorded versions must now form the unbroken run
  2..cur (v1 is the baseline, created unconditionally and never recorded) or
  the database refuses to open

The third finding (a failed cross-container share install deleting the old
container before the new one is up) is left as is: the user confirms a
reinstall that discards the data, and the retry costs nothing — Delete treats
an already-missing container as success (verified against the live Incus
socket), so the user simply installs again.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-16 07:31:11 +00:00
bluebluesodaandCommandCodeBot c2009ddc69 chore(copy): tighten 1.8 wording, drop the "was X (not Y)" asides
- admin CPU card: the "what saving does depends on the checkbox" footnote is
  gone; the state line says it instead, and the state/description text is
  shorter
- user panel: the reinstall share field is just "或从分享的检查点安装" and its
  note "将从该检查点恢复,不安装系统。"; the share warning no longer parenthesises
  the contents ("拿到分享码的人可以读取该检查点内的全部数据,包括容器里的凭据。"),
  the second confirmation is "确认删除 / 更早的 N 个检查点会被删除,无法恢复。",
  the revoke prompt drops the repeated question, and the restore/reset prompts
  lose their "…,确认?"/"(重置后无法恢复)" tails
- the share toast only mentions removed checkpoints when there were some
  (previously "share code created (0 older checkpoints removed)")
- expiry: "账户已到期,机器已关机并锁定。" and "该账户已到期,仅可延期。";the
  "new deadline = max(now, current) + duration" footnote and "(0 = permanent)"
  parentheticals are gone
- knowledge editor: label is a plain "Markdown: headings, lists, …" legend and
  the empty preview is a dash instead of an instruction
- docs: the 1.8 sections (expiry, snapshot sharing, refquota, sessions, CPU
  rule, knowledge base) are cut roughly in half

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-16 07:12:06 +00:00
bluebluesodaandCommandCodeBot 83f0f02d45 feat(panel,admin): fix knowledge preview, panel-native confirms, CPU editor, persistent admin sessions
User panel:
- the reinstall modal drops the system highlight as soon as a share code is
  typed, so "nothing selected" is an explicit state that says the container is
  rebuilt from a checkpoint rather than from an image; clearing the code
  restores the default image, and clicking a tile drops a stale code instead of
  letting it silently override the visible selection
- the time-machine actions (share/restore/delete/revoke, plus the sticky-notes
  reset) confirm in an in-page dialog styled like the panel instead of
  window.confirm(); publishing still asks twice when it would delete the older
  checkpoints

Admin panel:
- the knowledge-base preview renders again: it posts a FormData body
  (multipart/form-data) and ParseForm alone ignores multipart bodies, so the
  server rendered the empty string and the preview box stayed blank
- the dynamic CPU limit rule moves back to the panel: an editable card on the
  overview saves into the DB (the single source of truth) and applies on the
  spot, restoring capped containers when it is switched off. The cpu_limit.*
  config keys and their `vps config set` entries are gone, and `vps install`
  no longer touches the rule, so an upgrade cannot reset it
- admin sessions are persisted (new admin_sessions table, hashed tokens) so a
  panel restart no longer logs the operator out; a password change from the
  panel or the CLI still invalidates every other session

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-16 06:51:35 +00:00
bluebluesodaandCommandCodeBot 627543da07 feat(panel,audit): keep panel-password access on expired accounts; fill audit gaps
- an expired account may still change its own panel password, and the admin
  may still reset it — only the container stays locked. Both routes are
  deliberately exempt from the read-only gate and both stay audited
- snapshot sharing is now OFF by default (new installs and upgrades alike):
  `snapshots.share` defaults to false and the panel re-derives its DB mirror
  from the config file at startup, so even a binary-only upgrade picks the
  default up. No admin-panel UI for it; enable with `vps config set
  snapshots.share true`
- publishing a checkpoint now asks twice when it would delete older
  checkpoints (first the exposure, then the irreversible deletion)
- audit completeness: distinct domain.add/domain.update/domain.delete,
  notes.update, and subject suffixes for snapshot.share.<checkpoint>,
  reinstall.share.<owner> and quota.expire.<user>, with matching labels in
  the audit viewer (knowledge.* labels added too)

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-15 17:41:19 +00:00
bluebluesodaandCommandCodeBot 1078e99cfa ci(release): take release notes from the tag annotation
- the publish job reads the tag's own message and uses it as the release
  body: `git tag -a vX.Y.Z -m "..."` writes the notes shown on the release
  page
- a lightweight tag carries no message, so the body falls back to
  "fix some bugs"; GitHub still appends its auto-generated compare link
  below whatever body is passed
- a signed tag's PGP block is stripped from the notes
- document the annotated-tag flow in docs/development.md

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-15 17:03:14 +00:00
bluebluesodaandCommandCodeBot 76e6b0990c feat(quota): per-container validity period with expiry lockdown
Port of dev-quota-expiry onto 1.8. The branch's other three commits are
already in main, so only this feature is new here; its migration is
renumbered to v16 (1.8 already uses v14/v15).

- users.expires_at (RFC3339 UTC, '' = permanent): the admin sets days at
  creation and extends from the users table (72h/30d/90d/180d/custom/forever;
  extend = max(now, current) + duration)
- the 60s loop force-stops expired containers (stop force:true) and disables
  boot.autostart; the account is locked to read-only — the user panel refuses
  every mutation (including the new snapshot-share/unshare routes) and the
  admin panel allows only extend + delete
- the user panel shows a live countdown and a locked banner; the admin users
  table dims expired rows and tags the name "(-Nd)"
- CLI: vps add --days N, vps quota --days N | --clear-expiry
- audit: quota.expiry (admin change) and quota.expire.<user> (auto stop)
- the container and its checkpoints are never deleted, so a checkpoint shared
  before expiry stays installable by others (cloning a stopped container's
  snapshot works — verified end to end)

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-15 16:58:37 +00:00
bluebluesodaandCommandCodeBot 8cb5c7cb82 feat(knowledge): admin-authored knowledge base with a user-side reader
- new `knowledge` table (schema v15) and an admin page (/knowledge) to create,
  edit, preview and delete Markdown articles; the Markdown source is capped at
  256 KiB and both title and content are required
- `internal/markdown`: a small, dependency-free Markdown renderer for a basic
  subset (headings, fenced code, inline code, bold/italic, links, lists,
  blockquotes, rules). It escapes HTML first and only allows http/https/mailto/
  #/ links, so an article can never inject markup
- the admin editor previews through POST /knowledge-preview, which returns the
  rendered HTML as JSON
- the user panel's machine card gains a "知识库 / Knowledge base" button that
  opens a large overlay (nearly fullscreen on phones): every article is
  embedded rendered, the list opens an article with a back button, and a copy
  button is added to each code block

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-15 16:40:26 +00:00
bluebluesodaandCommandCodeBot 3b8cfdcd93 refactor(settings): move snapshot sharing and CPU limit to config.yaml
- `snapshots.share` and `cpu_limit.*` are now config.yaml fields managed with
  `vps config set` (validated, shown by `vps config list/help`) instead of being
  panel/DB-only settings; the admin panel now shows the CPU rule read-only and
  the snapshot-sharing card is gone
- both apply immediately: `vps config set` writes the config file, mirrors the
  value into the DB and applies it right away — no panel restart — the same
  pattern as net.v4_forward. `vps install` refreshes the mirror from the file
- the CPU rule is read from the mirror (falling back to the config file) so the
  enforcer and the read-only card pick up a change live; the share toggle too
- drop the now-unused admin routes/handlers, the DB-only rule setting and the
  ConfigSet CPU form

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-15 16:25:00 +00:00
bluebluesodaandCommandCodeBot 65b54d7970 feat(snapshot): admin toggle for snapshot sharing
- add a snapshot_share_enabled setting with an admin-overview toggle;
  absent means ENABLED, so an upgraded host gets the feature on by default
- turning it off hides the user-side share/import entry points and makes
  the manager refuse new shares and installs, while existing containers and
  checkpoints are left untouched
- the stored codes are deliberately kept in the database (not deleted), so
  re-enabling restores them for as long as their checkpoint still exists;
  a code whose checkpoint has vanished is reported as invalid instead of
  building a half-container, and deleting a checkpoint still removes its code

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-15 16:08:37 +00:00
bluebluesodaandCommandCodeBot 2363b89743 feat(snapshot): share a checkpoint to install from it
- a user can publish one checkpoint behind a random v4-UUID code; another
  user enters that code in the reinstall dialog and gets a container cloned
  copy-on-write from the snapshot (no image is built, so no extra disk until
  it diverges), re-provisioned as their own: own IP/ports/disk quota and
  network, a fresh hostname/root password/machine-id, the source's
  authorized_keys cleared, and no init script run
- publishing deletes the older checkpoints (a clone pins its origin, so they
  could never be restored to again); the panel warns with the exact count.
  One share per container; deleting the checkpoint/container or reinstalling
  revokes it, and a code for the container itself is handled as a restore
- clone via POST /1.0/instances with source.type=copy, then delete the old
  container and rename the clone into place; new lx.CloneFromSnapshot /
  lx.RenameInstance and a shared instanceSpec() so a clone is configured
  exactly like a fresh container
- new snapshot_shares table (schema v14) and /snapshot-share /
  /snapshot-unshare panel routes plus the time-machine and reinstall UI
- also fix a refused restore (a checkpoint pinned by a clone) so the
  container is always left running, and recognise the "subsequent internal
  snapshot(s)" refusal message alongside the existing one
- also give new containers a HARD disk limit: the pool carries
  volume.zfs.use_refquota so a volume's usage counts the blocks it
  references (including inherited image/snapshot blocks); existing
  containers are untouched

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-15 15:57:30 +00:00
bluebluesodaandCommandCodeBot 209383806b feat(bandwidth): configurable monthly reset day
- new panel.bandwidth_reset_day (1-28, default 1); the monthly bandwidth
  period key now starts on that day (mgr.BandwidthPeriod), so counters
  roll over on the configured day instead of the 1st
- user panel quota label shows "流量配额(至次月 N 号)"
- docs + BandwidthPeriod/label tests

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-12 08:09:44 +00:00
bluebluesodaandCommandCodeBot 44c3727ad3 docs: document multi-container user groups
- add a "User groups" section: name-derived grouping, name validation, the
  shared panel password and accent color, and the container switcher
- describe the switcher labels and the per-container sticky-notes tag
- note the accent color is group-wide in the color section
- list multi-container users in both READMEs

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-11 06:41:06 +00:00
bluebluesodaandCommandCodeBot a64df2ac96 feat(panel): show build version in user footer
- add configurable user footer visibility
- render the injected release or local-build version

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-08 13:15:58 +00:00
bluebluesodaandCommandCodeBot b46bd0a901 fix(panel): theme color only on admin impersonation; thin borders back to 1px
- Per-user accent color now tints the user panel only for operator
  impersonation sessions (handleOverview clears ThemeColor otherwise);
  users logging in with their own password see the default theme
- Restore 1px borders on user-panel cards, buttons, inputs, quick stats,
  modals and spinner, and on the login card/input (was 3px)
- Update docs and tests; add HTTP-level test covering both session kinds

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-06 04:52:37 +00:00
bluebluesoda e25e729de7 fix(ipv6): disable npd6 alongside ndppd to avoid competing NAs
The in-tree NDP responder relies on being the only NDP proxy on the host.
main.go already disabled ndppd.service at install; npd6 (an alternative
distro NDP proxy daemon) was left running, so a host that happened to have
npd6 installed would answer Neighbor Solicitations with a competing
link-local-source NA and break the global-address fix.

- main.go: also `systemctl disable --now npd6.service` at install
- uninstall.sh: also stop/disable npd6 in the IPv6 cleanup path
- docs: note both daemons are disabled (ipv6-ndp-responder.md, ipv6.md)
2026-09-04 08:47:36 +00:00
bluebluesoda c9b18ffee5 Merge remote-tracking branch 'origin/dev-ndp'
# Conflicts:
#	src/internal/admin/handlers.go
2026-09-04 08:33:56 +00:00
bluebluesoda 69f79187c8 feat(ports): replace net.slot_range with net.user_ports; probe SSH ports at add time
net.slot_range required operators to reason about IPv4 last-octet slots to
control the host's reserved port span. Replace it with net.user_ports, a
direct comma-separated set of user-port ranges (default 10000-29999) that a
new container's whole-hundred block is drawn from, auto-aligned to whole
hundreds and merged. Only-new-users effect; existing containers keep their
ports.

- cfg: ParseUserPorts/CanonicalUserPorts/UserPortRanges/UserPortCount/
  UserPortBlockStarts; legacy slot_range is migrated once on Load and the
  old key stays in the file, ignored, no longer editable.
- db: migration v13 adds a UNIQUE index on users.start_port (port blocks are
  no longer derived from idx, so uniqueness must be enforced directly);
  UsedStartPorts() feeds the allocator.
- mgr: port blocks are allocated independently of the IPv4 idx via
  allocUserPortBlock; admin capacity readout follows UserPortCount.
- installer: 00-ip-ask.sh asks for port ranges (default = no confirm, a
  changed value echoes capacity and asks Y/n) and converts legacy slot_range;
  00-check.sh scans only the effective user-port ranges.
- ssh: allocSSHPort now probes the host (bind test) before picking a port,
  reassigning on conflict; the install no longer hard-fails on an occupied
  30000-31999 port.
2026-09-04 08:32:13 +00:00
bluebluesoda a3a81d943f docs: note how the NDP responder coexists with the isolation layer
Clarify that security.port_isolation + ipv4/ipv6_filtering gate east-west
traffic on the bridge while the responder handles north-south NA replies on
the upstream NIC, and document the gateway-neighbour pinning that plans
around the isolation filter's deliberate side effect on fe80::1 NDP.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-08-31 21:39:21 +08:00
bluebluesoda 9696752d25 docs: explain the in-tree NDP responder design and scale-test checklist
Add docs/ipv6-ndp-responder.md: why prefix mode replaced the distro ndppd
daemon with a project-owned responder (some providers drop link-local-source
NAs), the data-plane correctness guarantees, the A/B design hardening, what
changed on dev-ndp, and an 8-point checklist for wide-scale testing. Link it
from docs/README.md.
2026-08-31 17:51:14 +08:00
bluebluesoda f79ccb09f0 feat(install): add btrfs storage backend (native subvolume or loop file)
- VPSMGR_STORAGE now accepts btrfs alongside zfs and dir. On a btrfs root the
  pool is created as a native subvolume at /var/lib/incus/storage-pools/vpsmgr
  (Incus only allows that source path and requires it NOT to pre-exist); on any
  other host it becomes a sparse loop-file pool sized exactly like ZFS.
- install.sh asks for an explicit beta confirmation up front when the btrfs
  backend is selected (default yes — on a btrfs host there is no ZFS fallback).
  Non-interactive installs proceed; non-btrfs/default-zfs installs are
  unchanged.
- btrfs swap: on a btrfs root the swapfile is created with
  `btrfs filesystem mkswapfile` (btrfs-progs >= 6.1), falling back to
  truncate + chattr +C + dd for older tools — the generic fallocate+mkswap path
  fails on btrfs (swap must be NODATACOW and preallocated). Non-btrfs hosts
  keep the existing fallocate path.
- 00-check.sh installs btrfs-progs and skips the ZFS DKMS/kernel-header
  toolchain for btrfs and dir alike; 10-incus.sh verifies the final driver.
- Panel Go code is untouched: container management is storage-driver-
  transparent, and lx.EnsureZFSRemoveSnapshots is a no-op off ZFS.
- Docs (architecture, configuration, READMEs, AGENTS) updated for the new
  backend, including snapshot-restore semantics (btrfs keeps later snapshots).

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-08-31 17:00:03 +08:00
bluebluesoda 6fcc97b81b docs: document the snapshot "time machine" semantics
- architecture.md: new bullet under Users and resources explaining that
  restoring to an older checkpoint discards the later ones, done as a single
  Incus call via zfs.remove_snapshots on the ZFS root volume (recursive
  rollback), applied at add/reinstall and refreshed on every `vps install`
  (incl. --update), with the delete-and-retry fallback and the reinstall
  checkpoint-count warning.
- configuration.md: snapshots.limit row now notes that restoring to an older
  checkpoint auto-deletes the newer ones.
- README / README.en: list the time-machine (checkpoint) feature in the user
  panel feature lists.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-08-31 06:58:11 +00:00
bluebluesoda fa151eaee4 feat(install): configurable slot range + auto-off Traefik on 80/443 conflict
Add net.slot_range, a shrinkable container slot range (read as the IPv4
last octet a new container may take; idx = octet-1) that bounds how many
containers can be created and, consequently, how much of the user-port
block 10000-29999 a host reserves. Default 2-201 = 200 slots; must always
be a sub-range of 2-201 (only-shrink rule). Affects ONLY new containers —
existing ones keep their slot/ports.

- cfg: add net.slot_range with strict ParseSlotRange validation
  (exactly one '-', both ends integers in 2..201, lo<=hi), SlotCount/
  SlotIdxBounds helpers, and VPSMGR_SLOT_RANGE env handling in FillAuto.
  Register net.slot_range as operator / next-add in the config registry.
- db/mgr: NextFreeIdx now picks from a caller-supplied [idxMin,idxMax]
  window instead of the fixed 1..cfg.MaxUsers; mgr.Add feeds it from the
  configured range. Existing users outside a narrowed range keep their
  slots.
- admin panel: capacity readout follows SlotCount() instead of a fixed
  200 (honestly shows over-capacity when pre-existing users fall outside
  a shrunken range).
- installer: 00-checklist asks the slot range right after the subnet
  octet (env/adoption/non-interactive branches, validated). The
  port-occupancy check now scans only the range's user-port span rather
  than the whole 10000-29999.
- 80/443 handling: if either is already bound by a non-vpsmgr process,
  the install no longer fails — it proceeds with Traefik installed but
  DISABLED (net.traefik false, no autostart). 00-check writes a marker
  that install.sh turns into VPSMGR_TRAEFIK=0 for 30-traefik and the
  panel. A belt-and-suspenders guard skips the force-off when our own
  traefik.service is already active (update/adoption). SSH 30000-31999
  and the user-port span remain hard failures.
- traefik: bump pinned version to 3.7.12 and rewrite 30-traefik's
  enable/disable decision to respect the effective net.traefik (forced
  env > adopted config > default), so a disabled traefik is never
  briefly started.
- docs: README(s), config example, architecture and configuration docs
  updated; tests added for slot-range parsing and the FillAuto env vars.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-08-30 09:36:29 +00:00
bluebluesoda 1b564a9900 feat(install): use container-only incus-base instead of full incus
- new installs pull incus-base (Zabbly lts-7.0) which ships the same
  incusd daemon without the QEMU/SPICE/Ceph VM stack (~240 MiB less
  download, 500 MiB+ less disk)
- leave hosts that already carry the full `incus` package untouched
- uninstall --purge now also removes the full-incus VM dependencies,
  guarded by an installed reverse-dependency check (never blanket autoremove)
- update the dev docs install snippet to incus-base

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-08-30 08:19:19 +00:00
bluebluesoda 1158eb96da feat(config): add independent Traefik toggle
- add runtime net.traefik switch with live DB state\n- stop and disable Traefik when IPv4 forwarding is off\n- block new domains while Traefik is disabled
2026-08-29 14:34:47 +00:00
bluebluesoda 97396589f5 feat(install): support disabling IPv4 forwarding
- add --disable-v4forward with explicit confirmation\n- skip reserved-port checks and keep Traefik stopped for IPv6-only installs\n- document dedicated-host requirements and sync config references
2026-08-28 15:07:24 +00:00
bluebluesoda de95681073 feat(admin): expand palette to 10 colors; strengthen user-panel theming
- Add vivid blue and fuchsia to the accent palette (5 swatches per row)
- User panel: tint background, cards, borders and code blocks via CSS
  variables plus a 3px colored bar under the title for a stronger cue
2026-08-26 18:35:09 +00:00
bluebluesoda 705b5d5b64 feat(admin): per-user accent color for login button and user panel
- DB v12: users.color column (hex from a fixed 8-color palette, allowlist)
- Admin: click the username to open a swatch picker + clear; colored text/dot
- Login button defaults to neutral black/white; themed when a color is set
- User panel: tint background and accents via CSS variables in both modes
- Document the hidden entry point in READMEs and docs/architecture.md
2026-08-26 18:15:18 +00:00
bluebluesoda e3e49b12e7 feat(panel): show Arch build date in the reinstall intro; fix Arch icon
- Arch script records the build date as 8 digits (YYYYMMDD) in the image
  description
- lx.ImageAliasesWithDesc exposes image descriptions; Images() folds the
  date into the Arch intro ('…滚动发行版,我们提供镜像打包于 <date>')
- Arch icon is now the 'A without crossbar' (Λ) shape instead of a solid
  triangle
2026-08-24 18:50:42 +00:00
bluebluesoda 5bb4a5776c fix(images): Arch build — drop openssh-client, record version, clean orphans
- openssh on Arch ships both sshd and the ssh client; openssh-client does not
  exist and broke the whole pacman transaction (tested on a real Incus host)
- pacman -Syu --needed avoids reinstalling base packages; remove orphaned
  packages after the upgrade (apt-autoremove equivalent)
- record the Archlinux<YYMM> version code via the publish key=value property
  (incus publish has no --description flag); plain rm for cache cleanup
  (pacman -Scc prompts in a non-tty exec)
- verified end-to-end: built vpsmgr/arch-sshd, launched a container and
  confirmed IPv4 + IPv6 connectivity and SSH over both stacks
2026-08-24 18:10:26 +00:00
bluebluesoda 49d34f3748 feat(images): optional Arch Linux rolling image builder
- scripts/90-arch-image.sh: builds vpsmgr/arch-sshd from images:archlinux/
  current; unlike other image scripts it always deletes and rebuilds (rolling),
  records Archlinux<YYMM> in the image description, keeps the image slim and
  deletes the base afterwards
- Register Arch Linux label + rolling-release blurb and a gray triangle icon
  in the user panel reinstall dialog
- Docs (README, architecture, development, AGENTS) and image tests updated
2026-08-24 17:48:12 +00:00
bluebluesoda 61fdf68686 feat(images): optional Debian 13 dev image builder
Add scripts/80-debian-dev-image.sh, modeled on 50-image.sh: pulls
images:debian/13, installs the universal sshd/tooling set plus the dev
toolchain (python3 + pip + venv, git/sqlite3/ripgrep/jq/gh/sshpass, all
--no-install-recommends), Go 1.26.7 (arch-aware, sha256 from the dl JSON
API), nvm v0.40.7 + Node 24, cleans caches, and publishes
vpsmgr/debian-dev-sshd (base deleted).

- Symlink go/gofmt/node/npm/npx into /usr/local/bin: /etc/profile.d is
  only read by login shells, so non-interactive ssh commands would miss
  them; nvm's .bashrc hook already covered node
- Register the alias in imageLabels so the reinstall dialog shows
  "Debian 13 dev"; extend collectManagedImages tests
- Optional: not part of install.sh, keeping small hosts lean
- Document the new image in docs/architecture.md, development.md and the
  AGENTS.md image-build invariant
2026-08-24 05:09:32 +00:00
bluebluesodaandCommandCodeBot dcc2f767ff feat(images): optional openSUSE Leap 16 image builder
Add scripts/70-opensuse-image.sh, modeled on 60-rhel-image.sh: pulls
images:opensuse/16.0, installs sshd + universal tooling via zypper,
cleans caches, and publishes vpsmgr/opensuse-sshd (base deleted).

- Register the alias in imageLabels so the reinstall dialog shows
  "openSUSE Leap 16"; extend collectManagedImages tests
- Leap 16 ships systemd-networkd, so panel IPv6 config takes the
  existing Debian networkd branch — no runtime code changes needed
- Optional: not part of install.sh, keeping small hosts lean
- Document the new image in README (zh/en) and docs/architecture.md

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-21 06:20:23 +00:00
bluebluesodaandCommandCodeBot 7344b8ef3c feat(swap): auto-apply swap ratio on install, drop swap-reapply CLI
The swap-reapply command is internal bookkeeping users should never
see: containers get the configured incus.swap_ratio automatically, so
remove it from the CLI surface.

- cmdInstall now calls ApplySwapToAll alongside the other upgrade
  self-heal steps, so containers created before swap support get their
  allowance after upgrading and running `vps install`
- vps config set incus.swap_ratio keeps applying immediately to all
  containers (unchanged)
- drop the swap-reapply dispatch, cmdSwapReapply and help entry
- docs updated accordingly

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-21 05:03:41 +00:00
bluebluesodaandCommandCodeBot 7b31f2d4ec feat(swap): configurable per-container swap allowance
Incus 7 on cgroup v2 writes memory.swap.max=0 for every container
unless limits.memory.swap carries an explicit byte amount (true/false
both end up as 0), so containers previously got no host swap at all
and `free` inside showed 0. Add incus.swap_ratio (default 0.5) so each
container gets limits.memory.swap = limits.memory x ratio, set at
create/reinstall and on quota changes.

- config: incus.swap_ratio field + registry entry (non-negative float,
  0 disables swap; ApplyImmediate)
- lx: Client carries swapRatio; SetMem keeps swap proportional,
  SetSwap refreshes only the swap key
- mgr: ApplySwapToAll refreshes every container from the DB memory
  quota (idempotent, no restart)
- cli: `vps config set incus.swap_ratio N` re-applies to all
  containers automatically; `vps swap-reapply` is the manual/repair
  path for containers created before swap support
- docs: configuration/architecture/README updated

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-20 21:01:44 +00:00
bluebluesodaandCommandCodeBot 76e99e0fcf feat(config): allow any space-free string for panel.display_ip
display_ip is purely cosmetic (panel URL, SSH hints) and never used in
rules, so accept IPv6 addresses, domain names, or any string without
spaces instead of requiring a parseable IP.

- registry: validate only that the value contains no spaces
- tests: domain accepted, space-containing value rejected
- docs/examples: drop the "public IPv4" wording

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-20 16:05:19 +00:00
bluebluesodaandCommandCodeBot 484b53e811 feat(panel): user-managed container snapshots
- user panel: create/list/restore/delete disk-only snapshots per container,
  with confirm dialogs and a reinstall warning that snapshots are wiped
- snapshots.limit config key (default 1) enforces a per-container cap;
  admin panel is untouched
- restore keeps the live container config (disk_only) so quotas/NIC limits
  stay in sync with the DB, serialized via opMu against reinstall/delete
- audit log: snapshot.create / snapshot.delete / snapshot.restore
- fix: snapshot names carry a random suffix to avoid same-second collisions

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-20 06:46:56 +00:00
bluebluesoda 86b7038e2f fix(install): tolerate guest ipv6 setup failures
- continue host installation when one container rejects guest IPv6 setup\n- preserve fatal handling for host and database errors\n- identify the affected container in reapply failures
2026-08-19 16:39:51 +00:00
bluebluesoda 99f990b910 Merge main into dev 2026-08-19 16:11:42 +00:00
bluebluesoda 7866badddc feat(metrics): persist minute resource samples
- collect container metrics in one background Incus sample\n- serve dashboard usage from seven-day SQLite history\n- replace per-container disk exec and live page sampling
2026-08-19 15:53:04 +00:00
bluebluesoda 8ab4d396d9 feat(install): add --update to force prebuilt binary refresh
- install.sh accepts --update (alongside --local-build): it forces a
  fresh download of the latest prebuilt release over an existing binary.
  A plain re-run still skips an installed binary (idempotent adopt).
- Conservative by design: on download/checksum failure the installed
  binary is kept and a warning is printed — never a surprise local
  rebuild over an unknown checkout.
- install_prebuilt now stops vps.service before replacing the binary
  (ETXTBSY on a live panel), and only after the download has verified,
  so a failed download never takes the panel down.
- docs: AGENTS.md invariant, architecture.md install lifecycle,
  README.zh-CN usage line.
2026-08-16 05:29:46 +00:00
bluebluesoda adad8584d4 feat(domain): admin blocked-domains list + strict validation
- Admin domain panel gains a blocked-domains textarea (one domain per
  line); entries and all their subdomains are refused by mgr.AddDomain —
  the single add path — using label-boundary matching (example.co.uk
  blocks a.example.co.uk but not forexample.co.uk). Existing domains are
  untouched; only the add path is guarded.
- Save is per-line validated: invalid lines are skipped and reported by
  their 1-based line numbers in a flash banner, the rest are saved.
- Migration v5 seeds a default list (mozilla/github/aws/microsoft/apple/
  google/etc.) only when the settings key was never written, so admin
  edits or a full clear are never re-seeded.
- normalizeDomain is rewritten around explicit rules shared by the add
  path and the blocked list: lowercase, strip all trailing dots, reject
  any character outside [a-z0-9.-] (never silently strip — pasting a URL
  is an error), must end with a letter (excludes dotted-quad IPs), must
  contain a dot (no single-word names), each label non-empty/<=63 and
  starting+ending with a letter or digit (consecutive hyphens mid-label
  like xn--p1ai stay legal), total <= 253. No TLD validation.
- Tests: db round-trip + v5 seed (fresh/reopen/admin-edit/preexisting-key),
  matching semantics, parse/save partial validation, validation rules.
- docs: architecture.md blocked-domain section.
2026-08-16 05:19:15 +00:00
bluebluesoda fb3f8b7cfc feat(install): add VPSMGR_STORAGE=dir opt-in for test boxes
- New installs use a dir storage pool when VPSMGR_STORAGE=dir (default
  zfs). A failed ZFS pool still aborts — the switch is explicit, never
  an automatic fallback.
- 00-check.sh skips zfsutils-linux, the DKMS module build and the zfs
  boot units entirely in dir mode (no kernel module needed).
- 10-incus.sh creates/adopts a dir pool without requiring zpool; zfs
  mode still refuses any non-ZFS pool, fresh or adopted.
- uninstall.sh is unchanged: incus storage delete handles both drivers
  and the zpool destroy is already guarded by 'zpool list'.
- docs: architecture.md and AGENTS.md document the switch.
2026-08-15 17:33:53 +00:00
bluebluesodaandCommandCodeBot 64349de0c0 refactor(install): always use root loop-file storage
Remove secondary-disk discovery and whole-disk ZFS creation from the
installer. New installations now always create a sparse ZFS loop-file
pool from free space on /, while existing vpsmgr pools are still adopted.
The installer no longer scans, selects, formats, or modifies devices such
as /dev/vdb, including virtual ISO/CD devices.

Update storage documentation and keep the existing reclaim, pool sizing,
and ZFS quota behavior.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-15 15:24:18 +00:00
bluebluesodaandCommandCodeBot c1db56312d feat(panel): editable IPv6 pool with batch-add management page
- net.ipv6_pool is now operator-editable (was immutable): accept bare
  addresses or explicit /128 (any other prefix length rejected), stored
  normalized to the bare form.
- New admin "IPv6 Pool" page (visible in pool mode): multi-line
  batch-add box, list with per-address state (free / used + owner), and
  remove for free addresses (assigned ones are refused — a user keeps
  its address for life).
- Adding addresses re-applies the host plumbing so any address the
  provider bound on the external interface is detached immediately.
- mgr: AddPoolIPv6s / RemovePoolIPv6 / PoolList / PoolEntries with tests.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-15 14:47:27 +00:00
bluebluesodaandCommandCodeBot 790ed11369 fix(pool): routed NIC for per-address IPv6, detach pool addrs from host
Empirically this is what makes discrete whitelist addresses work:

- The provider binds every address on the host's NIC at boot; while the
  host holds an address the kernel treats it as LOCAL and drops the
  container's packets using it as a source (source-address validation),
  so outbound v6 silently fails. Pool mode now removes each pool address
  from the external interface (vps ip6 addr-del), on add/reinstall and
  every ipv6-reapply/boot — self-healing after a reboot re-binds them.
- Containers get TWO NICs: eth0 = nictype routed on the external
  interface with ipv6.address=<pool /128> (Incus programs the /128 route
  + proxy_ndp + fe80::1 gateway), eth1 = bridged on incusbr0 for the
  shared IPv4. systemd-networkd binds the /128 statically on eth0 and
  DHCPv4s on eth1.
- The manual bridge /128-route + proxy_ndp plumbing (SetupIPv6BridgePool,
  WireIPv6Pool route/neigh) is gone — Incus programs everything.

Verified end-to-end on the whitelist host: container reaches out over v6
(curl -6 returns its own /128) and v4 (eth1 NAT) simultaneously, external
Globalping hits the container /128 with 0% loss, delete releases the
address and a new container reuses it.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-15 14:47:27 +00:00