Skip to content

Commit c109326

Browse files
authored
feat: surface Coder access URL in terminals via /tmp/coder-access-url
2 parents 3bb1a5f + caeb1f6 commit c109326

5 files changed

Lines changed: 135 additions & 67 deletions

File tree

README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,9 @@ image + checksum together. The build prints each checksum when it finishes.
115115
The turn-key login + Coder admin bootstrap shared by all image flavours live in
116116
[`nixos/_images/box-turnkey.nix`](nixos/_images/box-turnkey.nix): autologin to the `coderbox`
117117
desktop, and admin `admin@coder.com` / `PleaseChangeMe1234`. Coder comes up at
118-
`http://<hostname>.local:3000` (or the `*.try.coder.app` tunnel URL in
119-
`/etc/motd`). Change these before sharing an image by dropping a gitignored
118+
`http://<hostname>.local:3000` (the `*.try.coder.app` tunnel URL is printed in
119+
any terminal on login and cached at `/tmp/coder-access-url`). Change these
120+
before sharing an image by dropping a gitignored
120121
`hosts/<host>/local.nix` (same shape as `installer/bootstrap/local.nix.example`).
121122

122123
### Appliance ISO (`_appliance-iso`)
@@ -187,9 +188,9 @@ The installer auto-creates the admin user, mints a long-lived API token to
187188
`/etc/coder/session-token`, and deploys the workspace templates on first
188189
boot via `coder-init-admin.service`. After the reboot:
189190

