Files
風雪and風雪 7129cc346d perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
2026-09-27 09:27:35 +08:00

725 lines
32 KiB
Bash
Executable File

#!/usr/bin/env sh
# Standalone bootstrapper and installed lifecycle implementation.
set -eu
umask 077
# ROOT is useful for package builders and isolated lifecycle tests; normal installs use /.
ROOT="${THREE_M_UI_ROOT:-}"
BASE="$ROOT/usr/local/lib/3m-ui"
ENTRY="$ROOT/usr/local/bin/3m-ui"
CONFIG_DIR="$ROOT/etc/3m-ui"
DATA_DIR="${THREE_M_UI_DATA_DIR:-}"
if [ -z "$DATA_DIR" ] && [ -s "$BASE/DATA_DIRECTORY" ]; then DATA_DIR="$(cat "$BASE/DATA_DIRECTORY")"; fi
DATA_DIR="${DATA_DIR:-$ROOT/var/lib/3m-ui}"
LOG_DIR="$ROOT/var/log/3m-ui"
CONFIG_FILE="$CONFIG_DIR/config.yaml"
APP_BIN="$BASE/3m-ui-bin"
MIHOMO_BIN="$BASE/mihomo"
SERVICE_NAME=3m-ui
REPO="${THREE_M_UI_REPO:-}"
CHANNEL="${THREE_M_UI_CHANNEL:-}"
REQUESTED_VERSION=""
INSTALL_MIHOMO=1
OPERATION=install
YES=0
SNAPSHOT=""
TRANSACTION=0
STOPPED=0
WAS_RUNNING=0
WORK=""
say(){ printf '%s\n' "$*"; }
err(){ say "Error: $*" >&2; exit 1; }
command_exists(){ command -v "$1" >/dev/null 2>&1; }
usage(){ cat <<USAGE
Usage: install.sh [VERSION] [--yes] [--no-mihomo]
3m-ui update [VERSION] [--yes] [--no-mihomo]
3m-ui backup
3m-ui restore /absolute/path/to/snapshot.tar.gz [--yes]
The default channel is stable. Complete panel + Mihomo bundles support amd64
and arm64. Other release architectures require --no-mihomo and a custom core.
Environment:
THREE_M_UI_REPO=owner/repo Release source (saved for future upgrades)
THREE_M_UI_CHANNEL=stable|pre Update channel (saved for future upgrades)
THREE_M_UI_VERIFY_COSIGN=1 Require release provenance verification
PANEL_PORT=8080 Initial panel port (existing config is kept)
THREE_M_UI_MIHOMO_BINARY=PATH Explicit external core for a new installation
THREE_M_UI_GOMEMLIMIT=96MiB Panel heap cap; auto-tuned below 512MB RAM
THREE_M_UI_GOGC=50 Panel GC target; auto-tuned below 512MB RAM
THREE_M_UI_CORE_LOW_MEMORY=0|1 Small-RAM core tuning (auto 1 below 512MB)
A version selects that release for this operation; 'pre' selects the pre channel.
Updates stop the service only after downloads pass checksum validation, keep a
complete local snapshot, and restore it if initialization or health checks fail.
USAGE
}
for arg in "$@"; do
case "$arg" in
-y|--yes) YES=1;;
--no-mihomo) INSTALL_MIHOMO=0;;
--static|--dynamic) :;; # Compatibility: official panel artifacts are static.
--update) OPERATION=update;;
--backup) OPERATION=backup;;
--restore) OPERATION=restore;;
-h|--help) usage; exit 0;;
v[0-9]*|manual-[0-9]*|test-[0-9a-zA-Z._-]*|pre)
[ -z "$REQUESTED_VERSION" ] || err 'Only one version may be specified.'
REQUESTED_VERSION="$arg";;
/*) [ "$OPERATION" = restore ] || err "Unknown argument: $arg"; SNAPSHOT="$arg";;
*) err "Unknown argument: $arg";;
esac
done
# If this update was started as a child of 3m-ui.service (e.g. panel web UI),
# systemd will kill the whole cgroup on "systemctl stop 3m-ui" — including us —
# before we reach "systemctl start". Re-exec outside that cgroup first.
escape_panel_cgroup_for_update(){
[ "$OPERATION" = update ] || return 0
[ "${THREE_M_UI_UPDATE_ESCAPED:-0}" = 1 ] && return 0
if [ ! -r /proc/self/cgroup ] || ! grep -qE '3m-ui\.service' /proc/self/cgroup 2>/dev/null; then
return 0
fi
export THREE_M_UI_UPDATE_ESCAPED=1
say 'Update is running inside 3m-ui.service cgroup; re-scheduling outside it so stop cannot kill the updater.'
if command_exists systemd-run && [ -d /run/systemd/system ]; then
unit="3m-ui-update-$(date +%s)-$$"
# --no-block: return immediately; the oneshot unit continues after panel stops.
if systemd-run --no-block --collect --unit="$unit" --description="3m-ui self-update" --setenv=THREE_M_UI_UPDATE_ESCAPED=1 --setenv=THREE_M_UI_UPDATE_SILENT="${THREE_M_UI_UPDATE_SILENT:-}" --setenv=THREE_M_UI_CHANNEL="${THREE_M_UI_CHANNEL:-}" --setenv=THREE_M_UI_REPO="${THREE_M_UI_REPO:-}" --setenv=THREE_M_UI_ROOT="${THREE_M_UI_ROOT:-}" --setenv=THREE_M_UI_DATA_DIR="${THREE_M_UI_DATA_DIR:-}" --setenv=THREE_M_UI_VERIFY_COSIGN="${THREE_M_UI_VERIFY_COSIGN:-}" --setenv=PATH="$PATH" "$0" "$@"; then
say "Scheduled systemd unit: $unit"
exit 0
fi
say 'systemd-run failed; falling back to setsid.' >&2
fi
if command_exists setsid; then
# setsid alone does not leave the systemd cgroup, but helps non-systemd hosts.
setsid -f env THREE_M_UI_UPDATE_ESCAPED=1 "$0" "$@" >/dev/null 2>&1 || setsid env THREE_M_UI_UPDATE_ESCAPED=1 "$0" "$@" >/dev/null 2>&1 &
exit 0
fi
say 'Warning: could not escape service cgroup; update may be killed when the panel stops.' >&2
}
escape_panel_cgroup_for_update "$@"
init_system(){
if [ -d "$ROOT/run/systemd/system" ] && command_exists systemctl; then echo systemd
elif command_exists rc-service; then echo openrc
else echo unsupported; fi
}
service_action(){
case "$INIT" in
systemd) systemctl "$1" "$SERVICE_NAME";;
openrc) rc-service "$SERVICE_NAME" "$1";;
*) return 1;;
esac
}
service_active(){
case "$INIT" in
systemd) systemctl is-active --quiet "$SERVICE_NAME";;
openrc) rc-service "$SERVICE_NAME" status >/dev/null 2>&1;;
*) return 1;;
esac
}
reload_service(){ [ "$INIT" != systemd ] || systemctl daemon-reload; }
stop_existing(){
if [ -e "$UNIT" ] || service_active; then service_action stop; fi
STOPPED=1
}
arch(){
case "$(uname -m)" in
x86_64|amd64) echo amd64;; aarch64|arm64) echo arm64;;
armv7l|armv7*) echo armv7;; armv6l|armv6*) echo armv6;;
i386|i486|i586|i686|x86) echo 386;; riscv64) echo riscv64;;
loongarch64|loong64) echo loong64;; ppc64le) echo ppc64le;; s390x) echo s390x;;
*) err "Unsupported architecture: $(uname -m)";;
esac
}
# ── Go runtime memory tuning (no swap, no mmap tricks) ──────────
# GOMEMLIMIT = soft cap; Go GC runs harder before hitting it.
# GOGC = GC trigger ratio (lower = more frequent GC = less RSS).
# MemoryMax = systemd hard cap (OOM kills panel, not SSH).
#
# No swap creation. No mmap hacks. Pure Go runtime tuning.
#
# Override: THREE_M_UI_GOMEMLIMIT=80MiB THREE_M_UI_GOGC=75
compute_mem_tuning() {
TOTAL_KB=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 524288)
TOTAL_MB=$((TOTAL_KB / 1024))
if [ -n "${THREE_M_UI_GOMEMLIMIT:-}" ]; then
PANEL_GOMEMLIMIT="$THREE_M_UI_GOMEMLIMIT"
elif [ "$TOTAL_MB" -le 128 ]; then
PANEL_GOMEMLIMIT="48MiB"
PANEL_GOGC="20"
elif [ "$TOTAL_MB" -le 256 ]; then
PANEL_GOMEMLIMIT="96MiB"
PANEL_GOGC="50"
elif [ "$TOTAL_MB" -le 512 ]; then
PANEL_GOMEMLIMIT="192MiB"
PANEL_GOGC="75"
else
PANEL_GOMEMLIMIT="off"
PANEL_GOGC="100"
fi
[ -n "${THREE_M_UI_GOGC:-}" ] && PANEL_GOGC="$THREE_M_UI_GOGC"
case "$PANEL_GOMEMLIMIT" in
off) PANEL_MEMORYMAX="infinity" ;;
*MiB)
_r=$(echo "$PANEL_GOMEMLIMIT" | sed 's/MiB//')
PANEL_MEMORYMAX="$((_r + _r / 2))M"
;;
*) PANEL_MEMORYMAX="infinity" ;;
esac
# Export immediately so ALL subprocesses (download, extract, init,
# healthcheck, service start) inherit the limits. Note this only reaches Go
# processes: curl, tar and gzip ignore it, which is why the download workflow
# below is shaped to avoid holding anything in memory in the first place.
if [ "$PANEL_GOMEMLIMIT" != "off" ]; then
export GOMEMLIMIT="$PANEL_GOMEMLIMIT"
export GOGC="$PANEL_GOGC"
fi
# Core-side tuning is separate from the panel's own heap: it switches the
# Mihomo geodata loader to the memory-conservative one, which does not lean
# on resident GEO data the way the standard loader does. Never exported —
# only written into the service unit, so it cannot leak into this script's
# own Go subprocesses, which want the tighter tier above.
if [ -n "${THREE_M_UI_CORE_LOW_MEMORY:-}" ]; then
PANEL_CORE_LOW_MEMORY="$THREE_M_UI_CORE_LOW_MEMORY"
elif [ "$TOTAL_MB" -le 512 ]; then
PANEL_CORE_LOW_MEMORY=1
else
PANEL_CORE_LOW_MEMORY=0
fi
say "Memory tuning: GOMEMLIMIT=$PANEL_GOMEMLIMIT GOGC=$PANEL_GOGC MemoryMax=$PANEL_MEMORYMAX core low-memory=$PANEL_CORE_LOW_MEMORY (RAM: ${TOTAL_MB}MB)"
}
compute_mem_tuning
install_deps(){
if command_exists curl && command_exists tar && command_exists gzip &&
{ command_exists sha256sum || command_exists shasum || command_exists openssl; }; then return; fi
if command_exists apk; then apk add --no-cache curl ca-certificates tar gzip coreutils
elif command_exists apt-get; then apt-get update && apt-get install -y curl ca-certificates tar gzip coreutils
elif command_exists dnf; then dnf install -y curl ca-certificates tar gzip coreutils
elif command_exists yum; then yum install -y curl ca-certificates tar gzip coreutils
elif command_exists pacman; then pacman -Sy --noconfirm curl ca-certificates tar gzip coreutils
elif command_exists zypper; then zypper --non-interactive install curl ca-certificates tar gzip coreutils
else err 'Install curl, CA certificates, tar, gzip and sha256sum before continuing.'; fi
}
download(){ curl -fLsS --retry 3 --retry-delay 1 --connect-timeout 10 --max-time 300 "$1" -o "$2"; }
file_sha256(){
if command_exists sha256sum; then sha256sum "$1" | awk '{print $1}'
elif command_exists shasum; then shasum -a 256 "$1" | awk '{print $1}'
else openssl dgst -sha256 "$1" | awk '{print $NF}'; fi
}
# Same three implementations, reading the stream instead of a path so a file can
# be hashed while it is still being written.
file_sha256_stream(){
if command_exists sha256sum; then sha256sum | awk '{print $1}'
elif command_exists shasum; then shasum -a 256 | awk '{print $1}'
else openssl dgst -sha256 | awk '{print $NF}'; fi
}
# Scratch space for downloads, extraction and the update snapshot.
#
# /tmp is deliberately avoided when we can do better. On many small hosts /tmp is
# a tmpfs, and this machine has no swap, so every byte written there is pinned in
# RAM and cannot be reclaimed or written out. A single install would otherwise
# hold the downloaded bundle, the extracted release *and* a copy of the data
# directory in memory at the same time — well past the total RAM of the boxes
# this installer targets. DATA_DIR is on real storage, so use it, and fall back
# to mktemp only if that fails.
create_workdir(){
candidate="$DATA_DIR/.tmp/3m-ui-work-$$"
if mkdir -p "$candidate" 2>/dev/null && [ -w "$candidate" ]; then
WORK="$candidate"
return 0
fi
WORK="$(mktemp -d)" || err 'Cannot create a working directory.'
}
# Size the asset advertises, in bytes; 0 when the server does not say. HEAD only —
# no body is fetched, so this costs nothing on metered NAT links.
remote_bytes(){
curl -fLsSI --retry 2 --retry-delay 1 --connect-timeout 10 --max-time 60 "$1" 2>/dev/null |
awk 'tolower($1)=="content-length:"{ gsub(/[^0-9]/,"",$2); if ($2 != "") v=$2 } END{ print v+0 }'
}
# Refuse to begin when the scratch area cannot hold what the operation needs.
# Discovering this halfway through is much worse: the old service is already
# stopped by then. An unreadable df reading means unknown, not zero, so we do
# not block installs on hosts df cannot report on.
require_scratch(){
need_kb="$1"
avail_kb="$(df -Pk "$WORK" 2>/dev/null | awk 'NR==2{print $4}')"
case "$avail_kb" in ''|*[!0-9]*) return 0;; esac
[ "$avail_kb" -ge "$need_kb" ] ||
err "Not enough scratch space in $WORK: need about ${need_kb}KB, only ${avail_kb}KB free. Choose a THREE_M_UI_DATA_DIR on a disk with room, or free disk space."
}
# Need room for: the compressed asset, everything it expands to, and the files
# we then copy out of it. Tripling the download covers tar blowups comfortably.
reserve_for_asset(){
bytes="$(remote_bytes "$1")"
[ "$bytes" -gt 0 ] || return 0
require_scratch $(( bytes * 3 / 1024 + 1024 ))
}
# Single-pass download: the bytes are written to disk and hashed on the way
# through, so nothing has to re-read the file afterwards. Verification still
# completes before anything is extracted — a corrupt or truncated download only
# ever shows up as a checksum mismatch. Prints the computed sum.
download_hashed(){ # url dest
st="$WORK/.download-status"
rm -f "$st"
sum="$({ curl -fLsS --retry 3 --retry-delay 1 --connect-timeout 10 --max-time 300 "$1" ||
printf '%s' "$?" > "$st"; } | tee "$2" | file_sha256_stream)"
if [ -s "$st" ]; then err "Download failed (curl exit $(cat "$st")): $1"; fi
printf '%s\n' "$sum"
}
source_settings(){
[ -n "$REPO" ] || { [ ! -s "$BASE/REPOSITORY" ] || REPO="$(cat "$BASE/REPOSITORY")"; }
REPO="${REPO:-kazeyukiro/3m-ui}"
case "$REPO" in *[!a-zA-Z0-9._/-]*|/*|*/|*/*/*|*/../*|../*) err 'Invalid THREE_M_UI_REPO; expected owner/repository.';; esac
case "$REPO" in */*) :;; *) err 'Invalid THREE_M_UI_REPO; expected owner/repository.';; esac
[ -n "$CHANNEL" ] || { [ ! -s "$BASE/CHANNEL" ] || CHANNEL="$(cat "$BASE/CHANNEL")"; }
CHANNEL="${CHANNEL:-stable}"
[ "$REQUESTED_VERSION" != pre ] || CHANNEL=pre
[ "$CHANNEL" != prerelease ] || CHANNEL=pre
case "$CHANNEL" in stable|pre) :;; *) err 'THREE_M_UI_CHANNEL must be stable or pre.';; esac
if [ -n "$REQUESTED_VERSION" ]; then TAG="$REQUESTED_VERSION"
elif [ "$CHANNEL" = pre ]; then TAG=pre
else
# GitHub latest excludes prereleases. Never silently fall back to a development build.
location="$(curl -fLsSI --retry 3 --connect-timeout 10 --max-time 60 -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest")" || err 'No stable release available; choose a version or explicitly select THREE_M_UI_CHANNEL=pre.'
TAG="${location##*/}"
case "$TAG" in v[0-9]*) :;; *) err 'Unable to resolve a stable release; choose an explicit version.';; esac
fi
case "$TAG" in *[!a-zA-Z0-9._+-]*) err 'Invalid release tag.';; esac
RELEASE_URL="https://github.com/$REPO/releases/download/$TAG"
}
expected_sha256(){
awk -v a="$1" '{ n=$2; sub(/^\*/,"",n); sub(/^\.\//,"",n); if(n==a) {print $1; exit} }' "$WORK/SHA256SUMS"
}
verify_asset(){
verify_sum "$1" "$(file_sha256 "$2")"
}
# Compare a sum that was already computed — used by download_hashed, which
# hashed the stream on its way to disk and has no reason to read it again.
verify_sum(){
expected="$(expected_sha256 "$1")"
[ -n "$expected" ] || err "$1 is missing from SHA256SUMS."
actual="$2"
[ -n "$actual" ] || err "$1 downloaded no verifiable content."
[ "$actual" = "$expected" ] || err "Checksum mismatch for $1."
say "Checksum OK: $1"
}
verify_provenance(){
[ "${THREE_M_UI_VERIFY_COSIGN:-0}" = 1 ] || return 0
command_exists cosign || err 'THREE_M_UI_VERIFY_COSIGN=1 requires cosign.'
download "$RELEASE_URL/SHA256SUMS.pem" "$WORK/SHA256SUMS.pem"
download "$RELEASE_URL/SHA256SUMS.sig" "$WORK/SHA256SUMS.sig"
workflow=release.yml
[ "$TAG" != pre ] || workflow=release-pre.yml
for ref in "refs/tags/$TAG" refs/heads/main refs/heads/test; do
if cosign verify-blob --certificate "$WORK/SHA256SUMS.pem" --signature "$WORK/SHA256SUMS.sig" \
--certificate-identity "https://github.com/$REPO/.github/workflows/$workflow@$ref" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com "$WORK/SHA256SUMS" >/dev/null 2>&1; then return; fi
done
err 'Release signature verification failed.'
}
prepare_release(){
mkdir -p "$WORK/release"
download "$RELEASE_URL/SHA256SUMS" "$WORK/SHA256SUMS"
verify_provenance
cpu="$(arch)"
if [ "$INSTALL_MIHOMO" = 1 ]; then
case "$cpu" in amd64|arm64) :;; *) err "Complete bundles are available for amd64/arm64; use --no-mihomo for $cpu.";; esac
asset="3m-ui-bundle-linux-$cpu.tar.gz"
say "Downloading $REPO $TAG ($asset)..."
reserve_for_asset "$RELEASE_URL/$asset"
verify_sum "$asset" "$(download_hashed "$RELEASE_URL/$asset" "$WORK/bundle.tar.gz")"
# Bundles contain only these flat, regular files. Reject unexpected names/links.
tar -tzf "$WORK/bundle.tar.gz" > "$WORK/members"
while IFS= read -r member; do
case "${member#./}" in 3m-ui-bin|mihomo|MIHOMO_VERSION|VERSION|REPOSITORY|mihomo.env|LICENSE|MIHOMO_LICENSE|MIHOMO_SOURCE|install.sh|update.sh|uninstall.sh|3m-ui.sh|3m-ui) :;; *) err "Unexpected bundle member: $member";; esac
done < "$WORK/members"
tar -tvzf "$WORK/bundle.tar.gz" | awk 'substr($0,1,1)!="-" { bad=1 } END {exit bad}' || err 'Bundle contains non-regular files.'
tar -xzf "$WORK/bundle.tar.gz" -C "$WORK/release"
for member in 3m-ui-bin mihomo MIHOMO_VERSION VERSION REPOSITORY install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui; do
[ -s "$WORK/release/$member" ] || err "Incomplete bundle: $member missing."
done
[ "$(cat "$WORK/release/REPOSITORY")" = "$REPO" ] || err 'Bundle repository does not match the selected release source.'
else
asset="3m-ui-linux-$cpu"
reserve_for_asset "$RELEASE_URL/$asset"
say "Downloading $REPO $TAG ($asset)..."
verify_sum "$asset" "$(download_hashed "$RELEASE_URL/$asset" "$WORK/release/3m-ui-bin")"
for member in install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui; do
download "$RELEASE_URL/$member" "$WORK/release/$member"
verify_asset "$member" "$WORK/release/$member"
done
fi
for member in install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui; do
sh -n "$WORK/release/$member"
chmod 0755 "$WORK/release/$member"
done
chmod 0755 "$WORK/release/3m-ui-bin"
"$WORK/release/3m-ui-bin" --version >/dev/null
if [ "$INSTALL_MIHOMO" = 1 ]; then chmod 0755 "$WORK/release/mihomo"; "$WORK/release/mihomo" -v >/dev/null; fi
}
validate_storage(){
[ -f "$CONFIG_FILE" ] || return 0
# Use the new binary to inspect old settings read-only, before any downtime or
# migration. Refuse to claim a complete backup of shared external directories.
inspection_bin="$1"
(cd "$DATA_DIR" && THREE_M_UI_CONFIG="$CONFIG_FILE" "$inspection_bin" storage-paths) > "$WORK/storage-paths"
while IFS= read -r path; do
case "$path" in "$CONFIG_DIR"|"$CONFIG_DIR"/*|"$DATA_DIR"|"$DATA_DIR"/*) :;;
*) err "Storage outside the managed configuration/data directories: $path. Move it into $CONFIG_DIR or $DATA_DIR before using automatic install/update/backup.";;
esac
case "$path" in "$DATA_DIR/backups"|"$DATA_DIR/backups"/*) err "Active storage cannot live in the backup directory: $path";; esac
done < "$WORK/storage-paths"
}
# Snapshots cover configuration, encryption keys, SQLite/WAL, certificates, all
# app data, managed binaries/helpers, the entrypoint and service definition.
create_snapshot(){
mkdir -p "$WORK/snapshot" "$DATA_DIR/backups"
for spec in "base:$BASE" "config:$CONFIG_DIR" "entry:$ENTRY" "service:$UNIT"; do
name="${spec%%:*}"; path="${spec#*:}"
if [ -e "$path" ] || [ -L "$path" ]; then cp -a "$path" "$WORK/snapshot/$name"; fi
done
if [ -d "$DATA_DIR" ]; then
mkdir -p "$WORK/snapshot/data"
# Intermediate archive propagates tar errors rather than hiding a failing pipeline.
tar -cf "$WORK/data.tar" --exclude='./backups' --exclude='./.tmp' -C "$DATA_DIR" .
tar -xf "$WORK/data.tar" -C "$WORK/snapshot/data"
# Nothing reads data.tar again, and keeping it would hold the data directory
# twice over — unaffordable on the small-RAM machines this targets.
rm -f "$WORK/data.tar"
fi
printf '%s\n' '3m-ui-snapshot-v1' > "$WORK/snapshot/FORMAT"
printf '%s\n' "$BASE" "$CONFIG_DIR" "$DATA_DIR" "$ENTRY" "$UNIT" > "$WORK/snapshot/PATHS"
printf '%s\n' "$WAS_RUNNING" > "$WORK/snapshot/RUNNING"
enabled=0
case "$INIT" in
systemd) systemctl is-enabled --quiet "$SERVICE_NAME" && enabled=1 || true;;
openrc) [ ! -e "$ROOT/etc/runlevels/default/$SERVICE_NAME" ] || enabled=1;;
esac
printf '%s\n' "$enabled" > "$WORK/snapshot/ENABLED"
SNAPSHOT="$DATA_DIR/backups/$(date -u +%Y%m%dT%H%M%SZ)-$$.tar.gz"
tar -czf "$SNAPSHOT.tmp" -C "$WORK/snapshot" .
mv "$SNAPSHOT.tmp" "$SNAPSHOT"
say "Snapshot: $SNAPSHOT"
}
restore_files(){
restore_dir="$1"
# Undo enablement created by a failed first installation before removing its unit.
if [ "$(cat "$restore_dir/ENABLED")" = 0 ]; then
case "$INIT" in
systemd) systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true;;
openrc) rc-update del "$SERVICE_NAME" default >/dev/null 2>&1 || true;;
esac
fi
# Keep backup archives available across rollback and manual restores.
mkdir -p "$DATA_DIR" || return 1
for path in "$DATA_DIR"/* "$DATA_DIR"/.[!.]* "$DATA_DIR"/..?*; do
[ -e "$path" ] || [ -L "$path" ] || continue
[ "$path" = "$DATA_DIR/backups" ] && continue
# Scratch work now lives under DATA_DIR instead of tmpfs, so a restore would
# otherwise delete the directory it is currently reading its own rollback
# out of. Nothing here is application data; leave it for the cleanup trap.
[ -n "$WORK" ] && [ "$path" = "$WORK" ] && continue
[ "$path" = "$DATA_DIR/.tmp" ] && continue
rm -rf "$path" || return 1
done
[ ! -d "$restore_dir/data" ] || cp -a "$restore_dir/data/." "$DATA_DIR/" || return 1
for spec in "base:$BASE" "config:$CONFIG_DIR" "entry:$ENTRY" "service:$UNIT"; do
name="${spec%%:*}"; path="${spec#*:}"
rm -rf "$path" || return 1
if [ -e "$restore_dir/$name" ] || [ -L "$restore_dir/$name" ]; then
mkdir -p "$(dirname "$path")" || return 1
cp -a "$restore_dir/$name" "$path" || return 1
fi
done
reload_service || return 1
if [ "$(cat "$restore_dir/ENABLED")" = 1 ]; then
case "$INIT" in
systemd) systemctl enable "$SERVICE_NAME" >/dev/null;;
openrc) rc-update add "$SERVICE_NAME" default >/dev/null;;
esac
fi
}
cleanup(){
result=$?
trap - EXIT HUP INT TERM
if [ "$TRANSACTION" = 1 ]; then
say 'Operation failed; restoring the previous installation and data.' >&2
service_action stop >/dev/null 2>&1 || true
if [ -d "$WORK/snapshot" ] && restore_files "$WORK/snapshot"; then
if [ "$WAS_RUNNING" = 1 ]; then service_action start || say 'Could not restart the restored service; inspect 3m-ui logs.' >&2; fi
else
say "Automatic restore failed. Recovery snapshot: $SNAPSHOT" >&2
fi
[ "$result" != 0 ] || result=1
fi
if [ "$TRANSACTION" = 0 ] && [ "$STOPPED" = 1 ] && [ "$WAS_RUNNING" = 1 ]; then
service_action start || say 'Could not restart the previous service; inspect 3m-ui logs.' >&2
fi
[ -z "$WORK" ] || { rm -rf "$WORK"; rmdir "$DATA_DIR/.tmp" 2>/dev/null || true; }
exit "$result"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' HUP TERM
# Rewrites the memory directives an installed unit already carries.
#
# write_service() deliberately keeps whatever unit an operator has tuned, which
# means the values baked into it at install time would never be refreshed by an
# update. Running it again has to converge instead: drop the four directives we
# own, re-emit them with this run's numbers, and leave every other line — and
# therefore every other operator override — exactly where it was.
ensure_service_limits(){
[ -n "$UNIT" ] && [ -f "$UNIT" ] || return 0
if [ "$INIT" = systemd ]; then
limits="Environment=GOMEMLIMIT=${PANEL_GOMEMLIMIT}
Environment=GOGC=${PANEL_GOGC}
Environment=THREE_M_UI_CORE_LOW_MEMORY=${PANEL_CORE_LOW_MEMORY}
MemoryMax=${PANEL_MEMORYMAX}"
sed '/^Environment=GOMEMLIMIT=/d; /^Environment=GOGC=/d; /^Environment=THREE_M_UI_CORE_LOW_MEMORY=/d; /^MemoryMax=/d' \
"$UNIT" > "$WORK/service.stripped" || return 0
# Insert into [Service]; appending at the end would land in [Install] and
# silently apply these settings to the wrong section.
awk -v limits="$limits" '
{ lines[NR]=$0 }
END {
seen=0
for (i=1; i<=NR; i++) {
print lines[i]
if (lines[i] ~ /^\[Service\]/) { print limits; seen=1 }
}
if (!seen) print limits
}' "$WORK/service.stripped" > "$WORK/service.new"
install -m 0644 "$WORK/service.new" "$UNIT"
systemctl daemon-reload
else
limits="export GOMEMLIMIT=\"${PANEL_GOMEMLIMIT}\"
export GOGC=\"${PANEL_GOGC}\"
export THREE_M_UI_CORE_LOW_MEMORY=\"${PANEL_CORE_LOW_MEMORY}\""
sed '/^export GOMEMLIMIT=/d; /^export GOGC=/d; /^export THREE_M_UI_CORE_LOW_MEMORY=/d' \
"$UNIT" > "$WORK/service.stripped" || return 0
# OpenRC runscripts evaluate top to bottom, so re-export before anything
# could consult them.
awk -v limits="$limits" '
{ lines[NR]=$0 }
END {
seen=0
for (i=1; i<=NR; i++) {
print lines[i]
if (lines[i] ~ /^description=/) { print limits; seen=1 }
}
if (!seen) print limits
}' "$WORK/service.stripped" > "$WORK/service.new"
install -m 0755 "$WORK/service.new" "$UNIT"
fi
}
write_service(){
# Keep operator overrides, capabilities and enablement on an existing install.
# Older systemd syscall filters cannot run the current pure-Go SQLite build.
if [ -f "$WORK/snapshot/service" ]; then
if [ "$INIT" = systemd ]; then
if grep -q '^SystemCallFilter=' "$UNIT"; then
sed '/^SystemCallFilter=/d; /^PrivateDevices=/d; /^MemoryDenyWriteExecute=/d; /^RestrictNamespaces=/d' "$UNIT" > "$WORK/service"
install -m 0644 "$WORK/service" "$UNIT"
fi
systemctl daemon-reload
fi
# Existing install: nothing else about the unit changes, but the memory
# directives still have to track this host, not the day it was installed.
ensure_service_limits
return 0
fi
mkdir -p "$(dirname "$UNIT")"
if [ "$INIT" = systemd ]; then
cat > "$UNIT" <<UNITFILE
[Unit]
Description=3m-ui panel and Mihomo Core
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$APP_BIN
Environment=THREE_M_UI_CONFIG=$CONFIG_FILE
Environment=GOMEMLIMIT=${PANEL_GOMEMLIMIT}
Environment=GOGC=${PANEL_GOGC}
Environment=THREE_M_UI_CORE_LOW_MEMORY=${PANEL_CORE_LOW_MEMORY}
WorkingDirectory=$DATA_DIR
Restart=always
RestartSec=5
KillMode=control-group
UMask=0077
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ReadWritePaths=$CONFIG_DIR $DATA_DIR $LOG_DIR
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
SystemCallArchitectures=native
NoNewPrivileges=true
LimitNOFILE=65535
MemoryMax=${PANEL_MEMORYMAX}
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
UNITFILE
systemctl daemon-reload
systemctl enable "$SERVICE_NAME" >/dev/null
else
cat > "$UNIT" <<UNITFILE
#!/sbin/openrc-run
description="3m-ui panel and Mihomo Core"
command="$APP_BIN"
command_background="yes"
pidfile="/run/$SERVICE_NAME.pid"
directory="$DATA_DIR"
export THREE_M_UI_CONFIG="$CONFIG_FILE"
export GOMEMLIMIT="${PANEL_GOMEMLIMIT}"
export GOGC="${PANEL_GOGC}"
export THREE_M_UI_CORE_LOW_MEMORY="${PANEL_CORE_LOW_MEMORY}"
output_log="$LOG_DIR/$SERVICE_NAME.log"
error_log="$LOG_DIR/$SERVICE_NAME.log"
respawn_delay=5
supervisor=supervise-daemon
depend() { need net; after firewall; }
UNITFILE
chmod 0755 "$UNIT"
rc-update add "$SERVICE_NAME" default >/dev/null
fi
ensure_service_limits
}
wait_healthy(){
attempt=0
while [ "$attempt" -lt 30 ]; do
if service_active && (cd "$DATA_DIR" && GOMEMLIMIT="${PANEL_GOMEMLIMIT}" GOGC="${PANEL_GOGC}" THREE_M_UI_CONFIG="$CONFIG_FILE" "$APP_BIN" healthcheck) >/dev/null 2>&1; then return 0; fi
attempt=$((attempt + 1))
sleep 1
done
err 'Service health check failed after 30 attempts.'
}
manual_restore(){
[ -f "$SNAPSHOT" ] || err 'Usage: 3m-ui restore /absolute/path/to/snapshot.tar.gz [--yes]'
mkdir -p "$WORK/restore"
# Only archives produced by this installer are accepted; never source metadata.
tar -tzf "$SNAPSHOT" > "$WORK/members"
while IFS= read -r member; do
case "$member" in /*|../*|*/../*|*/..) err 'Unsafe snapshot member.';; esac
case "${member#./}" in ''|FORMAT|PATHS|RUNNING|ENABLED|base|base/*|config|config/*|data|data/*|entry|service) :;; *) err 'Unrecognized snapshot layout.';; esac
done < "$WORK/members"
tar -xzf "$SNAPSHOT" -C "$WORK/restore"
[ "$(cat "$WORK/restore/FORMAT")" = '3m-ui-snapshot-v1' ] || err 'Unsupported snapshot format.'
printf '%s\n' "$BASE" "$CONFIG_DIR" "$DATA_DIR" "$ENTRY" "$UNIT" > "$WORK/expected-paths"
cmp -s "$WORK/expected-paths" "$WORK/restore/PATHS" || err 'Snapshot paths differ from this installation; restore on the original host paths.'
if [ "$YES" != 1 ]; then
[ -t 0 ] || err 'Non-interactive restore requires --yes.'
printf 'Replace the installation and data with this snapshot? [y/N] '
read -r answer
case "$answer" in y|Y|yes|YES) :;; *) exit 0;; esac
fi
validate_storage "$APP_BIN"
service_active && WAS_RUNNING=1 || true
stop_existing
# Keep a snapshot of the current installation so a failed restore is reversible.
create_snapshot
TRANSACTION=1
restore_files "$WORK/restore"
service_action start
wait_healthy
TRANSACTION=0
STOPPED=0
say 'Snapshot restored and service is healthy.'
}
main(){
[ "$(id -u)" -eq 0 ] || err 'Please run as root.'
[ "$(uname -s)" = Linux ] || err 'Native installation requires Linux.'
# Avoid ambiguous paths in generated service definitions and snapshot metadata.
for path in "$ROOT" "$BASE" "$CONFIG_DIR" "$DATA_DIR"; do
case "$path" in *[[:space:]]*|*/../*|*/..|/|/etc|/usr|/var) err "Unsupported installation path: $path";; esac
done
case "$DATA_DIR" in /*) :;; *) err 'THREE_M_UI_DATA_DIR must be an absolute path.';; esac
INIT="$(init_system)"
[ "$INIT" != unsupported ] || err 'A systemd or OpenRC Linux host is required.'
if [ "$INIT" = systemd ]; then UNIT="$ROOT/etc/systemd/system/$SERVICE_NAME.service"; else UNIT="$ROOT/etc/init.d/$SERVICE_NAME"; fi
create_workdir
case "$OPERATION" in
restore) manual_restore; return;;
backup)
[ -x "$APP_BIN" ] || err '3m-ui is not installed.'
validate_storage "$APP_BIN"
service_active && WAS_RUNNING=1 || true
stop_existing
# Cleanup restarts the previous service if creating the snapshot fails.
create_snapshot
if [ "$WAS_RUNNING" = 1 ]; then service_action start; fi
STOPPED=0
return;;
update) [ -x "$APP_BIN" ] || err '3m-ui is not installed.';;
esac
install_deps
source_settings
prepare_release
validate_storage "$WORK/release/3m-ui-bin"
service_active && WAS_RUNNING=1 || true
stop_existing
create_snapshot
TRANSACTION=1
mkdir -p "$BASE" "$(dirname "$ENTRY")" "$CONFIG_DIR" "$DATA_DIR/mihomo" "$LOG_DIR"
# Replace every release-owned binary and lifecycle script (always, on install and update).
# Scripts must stay in lockstep with the panel so menu/CLI behaviour matches the release notes.
for member in 3m-ui-bin install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui; do
[ -s "$WORK/release/$member" ] || err "Release payload missing: $member"
install -m 0755 "$WORK/release/$member" "$BASE/$member"
say "Installed $BASE/$member"
done
if [ "$INSTALL_MIHOMO" = 1 ]; then
install -m 0755 "$WORK/release/mihomo" "$MIHOMO_BIN"
cp "$WORK/release/MIHOMO_VERSION" "$BASE/MIHOMO_VERSION"
for member in mihomo.env LICENSE MIHOMO_LICENSE MIHOMO_SOURCE; do
if [ -f "$WORK/release/$member" ]; then install -m 0644 "$WORK/release/$member" "$BASE/$member"; fi
done
fi
# Management entry must match the release script (never leave a stale /usr/local/bin/3m-ui).
install -m 0755 "$WORK/release/3m-ui" "$ENTRY"
# Second copy from BASE so ENTRY cannot drift if install is interrupted mid-write.
install -m 0755 "$BASE/3m-ui" "$ENTRY"
say "Installed management entry: $ENTRY"
for member in install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui 3m-ui-bin; do
[ -x "$BASE/$member" ] || err "Not executable after install: $BASE/$member"
done
[ -x "$ENTRY" ] || err "Not executable after install: $ENTRY"
printf '%s\n' "$TAG" > "$BASE/VERSION"
printf '%s\n' static > "$BASE/BUILD_MODE"
printf '%s\n' "$REPO" > "$BASE/REPOSITORY"
printf '%s\n' "$CHANNEL" > "$BASE/CHANNEL"
printf '%s\n' "$DATA_DIR" > "$BASE/DATA_DIRECTORY"
# Existing YAML/DB settings are preserved by init, including external core paths.
(cd "$DATA_DIR" && \
GOMEMLIMIT="${PANEL_GOMEMLIMIT}" GOGC="${PANEL_GOGC}" \
THREE_M_UI_CONFIG="$CONFIG_FILE" THREE_M_UI_DATA_DIR="$DATA_DIR" \
THREE_M_UI_MIHOMO_BINARY="${THREE_M_UI_MIHOMO_BINARY:-$MIHOMO_BIN}" "$APP_BIN" init)
write_service
service_action start
wait_healthy
TRANSACTION=0
STOPPED=0
say "3m-ui $TAG is ready ($REPO, channel $CHANNEL)."
say 'New installations print a one-time administrator password above. Use 3m-ui reset-admin if it is lost.'
say 'If you ever lock yourself out of the web UI (wrong port / SSL / listen address), SSH in and run: 3m-ui reset-config --panel --yes'
say "Configuration: $CONFIG_FILE"
say "Management: 3m-ui Backup: $SNAPSHOT"
}
main "$@"