# seolcu notes — full text > Verified fixes and setup notes for Linux, self-hosting and home-lab operations, plus an index of seolcu's open-source projects. Every page states its status, the environment it was verified on, and when. Generated 2026-11-09. Index: https://blog.seolcu.com/llms.txt --- # Automatic `docker compose pull` updates slowly fill the disk with dangling images > Each `docker compose pull` + `up -d` leaves the previous image untagged (dangling). Nothing removes it, so weekly auto-updates grow `/var/lib/containerd` without bound. Run `docker image prune --force --filter until=336h` after the post-update health check passes. - URL: https://blog.seolcu.com/notes/docker-compose-pull-dangling-images-fill-disk/ - Status: working - Verified: 2026-11-09 — Root cause of a full 232 GB root filesystem on an Ubuntu 24.04 home server on 2026-08-09; fix running in weekly update jobs since. - Applies to: docker Docker Engine with containerd image store, Compose v2; os Ubuntu 24.04 LTS - Search terms: `no space left on device`, `/var/lib/containerd`, `dangling images`, `docker image prune until`, `docker compose pull disk full` - Last changed: 2026-11-09 ## Symptom Services started crash-looping when the root filesystem reached 0 bytes free. In this case `/var/lib/containerd` held 21 GB of 20 dangling images, about 1.2 GB per service per week, accumulated over months of weekly unattended updates. ```bash docker image ls --filter dangling=true sudo du -sh /var/lib/containerd /var/lib/docker 2>/dev/null ``` ## Cause `docker compose pull` re-points the tag at the new image, and the old one becomes `:`. Compose and the Docker daemon never delete it automatically. ## Fix Prune **after** the health check passes. Until then the previous image is your rollback target. Keep a window of two weeks: ```bash docker compose pull docker compose up -d # ... wait for the health endpoint ... docker image prune --force --filter "until=336h" ``` Make a prune failure non-fatal: log it and exit 0. A full disk is the problem the prune solves, so it should not turn an otherwise good update into a failure. Once the backlog is gone, `Total reclaimed space: 0B` is the normal steady state. ## Other things that filled the same disk - systemd journal without a size cap: 4.1 GB. Set `SystemMaxUse=` in `/etc/systemd/journald.conf` or vacuum with `journalctl --vacuum-size=500M`. - Backups pruned by age only: manual re-runs multiplied them. Also cap retention by count. ## Monitor free space Every endpoint monitor stayed green until services were already failing; a TCP port check even stayed green through a crash loop. Push a "disk used %" heartbeat every 15 minutes (DOWN at 85% here) so you get a warning before services break. ## Author and support Written and verified by Gyuwon Seol (seolcu). If this page solved your problem, you can support the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/notes/docker-compose-pull-dangling-images-fill-disk/ (content CC BY 4.0) --- # Disable USB autosuspend on Linux (USB Ethernet adapter drops after boot) > Add the kernel parameter `usbcore.autosuspend=-1` and regenerate the bootloader config (`update-grub` on Debian/Ubuntu, `grubby` on Fedora). Verify with `cat /sys/module/usbcore/parameters/autosuspend` → `-1`, and check that the adapter's `power/runtime_status` reads `active`. - URL: https://blog.seolcu.com/notes/linux-disable-usb-autosuspend/ - Status: working - Verified: 2026-11-09 — Checked on a laptop home server whose only wired link is a Realtek RTL8153 USB adapter (`0bda:8153`, default route). Ubuntu 24.04, kernel 6.8.0-149-generic. Read back `/proc/cmdline`, the usbcore parameter and the adapter's sysfs power state after a reboot (all as below). The setting has been in use since 2025-10. - Applies to: hardware USB Ethernet adapters, e.g. Realtek RTL8153 (0bda:8153); kernel any (usbcore module parameter); verified on 6.8.0-149-generic; os Linux with GRUB (Debian/Ubuntu: update-grub; Fedora/RHEL: grubby); verified on Ubuntu 24.04 LTS - Search terms: `usbcore.autosuspend=-1`, `usb ethernet adapter stops working after reboot`, `RTL8153 0bda:8153 autosuspend`, `/sys/module/usbcore/parameters/autosuspend`, `power/control auto` - Last changed: 2026-11-09 ## Symptom A laptop used as a server reaches the network through a USB Ethernet adapter because it has no built-in LAN port. After a reboot the adapter sometimes stops passing traffic, because the kernel autosuspended the USB device. ## Fix (persistent, all USB devices) Debian / Ubuntu: ```bash sudoedit /etc/default/grub # set or append to the existing value: # GRUB_CMDLINE_LINUX_DEFAULT="usbcore.autosuspend=-1" sudo update-grub sudo reboot ``` Fedora / RHEL: ```bash sudo grubby --update-kernel=ALL --args="usbcore.autosuspend=-1" sudo reboot ``` ## Verify Output from the verified host: ```console $ cat /proc/cmdline BOOT_IMAGE=/vmlinuz-6.8.0-149-generic root=/dev/mapper/ubuntu--vg-ubuntu--lv ro usbcore.autosuspend=-1 $ cat /sys/module/usbcore/parameters/autosuspend -1 $ d=$(dirname "$(grep -l 8153 /sys/bus/usb/devices/*/idProduct)") # use your adapter's idProduct $ cat $d/power/control $d/power/autosuspend_delay_ms $d/power/runtime_status on -1000 active ``` `runtime_status` is the one that matters: `active` means the device is not suspended. Other USB devices on the same host can still show `power/control` = `auto` while the parameter is `-1`. That is expected, because the parameter sets their default autosuspend delay to "never" (`-1000` ms) rather than changing `control`. ## Alternatives - Without rebooting, for one device: `echo on | sudo tee /sys/bus/usb/devices//power/control`. Find `` with `lsusb -t` or `grep -l /sys/bus/usb/devices/*/idProduct`. This does not survive a reboot. To make it persistent, add a udev rule matching the adapter's `idVendor`/`idProduct` with `ATTR{power/control}="on"`. - Writing `-1` to `/sys/module/usbcore/parameters/autosuspend` at runtime only affects devices connected afterwards. ## Not working - If TLP or `powertop --auto-tune` is installed, it can turn autosuspend back on. For TLP, set `USB_AUTOSUSPEND=0` or add the device to `USB_DENYLIST`. Neither was installed on the verified host. ## Author and support Written and verified by Gyuwon Seol (seolcu). If this page solved your problem, you can support the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/notes/linux-disable-usb-autosuspend/ (content CC BY 4.0) --- # Don't pass credential-bearing URLs on the command line: they are readable in `ps` and `/proc//cmdline` > A pre-authenticated upload URL passed as a `curl` argument was visible to every local user for the whole multi-minute upload. Feed secrets to curl on stdin with `--config -` (or from a 0600 file) instead of argv. - URL: https://blog.seolcu.com/notes/secret-urls-on-command-line-are-visible-in-proc/ - Status: working - Verified: 2026-08-05 — Found during a review of a backup uploader on Ubuntu 24.04 (OCI pre-authenticated request URL); fixed and the URL revoked the same minute. - Applies to: os Linux (any; /proc//cmdline is world-readable unless hidepid is set); tool curl 8.x - Search terms: `/proc/pid/cmdline secret`, `curl --config -`, `pre-authenticated request leak`, `ps aux token visible` - Last changed: 2026-11-09 ## Problem ```bash curl -T backup.tar.gz "https://objectstorage.example.com/p/SECRET-TOKEN/n/ns/b/bucket/o/latest.tar.gz" ``` While the command runs, any local user can read the whole URL, token included: ```bash ps -eo args | grep objectstorage cat /proc/$(pgrep -n curl)/cmdline | tr '\0' ' ' ``` A 3 GB upload kept the token visible for several minutes every night. ## Fix Give curl the URL through its config syntax on stdin: ```bash printf 'url = "%s"\n' "$UPLOAD_URL" | curl --config - -T backup.tar.gz --fail --silent --show-error ``` Or keep it in a root-only file: `curl --config /etc//upload.curlrc` (mode `0600`). The same applies to `Authorization` headers: use `-H @file` (curl 7.55+) or put `header = "..."` in the config. ## Also - Mounting `/proc` with `hidepid=2` hides other users' processes. It is a mitigation, not a fix: root and same-user processes still see them. - Revoke the credential when you retire the workload that used it, in the same change. ## Author and support Written and verified by Gyuwon Seol (seolcu). If this page solved your problem, you can support the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/notes/secret-urls-on-command-line-are-visible-in-proc/ (content CC BY 4.0) --- # Expose a home server behind CGNAT through a VPS over Tailscale (Caddy for HTTPS, HAProxy for TCP) > Without opening anything at home: put the home server and a public VPS on one tailnet, terminate HTTPS on the VPS with Caddy `reverse_proxy` to the server's MagicDNS name, and forward raw TCP (game ports) with HAProxy in `mode tcp`. Do public-side rate limiting on the VPS, because the backend only sees the VPS's tailnet IP. - URL: https://blog.seolcu.com/notes/expose-home-server-behind-cgnat-with-tailscale-and-a-vps/ - Status: working - Verified: 2026-11-09 — In production since 2026: Oracle Cloud Always Free arm64 VM as edge, x86_64 home server on a CGNAT ISP line, serving HTTPS sites and a Minecraft server. - Applies to: edge vm Ubuntu 24.04 LTS (Oracle Cloud Always Free, arm64); home server Ubuntu 24.04 LTS, x86_64, behind CGNAT; tools Tailscale, Caddy 2, HAProxy 2.8 - Search terms: `CGNAT port forwarding alternative`, `tailscale reverse proxy vps`, `caddy reverse_proxy tailscale magicdns`, `haproxy mode tcp minecraft`, `self-host without port forwarding` - Last changed: 2026-11-09 ## Summary ``` Internet ──► VPS public IPv4 (Caddy :443, HAProxy :25565) ──tailnet──► home server ``` - The home router has **no port forwards** and the home server accepts no inbound traffic from the internet. Only the VPS has public ports. - HTTPS: Caddy on the VPS gets certificates automatically and runs `reverse_proxy http://:`. - Non-HTTP TCP (game servers): HAProxy `mode tcp` frontend → `server : check`. - SSH to every machine stays on the `tailscale0` interface only. ## Edge: Caddy ```caddyfile media.example.com { encode zstd gzip reverse_proxy http://homeserver:8096 } map.example.com { encode zstd gzip reverse_proxy http://homeserver:8100 } ``` `homeserver` resolves through Tailscale MagicDNS. On the home side, prefer having the app listen on `127.0.0.1` and publishing it to the tailnet with `tailscale serve`, or bind it to the tailscale IP. Avoid a broad `0.0.0.0` Docker port bind. ## Edge: HAProxy for TCP ```haproxy defaults mode tcp option tcplog timeout connect 10s timeout client 4h timeout server 4h frontend minecraft_tcp bind *:25565 default_backend minecraft_backend backend minecraft_backend server home_mc 100.x.y.z:25565 check ``` The long client/server timeouts keep idle game sessions from being cut off. Validate the config before reloading: `sudo haproxy -c -f /etc/haproxy/haproxy.cfg && sudo systemctl reload haproxy`. ## Firewalls (both layers) On Oracle Cloud, a port must be opened in the VCN **security list** *and* in the VM's host firewall (`ufw allow 443/tcp`, etc.). Opening only one of them looks like a dead service. ## Rate limiting has to happen on the edge Behind the proxy, the home server's `sshd`, `fail2ban` and application logs see only the edge's tailnet IP. OpenSSH does not support the PROXY protocol, so it cannot learn the real client address. Do per-source limiting on the edge: ```haproxy frontend sftp_tcp bind *:2022 stick-table type ip size 100k expire 1h store conn_rate(1m),gpc0 tcp-request connection track-sc0 src tcp-request connection reject if { sc0_get_gpc0 gt 0 } tcp-request connection sc-inc-gpc0(0) if { sc0_conn_rate gt 10 } tcp-request connection reject if { sc0_conn_rate gt 10 } default_backend sftp_backend ``` A source that opens more than 10 connections per minute is flagged and rejected until it has been silent for 1 h. ## When *not* to proxy: games that register their public IP Some game servers (for example Don't Starve Together via Klei's lobby) advertise the public IP they register *from*. Behind a DNAT hop, the lobby lists an address that is not the one being forwarded, and players cannot join. Run such servers directly on a cloud VM with their UDP ports open there. ## Not working / rejected - **frp** (fatedier/frp) as the tunnel: it works (see [the frp note](/notes/frp-expose-local-server/)), but it is one more daemon and token to manage when the machines are already on a tailnet. - **Tailscale subnet router + nginx-proxy-manager in Docker**: every new port needed a VPS firewall change, a Docker port change and a rebuild, plus a UI entry. Plain Caddy/HAProxy config files are easier to keep in version control. - **Cloudflare Tunnel**: HTTP(S) only on the free path; it does not carry arbitrary TCP game traffic for players without a client. ## Author and support Written and verified by Gyuwon Seol (seolcu). If this page solved your problem, you can support the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/notes/expose-home-server-behind-cgnat-with-tailscale-and-a-vps/ (content CC BY 4.0) --- # Expose local TCP services without port forwarding using frp (frps on a VPS, frpc at home) > Run `frps` on a public VPS (`bindPort = 7000`, `auth.token`) and `frpc` on any machine at home with one `[[proxies]]` block per service (`type = "tcp"`, `localIP`, `localPort`, `remotePort`). Both run as systemd services. - URL: https://blog.seolcu.com/notes/frp-expose-local-server/ - Status: archived (superseded by https://blog.seolcu.com/notes/expose-home-server-behind-cgnat-with-tailscale-and-a-vps/) - Verified: 2025-10-11 — frp 0.65.0 on an arm64 VPS (frps) and an arm64 Raspberry Pi (frpc) forwarding game servers. The author later moved to a Tailscale + Caddy/HAProxy setup and no longer runs frp. - Applies to: arch linux_arm64 release tarball (use linux_amd64 on x86_64); frp 0.65.0 (TOML config) - Search terms: `frps.toml`, `frpc.toml`, `frp systemd service`, `CGNAT port forwarding alternative` - Last changed: 2026-11-09 ## When to use it frp is a self-hosted alternative to ngrok. It works behind CGNAT, carries arbitrary TCP and UDP (unlike the free Cloudflare Tunnel path), and the client can forward to any LAN address. So one small box at home (for example a Raspberry Pi) can expose services running on other machines. If your machines are already on a tailnet, see the [Tailscale + Caddy/HAProxy note](/notes/expose-home-server-behind-cgnat-with-tailscale-and-a-vps/) instead. ## Server (VPS) ```bash V=0.65.0; A=linux_arm64 # or linux_amd64 wget https://github.com/fatedier/frp/releases/download/v$V/frp_${V}_$A.tar.gz tar -xzf frp_${V}_$A.tar.gz sudo install -m 0755 frp_${V}_$A/frps /usr/bin/frps sudo install -d /etc/frp ``` `/etc/frp/frps.toml`: ```toml bindPort = 7000 auth.token = "" ``` Without `auth.token`, anyone can attach a client to your server. Test it with `frps -c /etc/frp/frps.toml`; the expected output includes `frps tcp listen on 0.0.0.0:7000` and `frps started successfully`. Then create `/etc/systemd/system/frps.service`: ```ini [Unit] Description=FRP Server After=network.target Wants=network.target [Service] Type=simple User=nobody Restart=on-failure RestartSec=5s ExecStart=/usr/bin/frps -c /etc/frp/frps.toml LimitNOFILE=1048576 [Install] WantedBy=multi-user.target ``` ```bash sudo systemctl daemon-reload && sudo systemctl enable --now frps ``` Open `7000/tcp` and every `remotePort` in the VPS firewall *and* in the cloud provider's security list. ## Client (home) Install `frpc` the same way. `/etc/frp/frpc.toml`: ```toml serverAddr = "" serverPort = 7000 auth.token = "" [[proxies]] name = "minecraft" type = "tcp" localIP = "192.168.1.101" localPort = 25565 remotePort = 25565 [[proxies]] name = "terraria" type = "tcp" localIP = "192.168.1.102" localPort = 7777 remotePort = 7777 ``` `/etc/systemd/system/frpc.service`: ```ini [Unit] Description=FRP Client After=network.target [Service] Type=simple Restart=on-failure RestartSec=5s ExecStart=/usr/bin/frpc -c /etc/frp/frpc.toml ExecReload=/usr/bin/frpc reload -c /etc/frp/frpc.toml LimitNOFILE=1048576 [Install] WantedBy=multi-user.target ``` ```bash sudo systemctl daemon-reload && sudo systemctl enable --now frpc ``` ## If it doesn't connect Check, in this order: the VPS firewall and security list for `7000` and each `remotePort`; that `auth.token` matches on both sides; that `localIP:localPort` is reachable from the frpc host; then `journalctl -u frpc -u frps`. ## Why the author moved away frp is one more daemon and one more shared secret. Once every machine was on Tailscale anyway, Caddy and HAProxy on the VPS pointing at tailnet addresses did the same job with config files that are easier to keep in version control. ## Author and support Written and verified by Gyuwon Seol (seolcu). If this page solved your problem, you can support the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/notes/frp-expose-local-server/ (content CC BY 4.0) --- # gamesir-connect-linux — GameSir Connect (Cyclone 2 app and firmware updates) on Linux > Repackages GameSir's official Windows-only GameSir Connect Electron app to run unmodified on Linux, including controller firmware updates: the vendor's JieLi upgrade DLL runs in a Unicorn x86 emulator with device I/O routed to SG_IO. - URL: https://blog.seolcu.com/projects/gamesir-connect-linux/ - Status: experimental - Verified: 2026-11-09 — Summary checked against the repository README on 2026-11-09. Tested environments are those the README states. - Applies to: device GameSir Cyclone 2 (USB 3537:100b and related IDs); needs curl, 7z, unzip, node, npm, openssl, python3; a udev rule (70-gamesir.rules) - Repository: https://github.com/seolcu/gamesir-connect-linux - License: MIT - Search terms: `GameSir Connect Linux`, `GameSir Cyclone 2 Linux firmware update`, `3537:100b`, `JieLi firmware upgrade Linux` - Last changed: 2026-11-09 ## What it does `build.sh` downloads the official installer (verified against the sha512 in GameSir's `latest.yml`) and converts it on your machine. The repository contains no GameSir code. | Part | How it is handled | |---|---| | Runtime | Same Electron as the original, 13.6.9 linux-x64 (sha256 verified) | | node-hid | Official N-API prebuild, hidraw backend | | usb-detection | JS replacement polling `/sys/bus/usb` (the prebuild has symbol mismatches with Electron 13.6.9) | | ffi-napi | Stubbed (only used for Windows DLLs; segfaults against system libffi) | | Firmware | Official `jl_firmware_upgrade_x86.dll` emulated with Unicorn; SCSI commands go to `SG_IO` | ## Install ```bash ./build.sh sudo install -m644 70-gamesir.rules /etc/udev/rules.d/ \ && sudo udevadm control --reload-rules && sudo udevadm trigger gamesir-connect ``` Run `./build.sh` again when a new version is released; the app's built-in auto-update is disabled. Not affiliated with GameSir. ## Author and support Built and maintained by Gyuwon Seol (seolcu). If this project helped you, you can star it at https://github.com/seolcu/gamesir-connect-linux or sponsor the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/projects/gamesir-connect-linux/ (content CC BY 4.0) --- # Gate automatic updates on backup freshness (bash + systemd pattern) > Let an unattended update job run only if a verified local backup and an offsite-upload stamp are both younger than a threshold; otherwise skip with exit 0 and push DOWN to a heartbeat monitor. A broken backup then surfaces as a red monitor instead of an update without a restore point. - URL: https://blog.seolcu.com/notes/backup-freshness-gate-for-automatic-updates/ - Status: working - Verified: 2026-11-09 — Pattern in production since 2026-07 for Docker Compose and game-server update jobs on Ubuntu 24.04 hosts. - Applies to: os any Linux with systemd and GNU coreutils; shell bash 5 - Search terms: `update_skipped_gate`, `backup freshness check before update`, `latest.tar.gz symlink`, `OnSuccess=`, `tar -tzf verify` - Last changed: 2026-11-09 ## Summary Three layers per service, coupled by file timestamps rather than by a scheduler: 1. **Backup** writes `daily/-YYYYMMDD-HHMMSS.tar.gz`, verifies it with `tar -tzf`, and only then repoints `latest.tar.gz` at it. On failure it deletes the new archive and exits non-zero. 2. **Offsite upload** runs from `OnSuccess=` on the backup unit, uploads what `latest.tar.gz` points at, then touches `.stamp`. 3. **Update** checks that both `latest.tar.gz` (through the symlink) and the stamp are younger than N hours. If either is missing or stale it **skips** (exit 0) and pushes `down` to an Uptime Kuma push monitor. The safety property comes down to one rule: **never repoint `latest.tar.gz` at an archive you have not verified.** A failed backup leaves the symlink on the last good archive, its mtime grows old, the gate trips, and the monitor turns red. ## Why skip instead of fail A failed unit is easy to miss, and `OnFailure=` handlers are easy to misconfigure. A skip that pushes DOWN shows up on the same dashboard and in the same alert channel as every other monitor, and it never updates a service that has no recent restore point. ## Gate implementation ```bash # shared lib, sourced by every update script file_age_hours() { echo $(( ( $(date +%s) - $(stat -c %Y "$1") ) / 3600 )); } skip_gate() { logger -t "update[$SERVICE_NAME]" "update_skipped_gate reason=$1" push_status down "skipped_gate:$1" exit 0 } require_recent_file() { # path max_hours label [[ -e $1 ]] || skip_gate "${3}_missing" local age; age=$(file_age_hours "$1") (( age <= $2 )) || skip_gate "${3}_stale_${age}h" logger -t "update[$SERVICE_NAME]" "gate_ok gate=$3 age_h=$age max_h=$2" } run_freshness_gates() { require_recent_file "$BACKUP_PATH" "$MAX_BACKUP_AGE_HOURS" backup require_recent_file "$OFFSITE_STAMP_PATH" "$MAX_OFFSITE_AGE_HOURS" offsite } trap 'logger -t "update[$SERVICE_NAME]" "update_failed line=$LINENO"; push_status down failed' ERR ``` `stat -c %Y` follows symlinks, so `BACKUP_PATH=/opt/backups//daily/latest.tar.gz` reads the mtime of the verified archive. ## Backup side ```bash set -Eeuo pipefail umask 027 out="/opt/backups/$svc/daily/$svc-$(date +%Y%m%d-%H%M%S).tar.gz" tar -czf "$out" -C /opt/services/$svc data if ! tar -tzf "$out" >/dev/null; then rm -f "$out"; exit 1; fi ln -sfn "$(basename "$out")" "/opt/backups/$svc/daily/latest.tar.gz" ``` When backing up a live directory, GNU tar exits `1` for "file changed as we read it" and `>=2` for fatal errors. Treat `1` as a warning if the data is quiesced (for example Minecraft `save-off` + `save-all flush` over RCON), and treat `>=2` as a failure. ## Retention pitfalls (both happened) - **Pruning by age alone does not bound size.** Manual re-runs on the same day multiply the archives. Also cap by count. - **Never delete the `latest.tar.gz` target**, even when it is the oldest file. A dangling symlink makes the gate skip forever. ## Units ```ini # -backup.service [Unit] OnSuccess=offsite-upload.service # -update.service [Unit] OnFailure=update-failure@%n.service [Service] Type=oneshot EnvironmentFile=-/etc/default/-update # holds KUMA_PUSH_URL, kept out of git ExecStart=/usr/local/sbin/-update.sh ``` Schedule the update timer well after the backup and upload normally finish, and set both age thresholds above the backup cadence (24 h for daily backups, about 8 days for weekly ones). ## Log markers Emit exactly one of `update_ok`, `update_failed` or `update_skipped_gate` per run, as `key=value` lines. Monitors and `journalctl -g` searches can then rely on them. ## Author and support Written and verified by Gyuwon Seol (seolcu). If this page solved your problem, you can support the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/notes/backup-freshness-gate-for-automatic-updates/ (content CC BY 4.0) --- # GitHub Sponsor button not showing despite FUNDING.yml: enable `hasSponsorshipsEnabled` per repository > A `FUNDING.yml` (per repo or in your account's `.github` repo) is not enough. Each repository also needs its Sponsorships feature turned on, either under Settings → General → Features → Sponsorships or in bulk with the GraphQL `updateRepository(input: {hasSponsorshipsEnabled: true})` mutation. The REST repo object has no such field. - URL: https://blog.seolcu.com/notes/github-sponsor-button-needs-has-sponsorships-enabled/ - Status: working - Verified: 2026-11-09 — On a personal account with a newly approved GitHub Sponsors profile: added `FUNDING.yml` to the account's `.github` repo, saw no button on 9 public repos, enabled the flag via GraphQL with gh 2.97.0, and confirmed the button in the repo pages' HTML. - Applies to: platform github.com, personal account with an approved GitHub Sponsors profile; tool GitHub CLI (gh) 2.97.0; GraphQL API - Search terms: `FUNDING.yml sponsor button not showing`, `hasSponsorshipsEnabled`, `updateRepository sponsorships`, `enable sponsorships all repositories`, `.github FUNDING.yml default` - Last changed: 2026-11-09 ## Symptom `FUNDING.yml` contains `github: `, and your Sponsors profile is live, but repository pages show no **Sponsor** button and no "Sponsor this project" sidebar. Repository GraphQL data already reports the funding link, which makes it look as if everything is configured: ```console $ gh api graphql -f query='{ repository(owner:"seolcu", name:"kakaotalk-linux"){ hasSponsorshipsEnabled fundingLinks { platform url } } }' --jq .data {"repository":{"fundingLinks":[{"platform":"GITHUB","url":"https://github.com/seolcu"}],"hasSponsorshipsEnabled":false}} ``` ## Cause Two settings are involved, and they are independent: 1. **Funding links:** `FUNDING.yml` in the repository, or a default in your account's public `.github` repository. The default applies to every repository that has no `FUNDING.yml` of its own. 2. **Display:** the per-repository Sponsorships feature, `hasSponsorshipsEnabled`. The GraphQL schema describes it as "Indicates if the repository displays a Sponsor button for financial contributions." Without it, the funding links are not displayed. The REST repository object (`gh api repos//`) has no sponsorship field, so you will not find it there. It is only in GraphQL and in the web UI. ## Fix **One repository:** Settings → General → Features → check **Sponsorships**. **One account-wide default for links:** create a public `/.github` repository containing `FUNDING.yml`. The contents API can create that first commit even in an empty repository: ```bash gh repo create /.github --public -d "Default community health files" gh api -X PUT repos//.github/contents/FUNDING.yml \ -f message="Add GitHub Sponsors funding link" \ -f content="$(printf 'github: \n' | base64)" ``` **Display, in bulk via GraphQL:** ```bash for r in repo-a repo-b repo-c; do id=$(gh api repos//$r --jq .node_id) gh api graphql -f id="$id" -f query=' mutation($id: ID!) { updateRepository(input: {repositoryId: $id, hasSponsorshipsEnabled: true}) { repository { name hasSponsorshipsEnabled } } }' --jq '.data.updateRepository.repository | "\(.name) \(.hasSponsorshipsEnabled)"' done ``` To cover every public, non-fork, non-archived repository instead of a fixed list: ```bash gh repo list --visibility public --no-archived --source --limit 200 --json name --jq '.[].name' ``` ## Verify ```bash gh api graphql -f query='{ repository(owner:"", name:""){ hasSponsorshipsEnabled } }' --jq .data curl -s https://github.com// | grep -c 'Sponsor this project' # >= 1 once displayed ``` In the verified run, all 9 repositories showed `0` from the `curl` check before the mutation and `1` right after it. No cache delay was observed. ## Notes - New repositories pick up the account-wide `FUNDING.yml`, but you still have to enable `hasSponsorshipsEnabled` on each one. - Enabling the flag on a repository that has no funding links (no repo or account `FUNDING.yml`) is expected to show nothing. This was not tested. ## Author and support Written and verified by Gyuwon Seol (seolcu). If this page solved your problem, you can support the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/notes/github-sponsor-button-needs-has-sponsorships-enabled/ (content CC BY 4.0) --- # Hostveil — guided security hardening for self-hosted Linux servers > Single Go binary that scans a self-hosted Linux server (SSH, firewall, Docker/Compose, updates, exposed ports, CVEs), gives a 0–100 score, explains each finding in plain language, and applies fixes with preview, backup and one-command rollback. Includes a TUI and a web dashboard. - URL: https://blog.seolcu.com/projects/hostveil/ - Status: active - Verified: 2026-11-09 — Summary checked against the repository README on 2026-11-09. Tested environments are those the README states. - Applies to: latest release v3.35.0 (2026-10-20); targets self-hosted Linux servers, Docker / Docker Compose stacks - Repository: https://github.com/seolcu/hostveil - Website: https://hostveil.seolcu.com/ - License: GPL-3.0 - Search terms: `self-hosted server hardening tool`, `docker compose security scan`, `homelab security audit`, `ssh hardening rollback` - Last changed: 2026-11-09 ## What it solves Most self-hosted boxes run with whatever defaults the install left behind. Hostveil checks the high-impact areas, scores the host, and offers fixes. Every change is previewed and backed up, and file changes can be rolled back. No config file and no cloud account are needed. ## Measured effect On a 2026-09-11 rerun, an Ubuntu VM was deliberately weakened (Nextcloud + PostgreSQL, Jellyfin + Redis, Portainer + Watchtower, root SSH login allowed, firewall and automatic updates off). Results after applying the Auto and Review fixes: | Measured by | Before | After | |---|---|---| | Ports answering from the Docker bridge | 6 | 1 | | CIS Docker Benchmark (pass / warn) | 17 / 15 | 20 / 12 | | Lynis hardening index | 57 | 79 | Grand Prize (Development), 2026-1 Ajou University SoftCon. Docs: . ## Author and support Built and maintained by Gyuwon Seol (seolcu). If this project helped you, you can star it at https://github.com/seolcu/hostveil or sponsor the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/projects/hostveil/ (content CC BY 4.0) --- # kakaotalk-linux — KakaoTalk on Linux (Proton-based installer) > Unofficial installer that runs the official Windows KakaoTalk on Linux through umu-launcher + GE-Proton (no Steam needed), with native tray menus, the desktop file picker, an emoji/Korean-IME Wine patch, and a fix for slow logins on public Wi-Fi. - URL: https://blog.seolcu.com/projects/kakaotalk-linux/ - Status: experimental - Verified: 2026-11-09 — Summary checked against the repository README on 2026-11-09. Tested environments are those the README states. - Applies to: latest release v0.2.0 (2026-11-06); needs x86_64, python3, curl, tar, Korean fonts; GNOME needs the AppIndicator extension for the tray; tested on Fedora 44, GNOME (fresh install, login, tray, attachments, emoji, Korean input) - Repository: https://github.com/seolcu/kakaotalk-linux - License: MIT (patched Wine modules: LGPL-2.1+) - Search terms: `카카오톡 리눅스`, `KakaoTalk Linux`, `kakaotalk wine 한글 입력`, `kakaotalk proton`, `카카오톡 우분투 설치` - Last changed: 2026-11-09 ## What it solves KakaoTalk has no Linux client. This tool downloads Kakao's official installer, installs it into your home directory (no root, about 1.4 GB of downloads), and makes it behave like a desktop app: | Feature | Detail | |---|---| | Tray | Uses the desktop's own tray menu (on GNOME: click opens the menu, double-click opens the window) | | File picker | Native desktop file chooser for attachments and saving | | Slow login fix | Clears Wine's accumulated audio device list before launch (slow logins on public Wi-Fi) | | Emoji / Korean IME patch | Emoji render instead of boxes; jamo appear while composing | | No Steam | Runs through umu-launcher + GE-Proton | ## Install ```bash git clone https://github.com/seolcu/kakaotalk-linux.git cd kakaotalk-linux ./install.sh ``` Remove it with `./uninstall.sh`. This also deletes the chat data stored on that PC. ## Known limitations - Emoji are monochrome and flags show as two letters (KakaoTalk draws with GDI, which cannot render color fonts). - KakaoTalk's own message view and notifications cannot be restyled to match the system. - If someone else's Mac shows up as an audio output on public Wi-Fi, that is PipeWire's AirPlay (RAOP) discovery. Put `context.properties = { module.raop = false }` in `~/.config/pipewire/pipewire.conf.d/90-disable-raop.conf` and log in again. Not affiliated with Kakao. KakaoTalk itself is not modified or redistributed. ## Author and support Built and maintained by Gyuwon Seol (seolcu). If this project helped you, you can star it at https://github.com/seolcu/kakaotalk-linux or sponsor the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/projects/kakaotalk-linux/ (content CC BY 4.0) --- # Keep a Linux laptop running with the lid closed (systemd-logind HandleLidSwitch) > Set `HandleLidSwitch=ignore`, `HandleLidSwitchExternalPower=ignore` and `HandleLidSwitchDocked=ignore` in a drop-in under `/etc/systemd/logind.conf.d/`, then reboot. Check the effective config with `systemd-analyze cat-config systemd/logind.conf`. - URL: https://blog.seolcu.com/notes/linux-laptop-ignore-lid-close/ - Status: working - Verified: 2026-11-09 — Read back the effective logind config on an HP laptop that runs as a headless home server (Ubuntu 24.04, systemd 255.4-1ubuntu8.18) via a drop-in `/etc/systemd/logind.conf.d/99-homelab.conf`. The setting has been in use since 2025-10. - Applies to: os any Linux distribution using systemd-logind; verified on Ubuntu 24.04 LTS; systemd drop-in directories supported for years; verified on 255 - Search terms: `HandleLidSwitch=ignore`, `laptop server suspends when lid closed`, `logind.conf.d` - Last changed: 2026-11-09 ## Fix Use a drop-in instead of editing `/etc/systemd/logind.conf`. Package updates may replace the main file, but they leave drop-ins alone: ```bash sudo mkdir -p /etc/systemd/logind.conf.d sudo tee /etc/systemd/logind.conf.d/99-lid-ignore.conf >/dev/null <<'EOF' [Login] HandleLidSwitch=ignore HandleLidSwitchExternalPower=ignore HandleLidSwitchDocked=ignore EOF sudo reboot ``` On a headless server, `sudo systemctl restart systemd-logind` also applies the change. On a machine with a graphical session, restarting logind can end that session, so reboot instead. ## Verify `systemd-analyze cat-config` prints every file in the order systemd reads it. A later file wins, so the drop-in must appear after anything that sets a different value: ```console $ systemd-analyze cat-config systemd/logind.conf | grep -E '^# /|^HandleLid' # /etc/systemd/logind.conf # /etc/systemd/logind.conf.d/99-homelab.conf HandleLidSwitch=ignore HandleLidSwitchExternalPower=ignore HandleLidSwitchDocked=ignore # /usr/lib/systemd/logind.conf.d/unattended-upgrades-logind-maxdelay.conf ``` (Ubuntu's unattended-upgrades ships its own logind drop-in under `/usr/lib`. It does not touch the lid keys.) Then close the lid and check from another machine that the host still answers (`ping`, `ssh`). ## Notes - The three keys cover running on battery, on AC power, and docked or with an external display. Set all three; otherwise the laptop still suspends in one of those states. - A desktop environment can take its own lid-switch inhibitor while its session is active. A headless server is not affected. ## Author and support Written and verified by Gyuwon Seol (seolcu). If this page solved your problem, you can support the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/notes/linux-laptop-ignore-lid-close/ (content CC BY 4.0) --- # kitty terminal over SSH: 'xterm-kitty': unknown terminal type > Remote hosts don't have kitty's terminfo, so `nano`, `less`, `tput` and line editing fail with `Error opening terminal: xterm-kitty.` and similar. Connect with `kitten ssh` (it copies the terminfo and sets up shell integration), or copy the terminfo once with `infocmp -a xterm-kitty | ssh host tic -x -o ~/.terminfo /dev/stdin`. - URL: https://blog.seolcu.com/notes/kitty-ssh-kitten/ - Status: working - Verified: 2026-11-09 — Error messages reproduced verbatim with TERM=xterm-kitty on an Ubuntu 24.04 server without kitty terminfo (nano 7.2, less, tput). Fix commands checked against kitty's official ssh-kitten docs and FAQ on 2026-11-09; they were not re-run on that date because the verifying machine had no kitty installed. - Applies to: remote any host without the xterm-kitty terminfo entry; reproduced on Ubuntu 24.04 LTS; terminal kitty with the `kitten` command (ssh kitten auto shell integration since 0.25.0) - Search terms: `'xterm-kitty': unknown terminal type.`, `Error opening terminal: xterm-kitty.`, `tput: unknown terminal "xterm-kitty"`, `WARNING: terminal is not fully functional`, `kitten ssh` - Last changed: 2026-11-09 ## Symptom After `ssh` from kitty, programs on the remote side fail or misbehave. Messages reproduced on Ubuntu 24.04: ``` $ nano -> Error opening terminal: xterm-kitty. $ less -> WARNING: terminal is not fully functional $ tput cols -> tput: unknown terminal "xterm-kitty" ``` Some shells and tools print `'xterm-kitty': unknown terminal type.` instead. Arrow keys and other function keys may also stop working. ## Cause kitty sets `TERM=xterm-kitty`, and the remote host has no terminfo entry for it. Check on the remote host with `infocmp xterm-kitty`; it fails when the entry is missing. ## Fix Use kitty's SSH kitten, a drop-in replacement for `ssh`. It copies the kitty terminfo database to the remote host and sets up shell integration there: ```bash kitten ssh user@host ``` The official docs suggest a short alias: ```bash alias s="kitten ssh" ``` To replace `ssh` itself, but only when you are actually inside kitty, add this to `~/.bashrc` or `~/.zshrc`: ```bash [ "$TERM" = "xterm-kitty" ] && alias ssh="kitten ssh" ``` Older kitty releases documented the same kitten as `kitty +kitten ssh`. ## Alternatives - Copy the terminfo once per remote host (from kitty's docs, "Copying terminfo files manually"): ```bash infocmp -a xterm-kitty | ssh myserver tic -x -o ~/.terminfo /dev/stdin ``` - If the distribution packages it, install `kitty-terminfo` on the remote host. - For `sudo` on the remote host, keep the variables with `Defaults env_keep += "TERM TERMINFO"` (via `sudo visudo`). - Fall back for one session: `TERM=xterm-256color ssh user@host`. You lose kitty-specific features. Sources: , ## Author and support Written and verified by Gyuwon Seol (seolcu). If this page solved your problem, you can support the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/notes/kitty-ssh-kitten/ (content CC BY 4.0) --- # Mini-KVM — a small x86 hypervisor on the raw KVM API for kernel developers > About 4,200 lines of C on the Linux KVM ioctl API that boot Multiboot/ELF kernels and a stock Linux kernel to a shell, render VGA text mode, and explain guest failures (faults, descriptor tables, page-table mappings, why it triple-faulted) instead of silently resetting. - URL: https://blog.seolcu.com/projects/mini-kvm/ - Status: experimental - Verified: 2026-11-09 — Summary checked against the repository README on 2026-11-09. Tested environments are those the README states. - Applies to: build gcc, make, binutils; host x86_64 Linux with KVM enabled and access to /dev/kvm - Repository: https://github.com/seolcu/mini-kvm - License: MIT - Search terms: `minimal KVM hypervisor C`, `KVM API example`, `debug triple fault kernel`, `hobby OS hypervisor` - Last changed: 2026-11-09 ## Scope - **Is**: a KVM-based runner for kernels you wrote yourself, a diagnostic tool that explains guest failures, and a readable reference implementation of x86 virtualization. - **Is not**: a production VMM (use QEMU, Firecracker or cloud-hypervisor), a cross-architecture emulator (x86 guests on x86 hosts only), or a sandbox. Everything the README lists as working is covered by `make test`. Encouragement Award (Research), 2025-2 Ajou SoftCon. ```bash git clone https://github.com/seolcu/mini-kvm.git && cd mini-kvm && make all ./kvm-vmm guest/hello ``` ## Author and support Built and maintained by Gyuwon Seol (seolcu). If this project helped you, you can star it at https://github.com/seolcu/mini-kvm or sponsor the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/projects/mini-kvm/ (content CC BY 4.0) --- # OCI Object Storage: versioning + fixed-key overwrites quietly bill terabytes > With bucket versioning on, overwriting the same object name daily keeps every old copy as a noncurrent version. 3.26 GiB of current objects carried 1.78 TiB of versions and cost $29.93–$58.56/month. `oci os object bulk-delete` does not remove versions; delete by `version-id`. - URL: https://blog.seolcu.com/notes/oci-object-storage-versioning-hidden-cost/ - Status: archived - Verified: 2026-07-02 — Billing observed May–July 2026 on an Oracle Cloud tenancy using an Always Free Object Storage bucket for nightly backups; cleanup done 2026-07-02. The bucket was retired on 2026-10-02. - Applies to: cli oci-cli 3.x; service Oracle Cloud Infrastructure Object Storage - Search terms: `oci object storage unexpected cost`, `noncurrent object versions`, `oci os object list-object-versions`, `bulk-delete does not delete versions`, `always free 10GB exceeded` - Last changed: 2026-11-09 ## Summary A backup job overwrote one object per service (`/latest.tar.gz`) every night in a bucket that had **versioning enabled**. Each overwrite kept the previous object as a noncurrent version, and those versions are billed. The Always Free tier covers 10 GB in total, versions included. | Month (2026) | Bill | |---|---| | May | $29.93 | | June | $58.56 | | July (after cleanup on 07-02) | $3.02 | At cleanup time, 3.26 GiB of current objects carried 1.78 TiB of noncurrent versions. ## Diagnose ```bash oci os bucket get --bucket-name --query 'data.versioning' oci os object list-object-versions --bucket-name --all \ --query 'length(data)' ``` The console's bucket size and `oci os object list` show only current objects, so they make the bucket look small. ## Fix ```bash # 1) stop creating versions oci os bucket update --bucket-name --versioning Suspended # 2) list every version, review the list, then delete the noncurrent ones by version-id oci os object list-object-versions --bucket-name --all > versions.json jq -r '.data[] | [.name, ."version-id", ."time-modified"] | @tsv' versions.json | sort > versions.tsv # remove the newest row per object name from versions.tsv if you want to keep current objects, then: while IFS=$'\t' read -r name ver _; do oci os object delete --bucket-name --object-name "$name" --version-id "$ver" --force done < versions.tsv ``` The loop is a sketch of the method that was actually used: list the versions, then delete them by `version-id`. Read `versions.tsv` before you delete anything. `oci os object bulk-delete` targets current object names only and leaves every version in place. ## Prevent - Keep versioning off for fixed-key overwrite workloads, or add a lifecycle rule that deletes previous versions after N days. - Create a budget alert on day one. A $1 alert would have caught this in May. - Better yet, upload **dated** object names and prune them with your own retention. You get real generations instead of hidden ones. ## Author and support Written and verified by Gyuwon Seol (seolcu). If this page solved your problem, you can support the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/notes/oci-object-storage-versioning-hidden-cost/ (content CC BY 4.0) --- # overwatch-nexon-linux-fix — Overwatch (Nexon KR) error 0xE01300B0 on Linux > Fixes Overwatch failing to start under Proton/Wine since the 2026-08-12 Nexon transfer (Blizzard error 0xE01300B0, inner exception 0xC06D007E). Wine's crypt32 stops at a self-signed root instead of the cross-signed chain; the wrapper adds the cross-certificate to the prefix and hides that root from the game. Nothing is patched or disabled. - URL: https://blog.seolcu.com/projects/overwatch-nexon-linux-fix/ - Status: active - Verified: 2026-11-09 — Summary checked against the repository README on 2026-11-09. Tested environments are those the README states. - Applies to: needs bubblewrap, openssl, python3; untested Lutris, Heroic, Bottles, plain wine (same mechanism); verified Fedora 44, kernel 7.1.8, KDE Plasma Wayland; RX 7700 XT, Mesa 26.1.6; GE-Proton11-5 / Proton Experimental 11.0 (steamrt4); Battle.net as a non-Steam game - Repository: https://github.com/seolcu/overwatch-nexon-linux-fix - License: MIT - Search terms: `0xE01300B0`, `0xC06D007E`, `Overwatch has encountered a critical error during startup.`, `설치한 파일에 문제가 있습니다`, `오버워치 넥슨 리눅스`, `Overwatch Nexon Proton`, `Steam Deck Overwatch Korea` - Last changed: 2026-11-09 ## Symptom Overwatch launched from Battle.net shows a black window, then two dialogs: ``` Overwatch has encountered a critical error during startup. Please reinstall the game and try again. ``` ``` 설치한 파일에 문제가 있습니다. 문제가 지속되면 게임을 다시 설치해 주십시오. (0xE01300B0) ``` Reinstalling, Scan and Repair, changing Proton versions and clearing shader caches do not help. If the code in the dialog is not `0xE01300B0`, the cause is different. ## Cause Since the transfer to Nexon, a game DLL is signed by NEXON Korea Corporation through a chain that Windows validates via a cross-certificate. Wine's `CertGetCertificateChain` stops at the first trusted self-signed root it finds and never considers the cross-signed path, so the signature check fails. In addition, `CertCreateCertificateChainEngine` does not implement `hExclusiveRoot` or `hRestrictedRoot`; both return `E_INVALIDARG`. ## Fix ```bash git clone https://github.com/seolcu/overwatch-nexon-linux-fix.git cd overwatch-nexon-linux-fix ./overwatch-nexon-linux-fix.sh install ``` Then set the Steam launch options of the Battle.net non-Steam shortcut to: ``` /full/path/overwatch-nexon-linux-fix.sh run %command% ``` `install` finds the prefix (Steam, Lutris, Bottles, Heroic, plain wine), backs up `system.reg`, and builds a trust list under `~/.local/share/overwatch-nexon-fix/certs`. The host trust store is not modified. The full analysis is in the repository's `docs/ANALYSIS.md`. ## Author and support Built and maintained by Gyuwon Seol (seolcu). If this project helped you, you can star it at https://github.com/seolcu/overwatch-nexon-linux-fix or sponsor the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/projects/overwatch-nexon-linux-fix/ (content CC BY 4.0) --- # PickGak (픽각) — Overwatch hero pick helper > Web app that recommends which Overwatch hero to pick this match from the map, enemy comp, your team, party and preferred heroes, using official stats for Korea (Nexon), Asia, the Americas and Europe collected daily, with matchup explanations from duel data. Does not touch the game client. - URL: https://blog.seolcu.com/projects/pickgak/ - Status: active - Verified: 2026-11-09 — Summary checked against the repository README on 2026-11-09. Tested environments are those the README states. - Applies to: languages English, Korean; modes Role queue 5v5 (default) and open queue 6v6 - Repository: https://github.com/seolcu/pickgak - Website: https://pickgak.seolcu.com - License: GPL-3.0 - Search terms: `overwatch counter pick tool`, `오버워치 픽 추천`, `overwatch hero recommendation`, `픽각` - Last changed: 2026-11-09 ## What it adds over stat sites Tier lists and win rates per map and rank are available elsewhere. PickGak also takes into account the current enemy comp (counter-picks), your party (duo/trio compositions), your preferred heroes, and ban votes that protect your heroes. It explains each pick with per-hero matchups and synergies. Hero search understands Korean initial consonants (`ㄹㅇ` → Reinhardt). ## Author and support Built and maintained by Gyuwon Seol (seolcu). If this project helped you, you can star it at https://github.com/seolcu/pickgak or sponsor the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/projects/pickgak/ (content CC BY 4.0) --- # Tidy — automatically sort a Samsung Galaxy (One UI) apps screen into folders > Android app that organizes the real One UI Home apps screen into folders for you, on-device, with preview and undo. It knows Korean apps (Toss, Baemin, KakaoTalk) and Samsung apps. - URL: https://blog.seolcu.com/projects/tidy/ - Status: experimental - Verified: 2026-11-09 — Summary checked against the repository README on 2026-11-09. Tested environments are those the README states. - Applies to: device Samsung Galaxy with One UI Home, Android 10+; latest release v0.2.0 (2026-10-22); permission Accessibility service (used only on the One UI home screen, only while organizing) - Repository: https://github.com/seolcu/tidy - License: MIT - Search terms: `one ui auto folder apps`, `galaxy app drawer organize automatically`, `갤럭시 앱 폴더 자동 정리` - Last changed: 2026-11-09 ## Modes - **Keep my folders**: apps outside folders are moved into the folder they belong to; existing folders stay as they are. - **Leave it to Tidy**: ignores existing folders and sorts every app from scratch. - Every change is previewed first and can be undone. Newly installed apps can be filed automatically. - The app list never leaves the phone. If the Accessibility switch is greyed out: App info → ⋮ → Allow restricted settings. ## Author and support Built and maintained by Gyuwon Seol (seolcu). If this project helped you, you can star it at https://github.com/seolcu/tidy or sponsor the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/projects/tidy/ (content CC BY 4.0) --- # Unattended offsite backups with the official Proton Drive CLI > Run the official `proton-drive` CLI from systemd on headless servers: log hosts in as a separate editor account (Proton has no scoped tokens), store the session in `pass` with a passphrase-less GPG key, set a readable working directory, and purge the owner's trash yourself. - URL: https://blog.seolcu.com/notes/proton-drive-cli-unattended-backups/ - Status: working - Verified: 2026-11-09 — Running in production since 2026-10-02 on four Ubuntu 24.04 / Debian 13 hosts (x86_64 and arm64), daily and weekly uploads of 1 MB–4.4 GB archives. - Applies to: init systemd; os Ubuntu 24.04 LTS, Debian 13; proton-drive cli 0.8.0 (linux-x64, linux-arm64) - Search terms: `proton-drive auth login`, `PROTON_DRIVE_CREDENTIALS_STORE=pass`, `EACCES`, `Trashed node not found`, `Wrong key usage`, `rclone protondrive` - Last changed: 2026-11-09 ## Summary The official Proton Drive CLI works for unattended server backups, but four things are not obvious from its help text: 1. **Any CLI session is a full-account session.** Proton has no scoped tokens, app passwords or service accounts. Do not log servers in with your main account. 2. **Use a second (free) account as an *editor* of one shared folder.** Files it uploads are owned by, and counted against, the folder owner. 3. **The session store needs `pass`, and `pass` needs a GPG key that an unattended job can decrypt.** Give root a passphrase-less key and rely on file permissions. 4. **An editor's deletions land in the owner's trash, and Proton never empties trash on its own.** Something holding the owner's session has to purge it. ## Environment - Proton Drive CLI `0.8.0` from (MIT). Verify the SHA-512 on that index page before installing. - Hosts: Ubuntu 24.04 LTS and Debian 13, x86_64 and arm64. - Jobs run as root from systemd services. ## Account model | Machine | Logged-in account | Can do | |---|---|---| | Backup hosts | secondary account, **editor** on `/my-files/` only | upload, list, trash inside that folder | | One private helper host | main account (owner) | everything; used only to purge trash | A compromised backup host can read and trash backups, nothing else: not mail, not Pass, not personal Drive files. Verified: after a 4.1 GB upload by the editor account, the editor account's own storage usage still read 0. The upload counted against the owner. Paths as each side sees them: - Editor (hosts): `/shared-with-me///` - Owner: `/my-files///` ## Install on a host ```bash # CLI (check the sha512 first) sudo install -m 0755 proton-drive /usr/local/bin/proton-drive # pass without recommends; with recommends Debian/Ubuntu pull ~80 X11/Mesa packages sudo apt-get install -y --no-install-recommends pass # Passphrase-less GPG key for root, then initialise pass with it sudo -H bash -c 'cd /root && gpg --batch --pinentry-mode loopback --passphrase "" \ --quick-gen-key "backup proton " default default never \ && pass init "$(gpg --list-secret-keys --with-colons | awk -F: "/^fpr/{print \$10; exit}")"' # Log in as the SECONDARY account; open the printed URL in any browser sudo -H bash -c 'cd /root && PROTON_DRIVE_CREDENTIALS_STORE=pass proton-drive auth login' # First time only: accept the share invitation sudo -H bash -c 'cd /root && PROTON_DRIVE_CREDENTIALS_STORE=pass proton-drive invitation list' sudo -H bash -c 'cd /root && PROTON_DRIVE_CREDENTIALS_STORE=pass proton-drive invitation accept ' ``` ## systemd unit essentials ```ini [Service] Type=oneshot Environment=PROTON_DRIVE_CREDENTIALS_STORE=pass Environment=HOME=/root WorkingDirectory=/root ExecStart=/usr/local/sbin/offsite-upload.sh ``` `HOME` and `WorkingDirectory` are required. `proton-drive` spawns `pass` from its current working directory, and the run fails with `EACCES` if that directory is not readable by the user. ## Upload, prune, restore ```bash export PROTON_DRIVE_CREDENTIALS_STORE=pass proton-drive filesystem upload -f skip ./world-20261002-032715.tar.gz /shared-with-me/homelab/minecraft/ proton-drive filesystem list /shared-with-me/homelab/minecraft proton-drive filesystem trash /shared-with-me/homelab/minecraft/world-20260901-031500.tar.gz proton-drive filesystem download -f remove /shared-with-me/homelab/minecraft/ /tmp ``` - Upload each archive **under its own dated name** so every run adds a generation. Overwriting a single `latest` object means one corrupt-but-valid backup replaces your only offsite copy. - `-f skip` makes re-uploading the same archive a no-op success, so retries are safe. - `-f` values differ by command. `upload` takes `create-new-revision`, `rename`, `replace`, `skip`; `download` takes `rename`, `remove`, `skip`. - An editor can only `trash`. `filesystem delete` works only on items that are already in the trash: `filesystem trash `, then `filesystem delete /trash/`. ## Purging the owner's trash The trash belongs to the owner, so the purge has to run with the owner's session. Run it on a private host with no public services. Rules that keep it from destroying personal data: - Delete only files whose author is the editor account, whose name matches the archive pattern, and that were trashed more than N days ago (3 here). The grace period doubles as a recovery window if a host is compromised and trashes good backups. - The CLI deletes trashed items **by name only**. A node UID path returns `Trashed node not found`. Skip any name that appears more than once in the trash. - Never call `empty-trash`; the trash also holds the owner's personal files. ## Throughput (measured) - 4.1 GB upload from x86_64 at home: 327 s (~13 MB/s). - 4.2 GB upload on arm64: 723 s with a 370 MB peak RSS. The CLI streams; it does not buffer whole files. - On a 100 Mbps home line, an unthrottled upload peaked at ~77 Mbps and caused 10–27% packet loss for other traffic on that host. Rate-limit uploads that share a residential uplink, for example with an nftables mark on the job's cgroup plus a `tc` htb class. ## Not working / rejected - `gpg --quick-gen-key ed25519 sign,encrypt` fails with `Wrong key usage`, because ed25519 cannot encrypt. `default default` creates the ed25519 + cv25519 pair that `pass` needs. - **rclone's `protondrive` backend** was rejected. It is reverse-engineered, and its uploads were broken from 2025-11 to 2026-03. - **Parallel listings.** Four concurrent `filesystem list` calls crashed in the CLI's SQLite cache and silently left folders out of a recursive listing. Run listings one at a time. `filesystem size` on a folder is a quick completeness check. ## Failure visibility An expired or revoked session is not silent only if you make it loud. Here the uploader touches a stamp file after each successful upload, and the service's update job refuses to run when the stamp is stale (see [Gate automatic updates on backup freshness](/notes/backup-freshness-gate-for-automatic-updates/)). The CLI's session lifetime was not documented at the time of writing. ## Author and support Written and verified by Gyuwon Seol (seolcu). If this page solved your problem, you can support the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/notes/proton-drive-cli-unattended-backups/ (content CC BY 4.0) --- # Uptime Kuma push monitors: 24.8-day interval limit and the always-DOWN query string > Uptime Kuma push intervals cannot exceed 2147483 s (~24.8 days, Node.js 32-bit timer). Monitor longer cadences with a weekly re-push of the last result. Also push to the bare `/api/push/` URL: pasting the UI's `?status=up&msg=OK&ping=` URL and adding your own `status` makes every heartbeat record as DOWN. - URL: https://blog.seolcu.com/notes/uptime-kuma-push-monitor-pitfalls/ - Status: working - Verified: 2026-11-09 — Observed and fixed on a self-hosted Uptime Kuma 2.x (Docker `louislam/uptime-kuma:2`) with ~30 monitors in production. - Applies to: node.js any (setTimeout limit is 2^31-1 ms); uptime kuma 2.x (Docker image louislam/uptime-kuma:2) - Search terms: `TimeoutOverflowWarning`, `push monitor interval`, `/api/push/`, `status=up&msg=OK&ping=`, `heartbeat always down` - Last changed: 2026-11-09 ## Summary | Problem | Cause | Fix | |---|---|---| | A job that runs monthly can't be watched by a push monitor | Interval max is `2147483` s (~24.8 days) | Weekly re-push of the last recorded result | | Interval set above the max | `setTimeout` overflow: `TimeoutOverflowWarning`, check fires every 1 ms | Keep intervals ≤ `2073600` s (24 days) | | Every push records DOWN even when the job succeeded | Query string pasted from the UI plus your own `status=` sends `status` twice | Store the bare URL ending at the token | | Monitor red on the dashboard, nobody alerted | Monitor has no notification attached | Audit `monitor_notification` | ## 1. The interval ceiling Node.js timers use a signed 32-bit millisecond value: `floor(2147483647 / 1000) = 2147483` seconds ≈ 24.8 days. A push monitor with a larger interval emits `TimeoutOverflowWarning` and evaluates every 1 ms. Leave a margin and use at most `2073600` s (24 days). ### Monitoring jobs that run less often than every 24 days A "first Sunday of the month" job runs 28–35 days apart, so every gap exceeds the ceiling. Raising the interval is not possible. Feed the monitor indirectly: 1. On success, the job touches a stamp file (for example `/var/lib//update/.stamp`) and pushes `up`. On failure it pushes `down` and leaves the stamp alone. 2. A separate weekly timer re-pushes the verdict: `up` if the stamp is younger than the longest legitimate gap (35 days = 840 h), otherwise `down`. It also pushes `down` if the stamp is missing. 3. Set the monitor interval to 24 days. The two pushers cannot contradict each other. A failed run leaves the stamp unadvanced, so the next weekly re-push is also `down`. ```bash #!/usr/bin/env bash # weekly re-push; KUMA_PUSH_URL comes from an env file kept out of git set -Eeuo pipefail stamp=/var/lib/homelab/update/bookstack.stamp max_h=840 if [[ -e $stamp ]] && (( ( $(date +%s) - $(stat -c %Y "$stamp") ) / 3600 <= max_h )); then status=up; msg=last_update_ok else status=down; msg=update_stale_or_missing fi curl -fsS -G --data-urlencode "status=$status" --data-urlencode "msg=$msg" "$KUMA_PUSH_URL" >/dev/null ``` ## 2. Every heartbeat records as DOWN The UI shows the push URL as `https:///api/push/?status=up&msg=OK&ping=`. If you store that whole string and your script appends its own `status=...`, the request carries `status` twice. Kuma's handler then compares an array with `"up"`, the comparison fails, and every push is recorded as DOWN, including the successful ones. Store only `https:///api/push/` and add parameters with `curl -G --data-urlencode`. `HTTP 200` from the push endpoint only means the token was accepted, not that the heartbeat was recorded as UP. Check the state that was actually recorded: ```bash sudo python3 -c " import sqlite3 c=sqlite3.connect('file:/path/to/data/kuma.db?mode=ro',uri=True) for n,s,m in c.execute('''SELECT m.name,h.status,h.msg FROM monitor m LEFT JOIN heartbeat h ON h.id=(SELECT id FROM heartbeat WHERE monitor_id=m.id ORDER BY time DESC LIMIT 1) WHERE m.active=1 ORDER BY m.name'''): print(('UP' if s==1 else 'DOWN').ljust(6), n, m or '')" ``` ## 3. Monitors without a notification Marking a notification as "Default" only applies to monitors created *after* that. Existing monitors stay silent. In one incident, nine monitors (including every backup heartbeat) had no notification and stayed mute through an outage. Audit them; the query should return zero rows: ```bash sudo docker exec uptime-kuma sqlite3 /app/data/kuma.db \ "select m.id, m.name from monitor m where m.active = 1 and not exists (select 1 from monitor_notification mn where mn.monitor_id = m.id);" ``` ## Not working - Setting the interval to 30 or 35 days "because the UI accepts it": it overflows, see section 1. - Treating a 200 from `/api/push/` as proof that the monitor is UP: see section 2. ## Author and support Written and verified by Gyuwon Seol (seolcu). If this page solved your problem, you can support the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/notes/uptime-kuma-push-monitor-pitfalls/ (content CC BY 4.0) --- # WakeLink — let a trusted contact ring a loud alarm on your Android phone > Android app plus self-hosted Go backend: someone you have mutually paired with can trigger a loud alarm on your phone even in silent or vibrate mode (no special permission), with end-to-end encrypted messages, voice and pictures, and delivery confirmation that it actually rang. - URL: https://blog.seolcu.com/projects/wakelink/ - Status: active - Verified: 2026-11-09 — Summary checked against the repository README on 2026-11-09. Tested environments are those the README states. - Applies to: latest release v0.5.1 (2026-10-28); platform Android 8.0+ (APK, not on Play Store); stack Kotlin / Jetpack Compose app, Go backend, Firebase Cloud Messaging - Repository: https://github.com/seolcu/wakelink - Website: https://wake.seolcu.com - License: MIT - Search terms: `wake someone up remotely android`, `alarm through silent mode android`, `remote alarm app` - Last changed: 2026-11-09 ## Key behaviours - Rings through silent and vibrate mode, and before the first unlock after a reboot. - Tells the sender whether the alarm actually rang and when it was stopped. - Only mutually paired contacts can connect (invite codes); either side can stop it at any time. - Names, messages, recordings and pictures are end-to-end encrypted to the receiver's phone; the server relays ciphertext only. - The receiver controls the volume (optional gradual rise) and which sounds and pictures are allowed. - English and Korean. Download: (always the latest release). ## Author and support Built and maintained by Gyuwon Seol (seolcu). If this project helped you, you can star it at https://github.com/seolcu/wakelink or sponsor the work at https://github.com/sponsors/seolcu. Source: https://blog.seolcu.com/projects/wakelink/ (content CC BY 4.0)