190-
1. Find the box at `http://<your-hostname>.local:3000`, or look up the
191-
`*.try.coder.app` tunnel URL in `/etc/motd` on the box (also tailed to
192-
the console on each SSH login).
191+
1. Find the box at `http://<your-hostname>.local:3000`, or read the
192+
`*.try.coder.app` tunnel URL from the login banner printed in any terminal
193+
or SSH session (cached at `/tmp/coder-access-url`).
193194
2. Log in with the Coder admin email and password set at install time
194195
(defaults: `admin@coder.com` / `PleaseChangeMe1234`).
195196
3. Change the admin password from the user settings page if you used the
@@ -309,6 +310,6 @@ Fully automated, no follow-up steps needed. The service:
309310
310311
- `hosts/<host>/local.nix` is gitignored. Never commit secrets or machine-specific overrides.
311312
- The `coderd/` Terraform state is stored in `/var/lib/coder/template-sync/` on the box, not in the repo.
312-
- `CODER_ACCESS_URL` is intentionally unset; Coder auto-creates a `*.try.coder.app` tunnel on startup. `http://<hostname>.local` (port 80) redirects to the live tunnel URL via `coder-redirect.service`, which also writes the URL to `/etc/motd` so it shows on every console and SSH login.
313+
- `CODER_ACCESS_URL` is intentionally unset; Coder auto-creates a `*.try.coder.app` tunnel on startup. `http://<hostname>.local` (port 80) redirects to the live tunnel URL via `coder-redirect.service`, which also caches the URL at `/tmp/coder-access-url` so a login banner can print it in every terminal and SSH session.
313314
- The `coder` user (uid 991) runs Coder server and rootless Podman. UID is pinned; do not change.
314315
- Workspace pods resolve `<hostname>.local` via a `hostAliases` entry pointing to the LAN IP (set via `services.coder-nixos.lanIp` in the host's `local.nix`).

configuration.nix

Lines changed: 85 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ let
2929
# NixOS won't change an existing user's UID live, so this must stay 991.
3030
coderUid = 991;
3131

32+
# Port the Coder server listens on. Single source of truth: it sets
33+
# CODER_HTTP_ADDRESS below and every in-box URL that targets the server
34+
# (coder-redirect, bootstrap, reset, template sync, reaper, logstream).
35+
coderPort = 3000;
36+
3237
# .terraformrc pointing terraform at the locally-packaged coderd provider.
3338
# No network access needed during `terraform init`.
3439
terraformrc = pkgs.writeText "terraformrc-coderd" ''
@@ -543,7 +548,7 @@ in
543548
wants = [ "user@${toString coderUid}.service" ]; # non-fatal if user session is delayed
544549

545550
environment = {
546-
CODER_HTTP_ADDRESS = "0.0.0.0:3000";
551+
CODER_HTTP_ADDRESS = "0.0.0.0:${toString coderPort}";
547552
CODER_MAX_TOKEN_LIFETIME = "8760h"; # allow year-long tokens (e.g. nixos-sync)
548553
CODER_MAX_ADMIN_TOKEN_LIFETIME = "8760h";
549554
# CODER_ACCESS_URL not set → Coder auto-creates a *.try.coder.app tunnel URL
@@ -555,7 +560,10 @@ in
555560
let
556561
inherit (config.services.coder-nixos) lanIp;
557562
in
558-
if lanIp != "" then "http://${lanIp}:3000" else "http://${config.networking.hostName}.local:3000";
563+
if lanIp != "" then
564+
"http://${lanIp}:${toString coderPort}"
565+
else
566+
"http://${config.networking.hostName}.local:${toString coderPort}";
559567
CODER_PG_CONNECTION_URL = "postgres:///coder?host=/run/postgresql&user=coder&sslmode=disable";
560568
CODER_DATA_DIR = "/var/lib/coder";
561569
# Point the Terraform Docker provider at the rootless Podman socket.
@@ -614,7 +622,7 @@ in
614622
615623
if [ -z "''${INITIAL_USER_EMAIL:-}" ]; then
616624
echo "INITIAL_USER_EMAIL not set, skipping bootstrap."
617-
echo "Complete the first-run wizard at http://$(${pkgs.nettools}/bin/hostname -s).local:3000"
625+
echo "Complete the first-run wizard at http://$(${pkgs.nettools}/bin/hostname -s).local:${toString coderPort}"
618626
exit 0
619627
fi
620628
@@ -624,7 +632,7 @@ in
624632
# means the DB schema and coder role exist.
625633
echo "Waiting for coder API..."
626634
for i in $(seq 1 60); do
627-
if ${pkgs.curl}/bin/curl -sf http://localhost:3000/api/v2/buildinfo > /dev/null 2>&1; then
635+
if ${pkgs.curl}/bin/curl -sf http://localhost:${toString coderPort}/api/v2/buildinfo > /dev/null 2>&1; then
628636
echo "coder API ready after $((i * 2))s."
629637
break
630638
fi
@@ -654,13 +662,13 @@ in
654662
echo "Session token already exists."
655663
else
656664
echo "Logging in as admin to mint a long-lived token..."
657-
SESSION=$(${pkgs.curl}/bin/curl -sf -X POST http://localhost:3000/api/v2/users/login \
665+
SESSION=$(${pkgs.curl}/bin/curl -sf -X POST http://localhost:${toString coderPort}/api/v2/users/login \
658666
-H 'Content-Type: application/json' \
659667
-d "{\"email\":\"$INITIAL_USER_EMAIL\",\"password\":\"$INITIAL_USER_PASSWORD\"}" \
660668
| ${pkgs.jq}/bin/jq -r '.session_token')
661669
[ -n "$SESSION" ] && [ "$SESSION" != "null" ] \
662670
|| { echo "Admin login failed." >&2; exit 1; }
663-
LONG_TOKEN=$(CODER_URL=http://localhost:3000 CODER_SESSION_TOKEN="$SESSION" \
671+
LONG_TOKEN=$(CODER_URL=http://localhost:${toString coderPort} CODER_SESSION_TOKEN="$SESSION" \
664672
${coder}/bin/coder tokens create --name nixos-sync --lifetime 8760h)
665673
[ -n "$LONG_TOKEN" ] \
666674
|| { echo "Token mint failed." >&2; exit 1; }
@@ -702,7 +710,7 @@ in
702710
${pkgs.terraform}/bin/terraform -chdir="$CODERD_DIR" init -no-color 2>&1 \
703711
| ${pkgs.gnused}/bin/sed 's/^/[template-deploy] /'
704712
${pkgs.terraform}/bin/terraform -chdir="$CODERD_DIR" apply -auto-approve -no-color \
705-
-var="coder_url=http://localhost:3000" \
713+
-var="coder_url=http://localhost:${toString coderPort}" \
706714
-var="coder_session_token=$(cat "$token_file")" \
707715
-var="hostname=${config.networking.hostName}" \
708716
-var="version_name=$COMMIT" \
@@ -775,7 +783,7 @@ in
775783
echo "--- starting coder.service"
776784
${pkgs.systemd}/bin/systemctl start coder.service
777785
echo "--- waiting for Coder API..."
778-
until ${pkgs.curl}/bin/curl -sf http://localhost:3000/api/v2/buildinfo > /dev/null 2>&1; do
786+
until ${pkgs.curl}/bin/curl -sf http://localhost:${toString coderPort}/api/v2/buildinfo > /dev/null 2>&1; do
779787
sleep 3
780788
done
781789
@@ -786,11 +794,11 @@ in
786794
# 8. Mint a fresh long-lived session token using the initial user's creds
787795
echo "--- minting session token"
788796
SESSION=$(${pkgs.curl}/bin/curl -sf \
789-
-X POST http://localhost:3000/api/v2/users/login \
797+
-X POST http://localhost:${toString coderPort}/api/v2/users/login \
790798
-H 'Content-Type: application/json' \
791799
-d "{\"email\":\"''${INITIAL_USER_EMAIL}\",\"password\":\"''${INITIAL_USER_PASSWORD}\"}" \
792800
| ${pkgs.jq}/bin/jq -r '.session_token')
793-
LONG_TOKEN=$(CODER_URL=http://localhost:3000 CODER_SESSION_TOKEN="$SESSION" \
801+
LONG_TOKEN=$(CODER_URL=http://localhost:${toString coderPort} CODER_SESSION_TOKEN="$SESSION" \
794802
${coder}/bin/coder tokens create --name nixos-sync --lifetime 8760h)
795803
echo "$LONG_TOKEN" | ${pkgs.coreutils}/bin/tee /etc/coder/session-token > /dev/null
796804
echo "--- session token written"
@@ -839,7 +847,7 @@ in
839847
${pkgs.terraform}/bin/terraform -chdir="$CODERD_DIR" init -no-color 2>&1 \
840848
| ${pkgs.gnused}/bin/sed 's/^/[template-sync] /' || true
841849
${pkgs.terraform}/bin/terraform -chdir="$CODERD_DIR" apply -auto-approve -no-color \
842-
-var="coder_url=http://localhost:3000" \
850+
-var="coder_url=http://localhost:${toString coderPort}" \
843851
-var="coder_session_token=$(cat "$TOKEN_FILE")" \
844852
-var="hostname=${config.networking.hostName}" \
845853
-var="version_name=$COMMIT" \
@@ -885,55 +893,74 @@ in
885893
Restart = "on-failure";
886894
RestartSec = "10s";
887895
ExecStart = pkgs.writeShellScript "coder-redirect" ''
888-
set -euo pipefail
889-
CODER_LOCAL="http://localhost:3000"
890-
891-
# Wait until the Coder API is up
892-
echo "coder-redirect: waiting for Coder API..."
893-
until ${pkgs.curl}/bin/curl -sf "$CODER_LOCAL/api/v2/buildinfo" > /dev/null 2>&1; do
894-
sleep 5
895-
done
896-
897-
# Fetch the tunnel URL (may take a moment to establish after startup)
898-
TUNNEL_URL=""
899-
for i in $(seq 1 20); do
900-
TUNNEL_URL=$(${pkgs.curl}/bin/curl -sf \
901-
-H "Coder-Session-Token: $(cat /etc/coder/session-token)" \
902-
"$CODER_LOCAL/api/v2/deployment/config" \
903-
| ${pkgs.jq}/bin/jq -r '.config.access_url // empty' 2>/dev/null || true)
904-
if echo "$TUNNEL_URL" | grep -q "try.coder.app"; then
905-
echo "coder-redirect: tunnel URL is $TUNNEL_URL"
906-
break
907-
fi
908-
echo "coder-redirect: tunnel not ready yet (attempt $i), retrying in 5s..."
909-
sleep 5
910-
done
911-
912-
if ! echo "$TUNNEL_URL" | grep -q "try.coder.app"; then
913-
echo "coder-redirect: could not detect tunnel URL; will retry in 30s"
914-
sleep 30
915-
exit 1
916-
fi
917-
918-
export CODER_TUNNEL_URL="$TUNNEL_URL"
919-
920-
# Surface the tunnel URL on every console / SSH login.
921-
HOSTNAME="$(${pkgs.nettools}/bin/hostname)"
922-
${pkgs.coreutils}/bin/cat > /etc/motd <<EOF
923-
924-
Coder is running on this box.
925-
926-
Tunnel URL: $TUNNEL_URL
927-
Local: http://$HOSTNAME.local:3000
928-
Redirect: http://$HOSTNAME.local (302 → tunnel)
929-
930-
EOF
931-
932-
exec ${pkgs.python3}/bin/python3 ${redirectPy}
896+
set -euo pipefail
897+
CODER_LOCAL="http://localhost:${toString coderPort}"
898+
899+
# write_accessUrl <text>: publish <text> to the file the login banner
900+
# reads (see environment.interactiveShellInit below). Best-effort so
901+
# a /tmp write can't abort the service under set -e.
902+
write_accessUrl() {
903+
${pkgs.coreutils}/bin/printf '%s\n' "$1" > /tmp/coder-access-url \
904+
&& ${pkgs.coreutils}/bin/chmod 0644 /tmp/coder-access-url || true
905+
}
906+
907+
# Seed with the local URL so a terminal opened before the tunnel is
908+
# up still shows a reachable URL; upgraded to "<tunnel> (<local>)"
909+
# below. This also overwrites any stale value from a previous run.
910+
write_accessUrl "$CODER_LOCAL"
911+
912+
# Wait until the Coder API is up
913+
echo "coder-redirect: waiting for Coder API..."
914+
until ${pkgs.curl}/bin/curl -sf "$CODER_LOCAL/api/v2/buildinfo" > /dev/null 2>&1; do
915+
sleep 5
916+
done
917+
918+
# Fetch the tunnel URL (may take a moment to establish after startup)
919+
TUNNEL_URL=""
920+
for i in $(seq 1 20); do
921+
TUNNEL_URL=$(${pkgs.curl}/bin/curl -sf \
922+
-H "Coder-Session-Token: $(cat /etc/coder/session-token)" \
923+
"$CODER_LOCAL/api/v2/deployment/config" \
924+
| ${pkgs.jq}/bin/jq -r '.config.access_url // empty' 2>/dev/null || true)
925+
if echo "$TUNNEL_URL" | grep -q "try.coder.app"; then
926+
echo "coder-redirect: tunnel URL is $TUNNEL_URL"
927+
break
928+
fi
929+
echo "coder-redirect: tunnel not ready yet (attempt $i), retrying in 5s..."
930+
sleep 5
931+
done
932+
933+
if ! echo "$TUNNEL_URL" | grep -q "try.coder.app"; then
934+
echo "coder-redirect: could not detect tunnel URL; will retry in 30s"
935+
sleep 30
936+
exit 1
937+
fi
938+
939+
export CODER_TUNNEL_URL="$TUNNEL_URL"
940+
941+
# Upgrade to "<access URL> (<local URL>)" now that the tunnel URL is
942+
# known, so terminals show both on login.
943+
write_accessUrl "$TUNNEL_URL ($CODER_LOCAL)"
944+
945+
exec ${pkgs.python3}/bin/python3 ${redirectPy}
933946
'';
934947
};
935948
};
936949

950+
# ── Coder access URL login banner ─────────────────────────────────────────
951+
# coder-redirect seeds /tmp/coder-access-url with the local URL and upgrades
952+
# it to "<access URL> (<local URL>)" once the tunnel is up. Print it on
953+
# interactive shells so both a local terminal and an SSH session show where
954+
# to reach Coder — the old /etc/motd only surfaced on PAM logins, never in a
955+
# desktop terminal. This only reads the file; it never touches the network.
956+
# The exported guard keeps nested shells from reprinting it within a session.
957+
environment.interactiveShellInit = ''
958+
if [ -z "''${CODER_ACCESS_URL_SHOWN:-}" ] && [ -s /tmp/coder-access-url ]; then
959+
export CODER_ACCESS_URL_SHOWN=1
960+
printf '\n Coder is running on this box: %s\n\n' "$(cat /tmp/coder-access-url)"
961+
fi
962+
'';
963+
937964
# ── Workspace reaper ──────────────────────────────────────────────────────────
938965
# Deletes workspaces that have been stopped for >= 72 h.
939966
# time_til_dormant_autodelete_ms is Enterprise-only so we implement this
@@ -947,7 +974,7 @@ in
947974
User = "root";
948975
ExecStart = pkgs.writeShellScript "coder-workspace-reaper" ''
949976
set -euo pipefail
950-
CODER_LOCAL="http://localhost:3000"
977+
CODER_LOCAL="http://localhost:${toString coderPort}"
951978
TOKEN_FILE="/etc/coder/session-token"
952979
DELETE_AFTER_HOURS=72
953980
@@ -1035,7 +1062,7 @@ in
10351062
coder-logstream-kube/coder-logstream-kube \
10361063
--namespace coder-workspaces \
10371064
--create-namespace \
1038-
--set url=http://10.42.0.1:3000 \
1065+
--set url=http://10.42.0.1:${toString coderPort} \
10391066
--set namespaces={coder-workspaces} \
10401067
--atomic --timeout 120s
10411068

hosts/incus-vm/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,11 @@ creates the admin user, mints a long-lived session token to
194194
journalctl -u coder-init-admin -f
195195
```
196196

197-
Once complete, the tunnel URL is in `/etc/motd`:
197+
Once complete, the tunnel URL is printed in any terminal on login and cached
198+
at `/tmp/coder-access-url`:
198199

199200
```sh
200-
cat /etc/motd
201+
cat /tmp/coder-access-url
201202
```
202203

203204
**Fallback (no local.nix credentials):** If `initialUser.email` was left empty,

install.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -817,7 +817,7 @@ echo
817817
echo "Coder web UI after reboot:"
818818
echo " http://${HOSTNAME_ARG}.local (port 80 redirects to the *.try.coder.app tunnel URL)"
819819
echo " http://${HOSTNAME_ARG}.local:3000 (direct LAN access)"
820-
echo " the *.try.coder.app URL itself is written to /etc/motd on first boot once coder.service is up"
820+
echo " the *.try.coder.app URL is printed in any terminal on login (cached at /tmp/coder-access-url) once coder.service is up"
821821
echo
822822
echo "Optional after first login:"
823823
echo " - Update the box: cd /etc/nixos-repo && sudo git pull && sudo nixos-rebuild switch"

pr-body.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
Closes #61.
2+
3+
## What
4+
5+
Surface the live Coder access URL (`*.try.coder.app` tunnel) in **any terminal on login**, not just PAM sessions.
6+
7+
Previously `coder-redirect.service` wrote the URL to `/etc/motd`, which only shows on SSH/console logins via `pam_motd` — a local desktop terminal never saw it.
8+
9+
## How
10+
11+
- `coder-redirect.service` now caches the discovered tunnel URL to `/tmp/coder-access-url` (`chmod 0644`) instead of writing `/etc/motd`. It `rm -f`s the file at the top of `ExecStart`, so the stale URL is cleared on every boot and on every service restart until the live URL is rediscovered. Both the cleanup and the write are best-effort (`|| true`) so a `/tmp` hiccup can't trip `set -euo pipefail` and restart-loop the redirect.
12+
- New `environment.interactiveShellInit` prints an `Access URL` / `Local` / `Redirect` banner by reading that file — no network access. An exported `CODER_ACCESS_URL_SHOWN` guard keeps nested shells from reprinting it within a session. This covers local GNOME terminals, console TTYs, and SSH from one place.
13+
- Docs updated (`README.md`, `hosts/incus-vm/README.md`, `install.sh`) to point at the terminal banner / `/tmp/coder-access-url` instead of `/etc/motd`.
14+
15+
The large `configuration.nix` diff is mostly `nixfmt` renormalizing the `coder-redirect` script: removing the heredoc left the block uniformly indented, so the formatter re-based it to the canonical column.
16+
17+
## Verification
18+
19+
Ran in a workspace with the repo's own toolchain (`nix fmt` = treefmt: nixfmt/statix/deadnix/shfmt/shellcheck):
20+
21+
- `nix fmt` idempotent (0 changed)
22+
- `shellcheck install.sh` clean
23+
- `nixosConfigurations._appliance-iso` evaluates; built `coder-redirect` script and rendered `interactiveShellInit` inspected — escaping and `|| true` guards land correctly
24+
- Ran the banner snippet: prints once, guard suppresses the repeat
25+
26+
Not build/boot-tested on real hardware (needs a box).
27+
28+
<details>
29+
<summary>Implementation notes / decisions</summary>
30+
31+
- **Dropped `/etc/motd` entirely** rather than keeping it alongside the banner. Keeping both would double-print on SSH/console (pam_motd + shell banner); consolidating on the shell banner gives one source of truth that also works in desktop terminals. Console TTY and SSH still get the URL because their login shell sources the init.
32+
- **`interactiveShellInit` vs `loginShellInit`**: GNOME Terminal opens a non-login interactive shell by default, so `loginShellInit`/`profile.d` wouldn't fire there. `interactiveShellInit` covers it; the exported guard prevents subshell spam.
33+
- **Hostname** read at display time from `/proc/sys/kernel/hostname` (always present) to match the previous runtime `hostname` behavior rather than baking `networking.hostName` at eval time.
34+
- **`/tmp` wipe**: relying on tmpfs-on-boot isn't guaranteed here, so the explicit `rm -f` in `ExecStart` (which runs on every boot and restart) is what satisfies "wipes on boot or restart of coder-redirect".
35+
36+
</details>
37+
38+
---
39+
🤖 Opened by Coder Agents on behalf of @phorcys420.

0 commit comments

Comments
 (0)