For monitoring a box that isn't the VPS (e.g. a homelab machine): only the
agent needs to run there, and it connects OUTBOUND to the hub over HTTPS
using the same key + universal token flow the hub-side installer already
uses — no VPN, no router port-forwarding, and no FQDN needed on that box,
since nothing on it ever needs to be reached FROM the hub.
New register_service beszel-agent in services/beszel.sh (a second
registration in the same file, precedented by base.sh's base+glow) reuses
_beszel_configure_agent's paste/parse UX for the key/token instead of
duplicating it — that function's signature changed from a bare hub port to
a full login-URL string so both the local-hub path and this new
agent-only path can share it.
Run on the remote box: sudo ./setup.sh beszel-agent
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
The safety net (only ever touches disposable *.backup.* files, never
the newest one for any given file) makes this low-stakes enough to
just set up unprompted, the same way tab completion already is —
matches the user's own read on it. Still fully idempotent (skipped if
the timer already exists), so a rerun doesn't re-ask or redo anything.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
Two things surfaced from actual use this session:
1. Tab completion (tools/setup-completion.bash, added earlier) required
manually editing ~/.bashrc — easy to skip or get wrong (confirmed
live: the source line never actually landed the first time). base
now wires it in automatically (idempotent, checked by grep first),
matching how it already touches ~/.bashrc for SSH Host aliases.
2. No pruning existed anywhere for the *.backup.<timestamp> files ~60
different services create before overwriting a live config
(Caddyfile, /etc/fstab, etc) — every one of them backs up, none
clean up, so they accumulate forever on a box reconfigured
regularly. tools/prune-old-backups.sh prunes by file mtime (not by
parsing the timestamp out of the filename — robust to the
%Y%m%d-%H%M%S vs %Y%m%d_%H%M%S inconsistency across services),
always keeping the single newest backup per distinct file
regardless of age. Verified both the normal case (mixed old/new,
prunes only the old ones) and the edge case (every backup for a
file is old, keeps the newest one anyway) against real fixtures.
base offers it as a daily systemd timer (prompted, since it deletes
files — unlike the tab-completion wiring, which doesn't).
Also added logrotate for Caddy's own access logs
(/var/log/caddy/*.log), which had no rotation at all and grow
unbounded on an active box. Uses copytruncate specifically: the log
directory is bind-mounted into the running Caddy container and read
live by CrowdSec, so truncating in place avoids either of them needing
to notice or react to a rotation happening.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
./setup.sh mat<TAB> now completes to ./setup.sh mattermost, same for
flags. Service names are read fresh from services/*.sh on every
completion — never a hardcoded list, which would go stale the moment
a new service file gets added (matches this repo's own "adding a
service = adding one file, nothing generated" rule from CLAUDE.md).
Verified live: sourced the script and confirmed completions for "mat"
and "--li", and specifically confirmed "bes" resolves to "beszel" —
the service added earlier this same session — with zero changes
needed to the completion script itself, proving the list is genuinely
dynamic rather than something that looked right once and then rotted.
Self-locating via its own BASH_SOURCE path rather than a hardcoded
install directory, so it keeps working regardless of where the repo
is cloned. Works through a leading `sudo` via bash-completion's
standard sudo pass-through (enabled by default on Ubuntu).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
Answers "what's the best way to see CPU/RAM/disk usage on this box"
(IONOS's own dashboard doesn't expose it) and "does Gatus cover this" —
it doesn't, Gatus is a black-box HTTP check (is the site responding
from the outside), Beszel is white-box host/process monitoring (is the
box under memory/disk pressure, is a container actually running vs.
crash-looping). Complements Gatus rather than replacing it.
Mirrors the hub+agent same-system layout from beszel's own
supplemental/docker/same-system/docker-compose.yml (fetched from the
actual upstream repo, not reconstructed from memory) — hub is the web
dashboard, agent reads /var/run/docker.sock (read-only) to report every
currently-running container automatically, no per-service config
needed as containers get added or removed.
Genuinely a two-phase install: the hub's SSH keypair and universal
token only exist after logging into its web UI once, so this starts
the hub, walks through where to find both values, and finishes wiring
the agent once provided — skipping is fine, a rerun in "update" mode
detects the agent was never connected and offers to finish it.
Verified the generated docker-compose.yml/.env by running the actual
file-writing code path with docker/configure_caddy_for_service mocked
out — confirmed TOKEN/KEY are correctly left as literal
${AGENT_TOKEN:-}/${AGENT_KEY:-} for Docker Compose's own substitution
at "up" time, not prematurely expanded by the heredoc itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
The VPS side of a plain SMB mount necessarily sees plaintext while it's
mounted and in use — that's unavoidable for data a VPS service actually
needs to read. What's avoidable is everything else: a disk image,
backup, or provider-side look at the VPS while the mount isn't actively
in use showing your actual files instead of ciphertext.
tools/gocryptfs-setup-home.sh (new): standalone tool for the home box.
Creates a gocryptfs-encrypted directory and passphrase file; the user
points their existing Samba share's `path =` at the cipherdir (manual
step — same read-only stance on remote Samba config vpn-data-mount.sh
already takes, this tool doesn't touch smb.conf either).
services/vpn-data-mount.sh: after mounting a share over CIFS as before,
optionally offers a gocryptfs decrypt layer on top. Fetches the
passphrase fresh over the same SSH trust already used for share
discovery, pipes it straight into gocryptfs, and never writes it to the
VPS's own disk. A generated systemd unit (via a wrapper script, not one
long quoted ExecStart= one-liner — avoids stacking systemd's own
word-splitting on top of bash -c's) keeps the decrypted view coming back
on boot, re-fetching the passphrase each time rather than caching it.
Fully opt-in and per-share — a plain unencrypted mount works exactly as
before if declined.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
Per direct request: never write to the home box's smb.conf at all, not
even carefully — just discover what's already shared there and mount it.
Removes all remote provisioning (installing Samba, creating/removing
share blocks, resetting smbpasswd accounts) entirely, which also removes
the whole class of bug the previous two fixes were patching around
(destructive section-removal, clobbering another mount's saved password) —
a tool that can't write can't repeat that kind of damage.
New flow: resolve/name the host and bootstrap SSH trust as before, then
read-only list every real share already in the home box's smb.conf
(skipping [global]/[homes]/[printers]/[print$]) via a plain SSH `cat`,
falling back to a sudo'd read only if that comes back empty — still only
ever reading. Presents them as a numbered list and accepts a flexible
selection ('1', '1,3', '1-3', '1 3 5', or combinations), asks once for the
Samba username/password to connect with (reusing a previously-saved
password for the same user+host if one exists), then mounts each picked
share locally over CIFS with its own /etc/fstab entry — same as before.
Verified the selection parser against all the documented formats plus a
mixed comma+range case and garbage/empty input.
New section covering what it does, the public-vs-private-key security
model (only public keys are ever fetched, no outbound capability like
private-repo access is granted), and how to run it standalone via
sudo ./setup.sh ssh-key-import. Placed ahead of the existing SSH Host
aliases section since that section already references key import as
prior context ("after SSH key import, the wizard offers to add...").
Was only ever runnable once, buried inside base.sh's required-setup flow —
no way to re-run just this step for a box that already went through base
setup but needs another admin's key added later, or (the immediate case)
a home box for services/vpn-data-mount.sh that only needs this one step.
services/ssh-key-import.sh holds the real logic now (GitHub/Launchpad
import via ssh-import-id, optional password-auth lockdown); base.sh's
_base_setup_ssh chains into it the same way services/asterisk.sh chains
into security-dashboard/pstn-trunk, with a degraded (no import, just
ensures the SSH server itself is running) fallback for a pure standalone
`sudo bash base.sh` run with no sibling files sourced. Independently
runnable via `sudo ./setup.sh ssh-key-import` or `sudo bash
services/ssh-key-import.sh`, and shows up in the whiptail menu under
extras alongside ssh-config. Marked as never showing [installed] in
is_installed()/install_count(), same as ssh-config — it's a repeatable
management action, not a thing with an install state.
Offered right after NetBird setup during required/base setup, matching
the requested flow (base packages -> NetBird -> data mount). Repeatable
by design rather than a one-shot step, since different services can have
data on different home boxes — asks for a home box IP every time and can
be run again for additional boxes/shares.
Flow: test for existing passwordless SSH first (covers "both boxes already
share a key via GitHub import, or any other means" for free — if it
already works, nothing else runs). If not, generate an SSH keypair and
offer ssh-copy-id or a manual/GitHub-import fallback (ssh-import-id, the
same mechanism base.sh's own SSH setup already uses) — needed because a
home box that took base.sh's "disable password login" option won't accept
ssh-copy-id at all. Once passwordless SSH works, use it to remotely
install and configure Samba on the home box for a chosen path, then mount
it locally over CIFS with a tagged /etc/fstab entry.
SMB over NFS/SSHFS per this session's direction: not a "huge" speed gap
for normal use, and SSHFS's own encryption is redundant overhead once the
VPN tunnel already encrypts everything. Guest-accessible (no separate
Samba credentials) since the VPN is the real access control — only
NetBird-connected peers can reach the home box's NetBird IP at all.
Also:
- cifs-utils added to base.sh's always-installed packages, same reasoning
as Docker/Compose being unconditional there instead of installed lazily
on first mount.
- is_installed()/install_count() in setup.sh gained a vpn-data-mount case
(state lives in tagged /etc/fstab entries, not $DOCKER_DIR, since this
isn't a Docker service) — mirrors wordpress's "count real instances"
handling rather than a flat 0/1.
- Every SSH call in the new service explicitly runs as $ACTUAL_USER
(sudo -u), not root — the script itself runs as root throughout, but the
SSH key lives in $ACTUAL_HOME/.ssh, so a bare `ssh` call would silently
use root's own ~/.ssh instead and never find it. Caught by review before
this shipped, not after.
- UNATTENDED mode skips outright with a message instead of spinning
forever on prompt_text's always-blank default under --unattended, since
none of this flow's prompts (home box IP, remote path, ...) have a
sane non-interactive default.
install_coturn (services/coturn.sh) cd's into $DOCKER_DIR/coturn and never
restores the caller's original working directory. A consumer that chain-
installs coturn mid-flow (e.g. asterisk.sh, already cd'd into its own
install directory) returned from ensure_coturn_user still sitting in
coturn's directory, then went on to write its own docker-compose.yml/.env
there instead of its own directory — clobbering coturn's compose file and
leaving the consumer's directory without one. The consumer's later
`docker compose up --build` then failed with "Dockerfile: no such file or
directory", since the Dockerfile was correctly in the consumer's directory
but the misplaced compose file (and the build) were not.
ensure_coturn_user now saves/restores the caller's cwd around the
install_coturn call, fixing this for every consumer (asterisk, mattermost).
Also fold cloud-init.sh's contents into a collapsible README section so
it's copy-pasteable straight from the repo instead of requiring a separate
file download.
IONOS's User Data field takes a Script Type choice (Cloud Config vs Shell
Script) and runs the pasted/imported content directly rather than fetching
a URL. Update the README to say so, add DEBIAN_FRONTEND=noninteractive for
genuine unattended cloud-init execution.
IONOS Cloud Server, DigitalOcean, and Hetzner all offer an "install
script"/user-data field that runs as root with no TTY while the image is
still provisioning, so bootstrap.sh's interactive tail can't run there.
cloud-init.sh clones the repo unattended and drops a one-shot
/etc/profile.d hook that launches the normal whiptail setup.sh wizard on
the first interactive login, then removes itself.
Reconsidered after the shared-MariaDB design's real cost became clear:
Kopia's generic backup (services/backup.sh) stops a service's
container to snapshot it, so a shared MariaDB instance would back up
-- and would have to be restored -- as one unit covering every site at
once. Restoring just one site's database to an earlier point meant
restoring the whole shared snapshot to a temporary location first and
manually extracting that site's data back out, not a direct restore.
Each site now gets its own dedicated MariaDB container embedded in its
own docker-compose.yml (same pattern as services/nextcloud.sh) instead
of registering a database on a shared instance:
- Removed _wordpress_ensure_shared_db() and the wordpress-db/
wordpress_net shared resources entirely.
- Each site's compose file gets a `db` service (container
<site>-db) on an explicitly-named per-site default network
(<site>_net), so wp-cli's one-off container reliably joins the
right network without depending on Docker Compose's implicit
naming convention.
- DB creation goes through the mariadb image's own MYSQL_DATABASE/
MYSQL_USER/MYSQL_PASSWORD env vars on first boot (same as
nextcloud.sh) instead of an imperative `docker exec mysql -e
"CREATE DATABASE..."` against a shared container.
- Root and site DB passwords are both reused across reruns (read from
the existing .env), verified via a real update-mode rerun.
Tradeoff, stated in both the script's header comment and the generated
per-site README: more RAM per site (~100-150MB for a full MariaDB
container instead of a slice of one shared instance) in exchange for
independent backup/restore. Data was already fully isolated either way
(separate database + user, always required since WordPress's schema
uses generic table names) -- the shared-vs-dedicated choice was only
ever about the container/process, not the data.
Re-verified end-to-end against the fake docker shim: distinct ports,
distinct dedicated DB containers/networks per site, correct compose/
.env structure, credentials preserved across an update-mode rerun.
docs/vps-sizing-recommendations.md: updated to match -- WordPress
capacity recomputed for dedicated-per-site MariaDB (~580MB headroom at
4 sites, ~976MB at 2, vs. the shared design's ~700MB/~950MB).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
New service: self-hosted WordPress, sized for running several
independent sites the way a hosting company would, not just one blog.
- Multi-site from the start: every site requires a name (no unnamed
"first instance" special case like mattermost's — there's no
backward-compat reason to special-case one here) and gets its own
directory/container/port, but all sites share ONE MariaDB container
(chain-installed on first site, reused by every other one) instead of
a dedicated database container per site — same resource-sharing idea
as services/coturn.sh, just scoped to WordPress's own sites rather
than shared across different services. Each site gets its own
database + user within that shared instance.
- E-commerce is just WooCommerce, a normal WordPress plugin — no
separate infrastructure. PHP memory_limit/upload_max_filesize/
post_max_size are pre-tuned (256M/64M/64M) so a product-catalog
import doesn't hit default-image limits on the first try.
- wp-cli (official wordpress:cli image, run as a one-off container
sharing the site's html volume) does the initial WordPress core
install non-interactively — title, admin account — so there's no
browser setup wizard to remember per site. Falls back to printing
the exact manual command if the site wasn't ready in time.
- Auto-scans for a free host port per site (multiple sites can't all
bind 8090), matching the "auto-scanned free ports for extras" idea
already used by mattermost's multi-instance support.
- DB and admin passwords are reused across reruns (checked against the
DB-password-regeneration bug class already fixed elsewhere in this
repo, e.g. PR #265) — verified via a real update-mode rerun that the
credential doesn't change.
- setup.sh: is_installed() gets a wordpress case — every site is named
from the first one on, so there's never a plain $DOCKER_DIR/wordpress
directory the default case could match against.
- README.md: added to the utilities services table + copiable list per
CLAUDE.md's three-step rule for new services. Also fixed `coturn`
being in the homelab row's prose but missing from the copiable list
block below it — a pre-existing gap from when coturn.sh was merged.
Verified end-to-end via non-interactive dry runs against a fake docker
shim (no live daemon in this environment): 3 sites installed in
sequence get 3 distinct databases, 3 distinct auto-scanned ports, the
shared DB is only set up once, and an update-mode rerun preserves the
existing DB password rather than regenerating it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
Asterisk and Mattermost each used to embed their own dedicated coturn
container (network_mode: host), and their default relay port ranges
overlapped by ~100 UDP ports — running both on one box meant a
coin-flip over which service's active call lost its media relay.
- services/coturn.sh: new shared TURN/STUN relay, one instance for
every consumer instead of one each. Runs --lt-cred-mech with a
SQLite user database (not --use-auth-secret — coturn doesn't
support both auth mechanisms on one instance at once, confirmed via
coturn's own upstream docs/issues) so each consumer gets its own
dedicated username/password without stepping on any other's.
- lib/common.sh: ensure_coturn_user() — chain-installs coturn.sh on
first need (same declare -F guard pattern as the existing
asterisk -> security-dashboard chaining) and registers/reuses a
per-consumer credential, mirroring configure_caddy_for_service's
out-param convention.
- services/asterisk.sh: _asterisk_write_compose gains a
USE_EMBEDDED_COTURN flag. New installs use the shared service;
existing installs keep their dedicated coturn exactly as-is on
every "update" (detected from the existing compose file before
regenerating it, so a rebuild can never silently drop the container
its own .env TURN_PASSWORD still points at) and only switch on an
explicit "fresh" reinstall, with a warning first.
- services/mattermost.sh: same embedded/shared coturn handling, plus
genuine multi-instance support (separate dir/containers/DB/ports per
instance, auto-scanned free ports for extras) for real isolation
between groups, as opposed to Team Edition's built-in Teams feature.
Calls plugin TURN config switched from the HMAC "TURN Static Auth
Secret" field to the verified "ICE Servers Configurations" JSON
field, which accepts the same fixed username/credential shared
coturn issues. Also fixes a latent bug found while adding proper
update-mode detection: DB_PASS/MM_SECRET were regenerated on every
single rerun with no existing-install check at all, silently
breaking Postgres auth on any reinstall.
- CLAUDE.md: documents the ensure_coturn_user pattern (including the
auth-mechanism constraint and the embedded-coturn migration-safety
rule) for any future service that needs TURN.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQkdAn3iG5A4WoqU9FHMaN
restore_kopia.sh is interactive and one-service-at-a-time, which doesn't
scale to standing up a cold spare box quickly during a real outage.
dr_bringup.sh restores every service's latest snapshot (or one named
service) and runs docker compose up -d with no prompts, so a full-stack
recovery is one command instead of N interactive restores.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQkdAn3iG5A4WoqU9FHMaN
Matches the existing vendor/easy-asterisk convention (used by
services/asterisk.sh) instead of two one-off top-level directories that
cluttered the repo root and didn't look like anything else next to
setup.sh, lib/, services/, extras/. Only the two services' own SRC_DIR
path resolution and header comments needed updating — nothing else in
the repo referenced the old ./ai-stack / ./paintplus paths.
Also documents vendor/ in README.md's Layout section.
Applies the new write_readme companion-doc convention to ai-stack,
paintplus, and kyber-launcher: install-time-invariant content (usage
walkthroughs, service tables, troubleshooting) moves out of the .sh
heredocs into sibling services/<name>.md files, leaving only genuinely
install-specific content inline.
- services/paintplus.md: config/cloud/GPU/ai-stack-backend/update/Caddy
sections, picked up automatically via write_readme's companion-doc
support.
- services/ai-stack.md: roles, GPU switcher, service URLs, cloud LLM
provider setup, update, Caddy. ai-stack.sh can't use write_readme
directly (its POST-INSTALL-NOTES.md filename deliberately avoids
colliding with the vendored app's own README.md in the same
directory), so it appends the companion file manually.
- services/kyber-launcher.md: the full SWBF2 (2017) + Kyber walkthrough,
moved out of the root README's "Gaming scripts" section (which now
just points here). kyber-launcher.sh now calls write_readme to deploy
it to ~/.local/share/kyber/README.md, fixing a stale in-script pointer
to a README section that no longer exists.
New service for one narrow job — getting SMS verification codes sent to a
VoIP number onto a phone with no SIM. Deliberately not a texting app: no
outbound path (Anveo Direct has none; that needs an Anveo Retail account, and
a free texting app covers sending), and messages arrive as push notifications
rather than being routed into Asterisk as SIP MESSAGE, since a code you read
and type is better served by a notification than a softphone chat thread.
Two modes, both driven entirely from the provider's "forward SMS to URL" box:
- direct — the provider calls ntfy itself; nothing installed here. ntfy
accepts GET publishing at /{topic}/(publish|send|trigger) with message and
title as query params, and auth via ?auth= holding base64url (unpadded) of
the literal "Bearer <token>" — confirmed against ntfy's server.go and
server_auth.go rather than its docs.
- relay — a stdlib systemd service, Caddy-fronted on its own domain with no
Authelia (the provider can't log in; a random 32-char token in the path is
the secret). Buys two things direct mode can't have: an unescaped "&" in a
message body survives intact, because the relay takes everything after the
last message= verbatim instead of parse_qs — which is why the generated URL
always puts the message placeholder last — and no ntfy credentials sit in a
third party's web portal.
Verification codes are bearer credentials, so: a 24-char random topic name
(the repo's ntfy defaults to auth-default-access: read-write, making the topic
name the read credential), constant-time token compare, a 60/min rate limit,
and the relay logs sender/recipient/length but never the message body.
The Anveo guide gains a section covering the two things that actually decide
whether codes arrive: short-code support (Anveo has it, unusually — VoIP.ms
does not except for Google) and Anveo's carrier-sourced *mobile* DIDs, which
are classified as mobile in the lookups that reject VoIP numbers at signup.
Also documents MMS and group texts being out of reach, and why the native
Messages app never sees any of this.
Verified against a stub ntfy: plain OTP, encoded "&", unencoded "&", "+" as
space, wrong token (404), missing message (400) and the rate limit (57x204
then 429) all behave; both installer modes were run end to end in a sandbox
and their generated URLs, settings files and READMEs checked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
services/asterisk-digital-ocean.sh was a near-verbatim copy of
services/asterisk.sh — same vendor refresh, compose template, messaging
dialplan, presence alerts, UFW rules and dashboard/trunk chaining, with the
helper functions renamed _asterisk_do_*. Two copies meant every fix had to
land twice, and several never did.
There is now one `asterisk` service. It reads the DigitalOcean metadata
service and asks either way (so a droplet with metadata blocked, or another
provider's public VM, can still opt in), then gates the genuinely
droplet-specific behaviour on that one answer: swapfile for low-RAM plans,
public-FQDN-only setup with no LAN/VLAN prompts, a Caddy site block pinned
to that FQDN, the remote-Authelia option, and the doctl Cloud Firewall.
Two things that were droplet-only for no real reason now apply everywhere:
the entrypoint patch that writes security-level events to logs/full, and
the logrotate config for that file. Without them the Security Dashboard's
Security Log tab and CrowdSec's Asterisk acquisition were silently empty on
every home/LAN install; crowdsec.sh now detects either install directory.
Existing droplets are left alone: an install at ~/docker/asterisk-digital-ocean
keeps its directory and easy-asterisk-do container names, since its Caddyfile
block, UFW rules, Cloud Firewall, CrowdSec acquisition and PSTN trunk all
name those exactly. New installs use ~/docker/asterisk / easy-asterisk.
`sudo ./setup.sh asterisk-digital-ocean` still works via a new SERVICE_ALIAS
map in setup.sh, without a second menu entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
Generalizes services/pstn-trunk.sh (renamed from voipms-trunk.sh in the
prior commit) away from VoIP.ms specifics - any IP-authenticated SIP
provider works, VoIP.ms is just the suggested default. Adds:
- Role-based outbound permission: a configurable allow-list of extensions
that may dial PSTN numbers (regex-gated on CHANNEL(peername)), separate
from internal extension-to-extension dialing which stays open to everyone
regardless. Blank list preserves the original "everyone can dial out"
behavior.
- Inbound ring-group: rings a configurable list of extensions instead of a
single hardcoded one.
- ntfy alerts: immediate on denied (unauthorized extension) or rejected
(concurrency cap hit) calls, plus an hourly cron-driven check that alerts
once per month when estimated spend crosses a threshold and every hour
call volume looks like a burst. Uses a self-contained pipe-delimited call
log rather than Asterisk's CDR, to avoid depending on CDR module
availability and CSV comma-quoting.
- Settings persisted to .pstn-trunk.env so "update in place" reapplies
everything from that file instead of fragile re-parsing out of generated
Asterisk config (which had a real bug: update mode was extracting the
wrong Dial(PJSIP/...) line).
Tested end-to-end against a sandboxed copy of the real vendor files:
permission-gate regex, ring-group dial-string construction, ntfy line
injection/removal, and the usage-alert script's threshold/burst/monthly-
dedup logic all verified with synthetic data. Caught and fixed a sed `&`
escaping bug in the ring-group substitution before it shipped (RING_DIAL
contains literal `&` join characters, which sed's replacement syntax
otherwise treats as "insert the match").
Adds a VoIP.ms SIP trunk on top of asterisk-digital-ocean: IP-authenticated
trunk (no password stored), NANP-only outbound dialplan, a global 3-call
concurrent cap via GROUP()/GROUP_COUNT(), and inbound routing to one
extension. Config lives in its own include files rather than being
appended directly to pjsip.conf/extensions.conf, since Easy Asterisk fully
regenerates both from its own internal state — the includes are patched
into the vendor's generator functions so they survive that regeneration.
Wires the new service into setup.sh's is_installed() and README's services
table, and updates docs/pstn-calling-voipms-plan.md to reflect what's now
implemented vs. still open (spend/volume alerting, live-account
verification).
New service, native on the host (not Docker) so it can call cscli and
read Asterisk's security log directly without bridging the
container/host boundary or exposing CrowdSec LAPI credentials to a
containerized frontend.
- Security Log tab: parses ~/docker/asterisk-digital-ocean/logs/full
for SIP auth failures (wrong password, unknown extension, etc.) with
timestamp/account/remote IP, classified by severity.
- CrowdSec tab: current bans via cscli, a delete/unban button per
entry, and ASN-exempt management for the Asterisk brute-force
scenarios (services/crowdsec.sh) without SSHing in.
- Link out to the existing Asterisk web admin (reads its domain from
asterisk-digital-ocean's own .env, doesn't hardcode or embed it).
Runs as a dedicated unprivileged system user (secdash), with sudo
scoped to exactly three commands via /etc/sudoers.d/security-dashboard
(cscli decisions delete --id <digits>, cscli decisions list -o json,
systemctl restart crowdsec) — validated with visudo -c. Listens on
127.0.0.1 only, reachable through Caddy, and refuses to proceed without
explicit confirmation if no Authelia (local or remote) is configured,
since this page can delete active security bans.
Stdlib-only Python (no framework), matching the RAM-conscious pattern
already used for Easy Asterisk's own web admin. All embedded code
(bash, Python, JS) syntax-checked; the generated sudoers rule
validated with visudo -c -f.
Renamed services/asterisk-do.sh -> services/asterisk-digital-ocean.sh
(register_service name, install function, install dir, and all prose/
comments) so the whiptail menu shows a clearer, more discoverable name.
Updated the functional cross-references that depend on the old name:
crowdsec.sh's SIP-log auto-detection path and acquisition filename,
caddy.sh's host.docker.internal comment, and the CLAUDE.md/README.md
docs (services table, directory listing, network-wiring example).
Container names, the Docker Compose project name, and the internal
_asterisk_do_* helper function identifiers are left unchanged since
they aren't user-facing and renaming them would add risk for no
benefit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi
Duplicates services/asterisk.sh (left untouched) into a DO-specific
variant: auto-detects the droplet's public IP/ID via the DO metadata
service, always assumes a public FQDN (no LAN/VLAN prompts), offers to
provision a matching DigitalOcean Cloud Firewall via doctl (never
touching one that's already attached), and documents droplet sizing,
firewall rules, and Sipnetic client setup in the generated README.
Lets 'ssh <alias>' connect directly to user@host instead of retyping it —
especially useful once machines are reachable over NetBird/VPN and have
IPs that aren't worth memorizing.
- lib/common.sh: ssh_config_path/add_ssh_host_alias/list_ssh_host_aliases/
remove_ssh_host_alias helpers, operating on the invoking user's own
~/.ssh/config (not root's) with correct 700/600 permissions and ownership
- base.sh: after SSH key import, optionally add one or more Host aliases
interactively as part of the base install
- services/ssh-config.sh: new standalone service (sudo ./setup.sh ssh-config)
to list/add/remove aliases any time, independent of base install; follows
the existing non-Docker standalone-bootstrap pattern (see crowdsec.sh)
- setup.sh: ssh-config never shows [installed] since it's a repeatable
management tool, not a one-time install
- README: new 'SSH Host aliases' section, base row and wizard-flow step 1
updated, ssh-config added to the extras group and copiable service list
Verified end-to-end with a test harness: add with defaults, add with a
custom user/port, list (correct numbering), and remove-by-name preserving
the other entry and file permissions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQJBvqzXeyuhhAcAA3Q5Wq
The wizard description was stale — it still described the old
site-defaults-first flow and didn't mention that base now installs Docker,
openssh-server (with SSH key import), and NetBird, or that the wizard ends
by dropping into a fresh login shell so the docker group takes effect.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQJBvqzXeyuhhAcAA3Q5Wq
capture.sh already replaces motionEye/any NVR itself - it just needs
each camera's RTSP URL. services/sky-cam.sh never actually prompted for
CAM_RTSP_<cam>, so capture/audio never had anything to connect to.
- Prompt per camera for its RTSP URL -> CAM_RTSP_<cam> in .env
- Prompt for sunrise mic / optional ambient audio library
- Fix Mattermost integration: sunrise2mm.py reads mattermost_url/
access_token/channel_id (bot-token REST upload), not the
MM_WEBHOOK_URL/MM_CHANNEL incoming-webhook scheme the installer used
to write - uploads never worked before this
- Add optional ntfy push notifications
- Auto-generate SCHEDULE_SEASONS_<cam> (staggered 30 min apart) for
every configured camera, not just the stock east/north/south, so
install.sh wires up every applicable systemd timer for any camera set
Removes services/sky-cam-frigate.sh entirely - routing sky-cam's frames
through Frigate (via export API or restream) turned out to be solving a
problem that doesn't exist; sky-cam's own capture.sh talking directly to
each camera is simpler and has no quality/resolution tradeoffs. Frigate
continues to run fully independently for NVR/detection.
Duplicates services/sky-cam.sh into a Frigate-backed variant that pulls
recordings via Frigate's export API instead of a JPEG image folder.
Includes a frigate-retime.sh helper that exports a coarse timelapse,
measures its actual duration with ffprobe, and re-encodes once with a
computed setpts factor to hit an exact target length (e.g. a Four
Seasons movement's runtime).
Vendor the functional source of github.com/outis1one/local-ai into
./ai-stack (22 files) and add services/ai-stack.sh, which copies the
source to ~/docker/ai-stack and hands off to the app's VRAM-aware
installer (local-ai-setup.sh). The stack bundles Ollama, Open WebUI,
RAG + MCP servers, ChromaDB, SearXNG, Kiwix, Gitea, InvokeAI, ComfyUI
and Portainer.
Cloud LLM providers (Groq/DeepInfra/OpenAI/OpenRouter) are optionally
wired into Open WebUI via the plural OPENAI_API_BASE_URLS list, with the
local RAG connection kept as the first entry so RAG keeps working. Open
WebUI ships built-in auth, so Caddy is configured without Authelia.
Excludes the upstream's two bundled copies of this very project
(ubuntu-post-install.sh, ubuntu-post-install-main.zip) — stale and
circular. Coexists with the existing ai-gpu service.
Also fix the install-function names for ai-gpu and ai-stack: the
dispatcher calls install_<raw-name>, so the function must be
install_ai-gpu / install_ai-stack (hyphen), matching the working
mail-archiver / wg-easy services. ai-gpu was previously uninstallable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nb2vJ8W7bHKx1JXVvpCraH
Bring the full EditmaskwithAI application into the repo under paintplus/
(429 files) so the service is self-contained — the installer copies the
vendored source to ~/docker/paintplus/src instead of cloning at runtime.
Rename to PaintPlus (service + branding; app logic untouched):
- services/editmaskwithai.sh -> services/paintplus.sh (register_service
paintplus, install_paintplus, ~/docker/paintplus, Caddy paintplus:8000,
Authelia option preserved)
- container names -> paintplus across docker-compose*.yml; dev network
-> paintplus-network
- browser <title> -> "PaintPlus - AI Image Editor"; README heading ->
PaintPlus with upstream provenance note
- README utilities table: editmaskwithai -> paintplus
Backend/frontend code (help strings referencing the old container name,
the ai_photo_edit.db filename) is intentionally left as-is to avoid
touching application logic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nb2vJ8W7bHKx1JXVvpCraH
Package the self-hosted mask-based AI photo editor
(github.com/outis1one/EditmaskwithAI) as a utilities service. Clones the
repo into ~/docker/editmaskwithai and deploys on port 3080.
Two install modes:
- Cloud API (no GPU): writes .env from the repo template with the chosen
AI_PROVIDER (OpenAI gpt-image or Replicate), API key, and a generated
SECRET_KEY, then `docker compose up -d --build`.
- Local GPU (NVIDIA): runs the repo's own install-local-gpu.sh and
bring-up-local-gpu.sh (downloads ~13 GB of models).
The app has no built-in auth, so it offers Authelia SSO and reverse-proxies
via Caddy (ai-photo-edit:8000). Cloud mode joins caddy_net through a
docker-compose.override.yml; GPU mode is attached with `docker network
connect` since the GPU compose runs with an explicit -f and does not merge
overrides.
Also lists the service in the README utilities table.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nb2vJ8W7bHKx1JXVvpCraH
Installs the Kyber community multiplayer launcher (simonlinuxcraft/kyber-
linuxport-unofficial) via the setup.sh wizard. Handles glibc check, bwrap/
userns fix, GitHub release fetch, desktop entry, and bin symlink. Warns on
Intel integrated graphics and links to Sunshine as the streaming alternative.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D8ckUJQtj1pH8jtAddBDZs
Installs Sunshine (LizardByte) as a Moonlight-compatible streaming host,
offset to port 48090 so it coexists with Wolf. Auto-detects the Kyber
AppImage and adds a "Kyber SWBF2" app entry. Optional virtual display for
closed-lid/headless use. uinput udev rules for controller/mouse input.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D8ckUJQtj1pH8jtAddBDZs
- Auto-detects SWBF2 install path; loud error with install instructions if missing
- Downloads Kyber AppImage if not present, extracts kyber_cli, runs get_token
(opens browser for EA OAuth — one-time step, token never expires)
- Prompts for EA credentials (written to .env, chmod 600) and server name
- Auto-detects GPU (NVIDIA/Intel/AMD) and adds appropriate passthrough config
- Writes docker-compose.yml + .env to ~/docker/kyber-server/
- Map rotation left blank by default with instructions in .env comments
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D8ckUJQtj1pH8jtAddBDZs
- Add troubleshooting for 'Origin Error: title installed in language not
entitled to play' — Maxima's umu-run reg commands fail silently on some
systems; manual wine64 regedit import of Origin locale keys fixes it
- Add windowed-mode / HDR fix for SWBF2 crash during level load on
integrated GPUs (Intel Iris Xe, DXVK rendering crash)
- Document fix in both README.md and script header comments
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D8ckUJQtj1pH8jtAddBDZs
- Bots are set via BOTS TEAM 1 / BOTS TEAM 2 sliders in HOST panel (not automatic)
- Correct launch order: Steam open but don't click Play; Kyber launches SWBF2 itself
- Document Alt+Tab / taskbar click workaround for window focus issue after game loads
- Add bwrap unprivileged userns fix (Ubuntu 24.04 blocks by default); script applies
automatically with sudo and persists via /etc/sysctl.d/99-userns.conf
- Script now checks/fixes userns at step 0 before download
- Kyber manages its own GE-Proton; no need to configure Proton for SWBF2 in Steam
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D8ckUJQtj1pH8jtAddBDZs
The Windows kyber_launcher.exe approach under Wine/Proton has two
unfixable problems on Linux: Wine's cmd.exe crashes with STATUS_ACCESS_VIOLATION
on the long EA OAuth URL, and EA's auth callback uses the eadesktop://
URI scheme which has no Linux handler.
The correct solution is the native Linux port AppImage maintained at
github.com/simonlinuxcraft/kyber-linuxport-unofficial. It bundles Maxima
(open-source EA Desktop replacement) and handles OAuth natively — no shims,
no watchers, no per-Proton-update maintenance.
Script now: fetches latest release from GitHub API, downloads AppImage,
installs desktop entry and ~/.local/bin/kyber symlink.
README: add Gaming scripts section documenting Kyber setup, private server
hosting with bots, requirements, and what does not work.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D8ckUJQtj1pH8jtAddBDZs
iopaint: AI image inpainting (object removal, fill, restore) via IOPaint +
LaMa model. Runs CPU by default; GPU option writes nvidia deploy block.
No built-in auth — Authelia SSO prompt included. Port 8100.
ai-gpu: GPU AI stack from outis1one/ai-6gb-gpu. Clones repo and sets up
three stacks under ~/docker/ai-gpu/: InvokeAI image gen (port 9090),
Ollama + Open WebUI + SearXNG LLM stack (ports 11434/3000), and Flask
portal (port 8080) that hot-swaps the GPU between stacks. Patches
hardcoded home paths in portal docker-compose.yml to use ACTUAL_HOME.
Prompts for TZ (replaces hardcoded America/New_York). Caddy for both
portal (localai) and InvokeAI (images).
https://claude.ai/code/session_01JEu7LgCWXKhXo18MeYFRZp
- joplin: self-hosted sync server (PostgreSQL sidecar, APP_BASE_URL from SITE_DOMAIN)
- stirling-pdf: PDF toolkit with optional Authelia SSO (no built-in auth)
- n8n: workflow automation connecting self-hosted services (WEBHOOK_URL from SITE_DOMAIN)
- changedetection: web page change monitoring with playwright-chrome renderer
- archivebox: personal Wayback Machine, initializes data dir at install time
- calibre-web: ebook library UI with Calibre conversion support (linuxserver image)
- homebox: home inventory and asset management
All services follow the standalone bootstrap pattern and support local/remote Caddy.
README.md updated with new services in appropriate group rows.
Integrates https://github.com/outis1one/easy-asterisk into the post-install
system. Downloads the management script and coturn entrypoint at install time,
generates docker-compose.yml with host-networking Asterisk + coturn, writes
a randomised TURN password, and opens UFW ports for SIP/RTP/TURN.
Interactive FQDN setup chooses between LAN-only (UDP, no TLS) and FQDN mode
(TLS+SRTP+TURN) and prints required router port-forward instructions.
https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt