hardware/ubuntu-wsl/services/pi-web.md

pi-web (WSL) โ€” autostart, port 30142

A second, independent Pi Web instance that runs inside the WSL Ubuntu distro and uses the WSL install of @agegr/pi-web and the WSL pi session data (/root/.pi). It is separate from the Windows-native instance on port 30141, which is documented in ../../windows/pi-web-autostart.md.

Set up: 2026-09-20 ยท Host: Nuk ยท Distro: Ubuntu ยท Domain: pi-wsl.dph.am

Windows instance WSL instance (this doc)
Runtime Windows node (nvm-windows) WSL node (nvm, /root/.nvm)
Binary C:\Program Files\nodejs\pi-web.cmd /root/.nvm/.../bin/pi-web (via wrapper)
Port 30141 30142
Host allowlist pi-win.dph.am pi-wsl.dph.am
pi data Windows ~/.pi /root/.pi
Autostart Task Scheduler \pi-web systemd pi-web.service + Task Scheduler \pi-web-wsl
Doc ../../windows/pi-web-autostart.md this file

1. What happens at logon

Because WSL does not start on its own, autostart is two layers:

  1. Windows Task Scheduler fires pi-web-wsl 45 seconds after NUK\domin logs on.
  2. That task runs a hidden PowerShell process (autostart.ps1), which boots the Ubuntu distro (wsl -d Ubuntu -u root --exec /bin/true).
  3. Booting the distro starts systemd, which starts the enabled pi-web.service. The wrapper also runs systemctl start pi-web.service explicitly, as a no-op when it is already up.
  4. The wrapper polls http://127.0.0.1:30142/ for up to 60 s and logs the result.

Result: shortly after logon, Pi Web (WSL) answers on 0.0.0.0:30142 inside the distro, on 127.0.0.1:30142 on Windows, and โ€” via the Windows portproxy โ€” on http://192.168.1.153:30142/. The browser is never opened.


2. WSL side

Files

Path Purpose
/etc/systemd/system/pi-web.service The unit. Configuration (env vars, port) lives here.
/usr/local/bin/pi-web-wsl Wrapper that resolves the newest nvm Node and execs its pi-web, so nvm install / nvm alias default do not break the service.

Unit (pi-web.service)

[Unit]
Description=pi-web (WSL) - Pi coding agent web UI
After=network.target

[Service]
Type=simple
WorkingDirectory=/root
Environment=PI_WEB_ALLOWED_HOSTS=pi-wsl.dph.am
Environment=PI_WEB_NO_OPEN=1
Environment=PI_WEB_HOSTNAME=0.0.0.0
Environment=PORT=30142
ExecStart=/usr/local/bin/pi-web-wsl -H 0.0.0.0 -p 30142 --no-open
Restart=always
RestartSec=3
TimeoutStopSec=15
KillMode=mixed

[Install]
WantedBy=multi-user.target

pi-web forwards SIGINT/SIGTERM to its Next.js child, so the default KillSignal plus TimeoutStopSec=15 gives a clean shutdown.

Environment variables

Variable Value Meaning
PI_WEB_ALLOWED_HOSTS pi-wsl.dph.am Exact hostnames accepted (the DNS name used to reach it).
PI_WEB_NO_OPEN 1 Never open a browser.
PI_WEB_HOSTNAME / PORT 0.0.0.0 / 30142 Bind defaults (the CLI flags also set these).

Commented-out in the unit:

Variable Effect
PI_WEB_PASSWORD Require a password for browser logins (HTTP Basic Auth, user pi). Recommended โ€” it is bound to 0.0.0.0.
PI_WEB_IDLE_TIMEOUT_MS 0 disables the 10-minute idle shutdown.

Enabling / disabling autostart (inside WSL)

systemctl enable --now pi-web     # enable + start (already done)
systemctl disable pi-web          # stop autostart
systemctl status pi-web
journalctl -u pi-web -f           # live logs (journald)
systemctl restart pi-web          # after editing the unit

3. Windows side

Files

All live in %LOCALAPPDATA%\pi-web-wsl, i.e. C:\Users\domin\AppData\Local\pi-web-wsl.

File Purpose
autostart.ps1 Logon wrapper. Boots the distro, starts the unit, waits for HTTP 200, logs.
install-autostart.ps1 Registers/refreshes the pi-web-wsl scheduled task. Idempotent, no admin needed.
setup-network.ps1 Creates the firewall rule + portproxy (needs an elevated shell). Idempotent.
logs\pi-web-wsl.log Runtime log (appended, UTF-8).

Gotcha (hit during setup): run via WSL interop, $env:USERDOMAIN is WORKGROUP, which does not resolve to a SID and makes Register-ScheduledTask fail with "No mapping between account names and security IDs was done." The installer uses [Security.Principal.WindowsIdentity]::GetCurrent().Name (โ†’ NUK\domin) instead.

Scheduled task pi-web-wsl

Field Value
Trigger At logon of NUK\domin, delay PT45S
Action C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe
Arguments -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "C:\Users\domin\AppData\Local\pi-web-wsl\autostart.ps1"
Principal NUK\domin, logon type Interactive, run level Limited (no elevation)
Execution time limit PT0S (unlimited)
Multiple instances IgnoreNew
Start when available Enabled
Restart on failure 3 attempts, 1 minute apart

install-autostart.ps1 is the source of truth โ€” edit and re-run rather than hand-tweaking the task.


4. Networking

Changed 2026-09-21: the whole box switched from NAT to mirrored networking (C:\Users\domin\.wslconfig -> networkingMode=mirrored). The NAT + portproxy setup described below (kept for history) is retired for pi-web -- the stale portproxy rule was deleted because it now conflicts (EADDRINUSE) with WSL binding 0.0.0.0:30142 directly on the shared interface. Current behavior:

Reachable from Address How
WSL itself http://127.0.0.1:30142/ and http://192.168.1.153:30142/ direct listener on the shared interface
Windows http://localhost:30142/ and http://192.168.1.153:30142/ same interface, mirrored -- no relay needed
LAN http://192.168.1.153:30142/ ordinary Windows Firewall allow rule (Hyper-V firewall also permits it -- the pre-existing pi-web-wsl (TCP 30142) rule shows up in Get-NetFirewallHyperVRule too, confirmed 2026-09-21)
Public https://pi-wsl.dph.am/ nginx reverse proxy -> 192.168.1.153:30142 (unchanged)

No netsh portproxy rule exists or is needed for 30142 anymore. If one ever reappears (e.g. from re-running the old setup-network.ps1), delete it -- it will fight with WSL's own bind and crash-loop the service:

netsh interface portproxy delete v4tov4 listenport=30142 listenaddress=0.0.0.0

The New-NetFirewallRule from setup-network.ps1 (below) is still valid/needed under mirrored mode -- only the portproxy half of that script is now obsolete for this service.

Windows firewall (still needed; portproxy below is historical/NAT-only)

New-NetFirewallRule -DisplayName "pi-web-wsl (TCP 30142)" `
  -Direction Inbound -Action Allow -Protocol TCP -LocalPort 30142 -Profile Private

<details> <summary>Historical: NAT-mode portproxy (retired 2026-09-21, do not re-add)</summary>

netsh interface portproxy add v4tov4 `
  listenaddress=0.0.0.0 listenport=30142 connectaddress=127.0.0.1 connectport=30142

The portproxy pointed at loopback, not the WSL IP, because WSL's IP changed on every reboot under NAT (192.168.119.217 that day, something else the next). Loopback was stable: WSL's localhost forwarding took it from there. This was the reason NAT + portproxy was originally chosen over mirrored networking for this service -- superseded once the whole box moved to mirrored mode, which gives WSL a stable, shared LAN address instead.

</details>

Reverse proxy / DNS (handled manually)

pi-wsl.dph.am is reverse-proxied directly to 192.168.1.153:30142 โ€” the Windows box โ€” rather than being a router port-forward of 30142. So the public entry point is the existing *.dph.am nginx ingress, not a new router rule.

  • DNS: the *.dph.am wildcard already points at the home public IP; no per-subdomain record is needed.
  • nginx: add a server_name pi-wsl.dph.am site with proxy_pass http://192.168.1.153:30142; (a LAN-IP upstream, unlike the Mac-local backends in ../../../services/dph-am-stack.md). Standard certbot --nginx gives it TLS, and the auth_request login wall can be layered on as usual.
  • pi-win.dph.am (โ†’ 192.168.1.153:30141) is the working precedent for this shape; it also carries a PWA manifest/icon, listed in ../../mac-studio/services/nginx-pwa-injection.md. Add a pi-wsl/ entry there if this one gets the same treatment.
  • No router port-forward for 30142 is needed. Only the existing 80/443 ingress.
  • The Windows portproxy + firewall rule are still required: nginx reaches the Windows host over the LAN at 192.168.1.153:30142, which is exactly what those expose.
  • nginx forwards the external hostname, so PI_WEB_ALLOWED_HOSTS=pi-wsl.dph.am is what lets the request through โ€” keep it in sync with the nginx server_name.

5. Common operations

# Windows task
Get-ScheduledTask -TaskName pi-web-wsl | Select-Object TaskName, State
Get-ScheduledTaskInfo -TaskName pi-web-wsl | Select-Object LastRunTime, LastTaskResult
Start-ScheduledTask -TaskName pi-web-wsl
Get-Content "$env:LOCALAPPDATA\pi-web-wsl\logs\pi-web-wsl.log" -Encoding UTF8 -Wait
Disable-ScheduledTask -TaskName pi-web-wsl
Unregister-ScheduledTask -TaskName pi-web-wsl -Confirm:$false
# WSL service
systemctl status pi-web
journalctl -u pi-web -f
systemctl restart pi-web
# What is listening on 30142 on the Windows side? (relay + portproxy)
Get-NetTCPConnection -LocalPort 30142 -State Listen |
    Select-Object LocalAddress, LocalPort, OwningProcess

6. Troubleshooting

Symptom Cause / fix
Port 30142 dead after logon Windows task disabled/missed, or distro not booted. Check the task's LastTaskResult and logs\pi-web-wsl.log. Start it manually with Start-ScheduledTask -TaskName pi-web-wsl.
Works on Windows, not from LAN Firewall rule or portproxy missing. Check Get-NetFirewallRule -DisplayName 'pi-web-wsl (TCP 30142)' and netsh interface portproxy show all, then re-run setup-network.ps1 elevated.
Invalid host header in the browser The name you used is not in PI_WEB_ALLOWED_HOSTS. Add it in /etc/systemd/system/pi-web.service, then systemctl daemon-reload && systemctl restart pi-web.
EADDRINUSE on 30142 Something else already bound the port. Could be a manual pi-web, but as of 2026-09-21 the more likely cause is a stale NAT-era netsh portproxy rule now conflicting with WSL's direct bind under mirrored networking -- check netsh interface portproxy show all on Windows first, then ss -ltnp | grep 30142 inside WSL.
Service starts at distro boot but not at logon The systemd side is fine; the Windows logon task is the missing piece. wsl -d Ubuntu -u root --exec /bin/true boots the distro by hand to confirm.
pi-web not found / wrong Node /usr/local/bin/pi-web-wsl picks the newest $NVM_DIR/versions/node/*/bin; install a Node there or adjust NVM_DIR in the wrapper.
Windows instance on 30141 also stops responding Unrelated to this service โ€” see ../../windows/pi-web-autostart.md.

pi-wsl.dph.am and pi-win.dph.am must stay on different ports: two servers cannot share 0.0.0.0:30142.


7. Security notes

  • Bound to 0.0.0.0 and reachable from the LAN/WAN through the portproxy โ€” no password is configured, same as the Windows instance. Pi Web drives an agent with filesystem access, so anyone who can reach the port can execute commands. Set PI_WEB_PASSWORD in /etc/systemd/system/pi-web.service and restart the unit.
  • Password auth does not encrypt traffic. For access beyond a trusted network, put it behind HTTPS (reverse proxy) or a VPN.
  • The WSL service runs as root (the distro's only user), so pi-web inherits root's filesystem access and /root/.pi.

8. Verification performed at setup (2026-09-20)

Check Result
pi-web.service active + enabled active, enabled
Listener 0.0.0.0:30142 (next-server)
Host allowlist HTTP 200 with Host: pi-wsl.dph.am via curl
Windows loopback โ†’ WSL 200 from http://127.0.0.1:30142/
Windows LAN IP 200 from http://192.168.1.153:30142/ (and from inside WSL)
Logon task end-to-end pi-web-wsl run manually โ†’ LastTaskResult 0, wrapper logged ready on port 30142
Windows instance untouched 30141 still listening/serving

Not tested: a full Windows reboot (would require terminating the live session). The task and unit are both enabled, so the first real logon is the remaining confirmation.

9. Incident + mirrored-networking migration (2026-09-21)

Symptom: pi-web unreachable at localhost:30142 the morning after working fine the night before.

Root causes (two, found in sequence):

  1. The WSL VM itself had restarted (uptime showed ~2 min), not crashed independently. WSL fully shuts down ~8s after the last session/process closes, taking systemd (and pi-web) down with it. The unit's Restart=always + systemd's own startup correctly brought pi-web back once the VM rebooted, but the box had likely been fully stopped for a while before that, hence "it just stopped working."
  2. Root networking was NAT (not mirrored, despite an assumption otherwise) โ€” switched to mirrored mode (see ยง4) to fix localhost/LAN reachability more durably. This uncovered a stale NAT-era netsh portproxy rule for 30142 that started conflicting with WSL's own direct bind under mirrored mode, crash-looping the service with EADDRINUSE. Deleted the portproxy rule (ยง4); service came up clean afterward.

Verified after the fix: pi-web.service active with no restart loop; curl localhost:30142 โ†’ 200 from both WSL and (per Dom) the Windows browser; eth0 inside WSL shows 192.168.1.153 directly (mirrored mode confirmed active); Get-NetFirewallHyperVRule shows the existing pi-web-wsl (TCP 30142) rule still applies under mirrored mode, so LAN reachability shouldn't require a new rule โ€” not yet independently confirmed from a second LAN device.

Still open: the WSL VM can still fully shut down when no session is open (root cause #1 above isn't fixed, only made less likely to matter since pi-web now restarts more reliably). A Windows Scheduled Task running wsl.exe -d Ubuntu -e sleep infinity at startup was proposed to hold a session open permanently, but has not been registered yet. Do this next time this comes up.