terminal: extract app code to viktor/terminal-lobby on Forgejo
The lobby has grown enough (frontend, two Go services, devvm units + scripts + config) that it earns its own repo. Code now lives at https://forgejo.viktorbarzin.me/viktor/terminal-lobby with scripts/deploy.sh covering the manual deploy until CI activation lands (Woodpecker forge_id=2 activation still 500s; Forgejo Actions not yet enabled). This stack now owns only the K8s side — Services, Endpoints, IngressRoutes, middlewares. main.tf comment block updated to point at the new repo and the full DevVM port map. Removed: - stacks/terminal/files/ (index.html + DevVM artefacts) - stacks/terminal/tmux-api/ (Go service) - stacks/terminal/clipboard-upload/ (Go service)
This commit is contained in:
parent
c135c04c79
commit
d6049ff7a0
12 changed files with 16 additions and 1530 deletions
|
|
@ -1,3 +0,0 @@
|
|||
module clipboard-upload
|
||||
|
||||
go 1.25.0
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
uploadDir = "/tmp/clipboard-images"
|
||||
maxUpload = 10 << 20 // 10MB
|
||||
listenAddr = "0.0.0.0:7683"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||
log.Fatalf("Failed to create upload dir: %v", err)
|
||||
}
|
||||
|
||||
http.HandleFunc("/upload", handleUpload)
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
log.Printf("Clipboard upload service listening on %s, saving to %s", listenAddr, uploadDir)
|
||||
log.Fatal(http.ListenAndServe(listenAddr, nil))
|
||||
}
|
||||
|
||||
func handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUpload)
|
||||
if err := r.ParseMultipartForm(maxUpload); err != nil {
|
||||
http.Error(w, "File too large (max 10MB)", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("image")
|
||||
if err != nil {
|
||||
http.Error(w, "Missing 'image' field", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
ct := header.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(ct, "image/") {
|
||||
http.Error(w, "Not an image", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ext := ".png"
|
||||
switch ct {
|
||||
case "image/jpeg":
|
||||
ext = ".jpg"
|
||||
case "image/gif":
|
||||
ext = ".gif"
|
||||
case "image/webp":
|
||||
ext = ".webp"
|
||||
}
|
||||
|
||||
randBytes := make([]byte, 4)
|
||||
rand.Read(randBytes)
|
||||
filename := fmt.Sprintf("%s-%s%s", time.Now().Format("20060102-150405"), hex.EncodeToString(randBytes), ext)
|
||||
destPath := filepath.Join(uploadDir, filename)
|
||||
|
||||
dest, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to save", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer dest.Close()
|
||||
|
||||
if _, err := io.Copy(dest, file); err != nil {
|
||||
os.Remove(destPath)
|
||||
http.Error(w, "Failed to save", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Saved clipboard image: %s (%s, %d bytes)", destPath, ct, header.Size)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"path": destPath})
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
# DevVM terminal files
|
||||
|
||||
ttyd + tmux-api on the DevVM (`10.0.10.10`). ttyd serves the multi-session
|
||||
lobby on port 7681 and attaches each Authentik identity into its own OS
|
||||
user's tmux server. tmux-api (port 7684) backs the lobby's list/kill
|
||||
actions, scoped to the same OS user.
|
||||
|
||||
`terminal-ro.service` (port 7682, single read-only session) and
|
||||
`clipboard-upload` (port 7683) are unchanged by these files.
|
||||
|
||||
## Per-user isolation
|
||||
|
||||
The Authentik forward-auth middleware injects `X-authentik-username` on
|
||||
every authenticated request:
|
||||
|
||||
1. **ttyd** is started with `-H X-authentik-username`, so the header value
|
||||
lands as `$TTYD_USER` in each launched `tmux-attach.sh` invocation.
|
||||
2. **`tmux-attach.sh`** looks up `$TTYD_USER` in `/etc/ttyd-user-map`,
|
||||
denies the connection if there is no mapping, and otherwise
|
||||
`sudo -n -H -u <os_user> /usr/bin/tmux …`.
|
||||
3. **`tmux-api`** reads `X-authentik-username` on every request and runs
|
||||
tmux as the mapped OS user too — so the lobby's session list is the
|
||||
intersection of "your Authentik identity" and "what tmux on that OS
|
||||
user's socket reports".
|
||||
|
||||
Different Authentik identities map to different Unix users, which means
|
||||
different `/tmp/tmux-<uid>/default` sockets — kernel-level isolation,
|
||||
not "the API filtered the list".
|
||||
|
||||
Adding a new user:
|
||||
|
||||
1. Append a line to `/etc/ttyd-user-map` (canonical at
|
||||
`files/devvm/ttyd-user-map`).
|
||||
2. Append `wizard ALL=(<os_user>) NOPASSWD: /usr/bin/tmux` to
|
||||
`/etc/sudoers.d/ttyd-users` (canonical at
|
||||
`files/devvm/sudoers.d-ttyd-users`).
|
||||
3. Ensure the OS user exists (`useradd -m <os_user>`).
|
||||
|
||||
## Layout
|
||||
|
||||
| Source | Destination on DevVM | Mode |
|
||||
|--------|----------------------|------|
|
||||
| `tmux-attach.sh` | `/usr/local/bin/tmux-attach.sh` | 0755 |
|
||||
| `ttyd.service` | `/etc/systemd/system/ttyd.service` | 0644 |
|
||||
| `tmux-api.service` | `/etc/systemd/system/tmux-api.service` | 0644 |
|
||||
| `ttyd-user-map` | `/etc/ttyd-user-map` | 0644 |
|
||||
| `sudoers.d-ttyd-users` | `/etc/sudoers.d/ttyd-users` | 0440, root:root |
|
||||
| `../index.html` (one dir up) | `/usr/local/share/ttyd/index.html` | 0644 |
|
||||
| `../../tmux-api/` Go binary | `/usr/local/bin/tmux-api` | 0755 |
|
||||
|
||||
## Apply
|
||||
|
||||
From the workstation (`infra/` repo root):
|
||||
|
||||
```bash
|
||||
DEVVM=10.0.10.10 # SSH config provides the user
|
||||
|
||||
# 1. Build the tmux-api binary for linux/amd64
|
||||
( cd infra/stacks/terminal/tmux-api && GOOS=linux GOARCH=amd64 go build -o /tmp/tmux-api . )
|
||||
|
||||
# 2. HTML + config files
|
||||
scp infra/stacks/terminal/files/index.html $DEVVM:/tmp/index.html
|
||||
scp infra/stacks/terminal/files/devvm/tmux-attach.sh $DEVVM:/tmp/tmux-attach.sh
|
||||
scp infra/stacks/terminal/files/devvm/ttyd-user-map $DEVVM:/tmp/ttyd-user-map
|
||||
scp infra/stacks/terminal/files/devvm/sudoers.d-ttyd-users $DEVVM:/tmp/sudoers.d-ttyd-users
|
||||
ssh $DEVVM "
|
||||
sudo install -m 0644 /tmp/index.html /usr/local/share/ttyd/index.html
|
||||
sudo install -m 0755 /tmp/tmux-attach.sh /usr/local/bin/tmux-attach.sh
|
||||
sudo install -m 0644 /tmp/ttyd-user-map /etc/ttyd-user-map
|
||||
sudo install -m 0440 -o root -g root /tmp/sudoers.d-ttyd-users /etc/sudoers.d/ttyd-users
|
||||
sudo visudo -cf /etc/sudoers.d/ttyd-users
|
||||
rm /tmp/index.html /tmp/tmux-attach.sh /tmp/ttyd-user-map /tmp/sudoers.d-ttyd-users
|
||||
"
|
||||
|
||||
# 3. tmux-api binary
|
||||
scp /tmp/tmux-api $DEVVM:/tmp/tmux-api
|
||||
ssh $DEVVM "sudo install -m 0755 /tmp/tmux-api /usr/local/bin/tmux-api && rm /tmp/tmux-api"
|
||||
|
||||
# 4. systemd units
|
||||
scp infra/stacks/terminal/files/devvm/ttyd.service $DEVVM:/tmp/
|
||||
scp infra/stacks/terminal/files/devvm/tmux-api.service $DEVVM:/tmp/
|
||||
ssh $DEVVM "
|
||||
sudo mv /tmp/ttyd.service /etc/systemd/system/
|
||||
sudo mv /tmp/tmux-api.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now tmux-api
|
||||
sudo systemctl restart ttyd
|
||||
"
|
||||
|
||||
# 5. Sanity checks
|
||||
ssh $DEVVM "systemctl status ttyd tmux-api --no-pager"
|
||||
ssh $DEVVM "curl -sf -H 'X-Authentik-Username: vbarzin' localhost:7684/whoami"
|
||||
ssh $DEVVM "curl -sf -H 'X-Authentik-Username: emil.barzin' localhost:7684/whoami"
|
||||
ssh $DEVVM "curl -si -H 'X-Authentik-Username: nobody' localhost:7684/whoami | head -3"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **ttyd ≥ 1.7** required for the `-a` flag (URL args → argv). DevVM has 1.7.7.
|
||||
- **Argv flow**: `?arg=foo` → ttyd appends `foo` as `$1` to `tmux-attach.sh`
|
||||
→ wrapper regex-validates and runs `tmux new-session -A -s "$name"`. ttyd
|
||||
uses argv, never a shell string — no injection path.
|
||||
- **No external exposure of 7681/7684** — DevVM is internal-VLAN-only;
|
||||
Authentik forward-auth is the access gate.
|
||||
- **Cutover history** — `term.viktorbarzin.me` and `ttyd-multi.service`
|
||||
(port 7685) were the staging surface for this design; both retired
|
||||
when the multi-session config was promoted to port 7681. The
|
||||
per-Authentik-user isolation followed in a separate change.
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
# Install at /etc/sudoers.d/ttyd-users (mode 0440, owner root:root).
|
||||
#
|
||||
# wizard (the user running ttyd.service + tmux-api.service) needs to run
|
||||
# tmux as the OS user that backs each Authentik identity. Narrow the
|
||||
# NOPASSWD grant to the tmux binary only, scoped to each named target user
|
||||
# — never `(ALL)`.
|
||||
#
|
||||
# Add one line per OS user listed on the right-hand side of
|
||||
# /etc/ttyd-user-map. The mapping file is the source of truth for which
|
||||
# Authentik usernames are accepted; this file is the kernel-level grant
|
||||
# that makes the per-user attach actually work.
|
||||
|
||||
wizard ALL=(emo) NOPASSWD: /usr/bin/tmux
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
[Unit]
|
||||
Description=tmux-api (port 7684) - REST API for listing and killing tmux sessions
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/tmux-api
|
||||
Restart=always
|
||||
User=wizard
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Invoked by ttyd.service per WebSocket connection. ttyd's `-a` flag
|
||||
# forwards `?arg=<value>` as $1; `-H X-authentik-username` sets
|
||||
# $TTYD_USER to the Authentik identity.
|
||||
#
|
||||
# We map TTYD_USER → OS user via /etc/ttyd-user-map and sudo into that
|
||||
# user before running tmux, so each Authentik identity gets its own
|
||||
# kernel-isolated tmux server (one socket per uid). Authentik users
|
||||
# without a mapping are denied — no fallback to a shared account.
|
||||
set -euo pipefail
|
||||
|
||||
MAP=/etc/ttyd-user-map
|
||||
NAME_RE='^[a-zA-Z0-9_-]{1,32}$'
|
||||
|
||||
auth_user="${TTYD_USER:-}"
|
||||
auth_local="${auth_user%%@*}"
|
||||
|
||||
os_user=""
|
||||
if [[ -n "$auth_local" && -r "$MAP" ]]; then
|
||||
os_user=$(awk -F= -v k="$auth_local" '
|
||||
/^[[:space:]]*(#|$)/ {next}
|
||||
$1==k {sub(/:.*$/, "", $2); print $2; exit}
|
||||
' "$MAP")
|
||||
fi
|
||||
|
||||
if [[ -z "$os_user" ]] || ! id "$os_user" >/dev/null 2>&1; then
|
||||
cat <<EOF
|
||||
|
||||
Access denied
|
||||
─────────────
|
||||
No terminal account for Authentik user '${auth_user:-<missing header>}'.
|
||||
|
||||
This DevVM maps Authentik identities to OS users via
|
||||
/etc/ttyd-user-map. Ask Viktor to add a mapping (and a matching
|
||||
/etc/sudoers.d/ttyd-users entry) if you should have access.
|
||||
|
||||
EOF
|
||||
sleep 10
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Session name from URL ?arg=<name>; default to the OS user's own name.
|
||||
name="${1:-$os_user}"
|
||||
[[ "$name" =~ $NAME_RE ]] || name="$os_user"
|
||||
|
||||
home_dir=$(getent passwd "$os_user" | cut -d: -f6)
|
||||
home_dir="${home_dir:-/}"
|
||||
|
||||
if [[ "$os_user" == "$(id -un)" ]]; then
|
||||
exec tmux new-session -A -s "$name" -c "$home_dir"
|
||||
else
|
||||
exec sudo -n -H -u "$os_user" tmux new-session -A -s "$name" -c "$home_dir"
|
||||
fi
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
# Authentik username (X-authentik-username header value, local part before @)
|
||||
# → OS user on this DevVM.
|
||||
#
|
||||
# Format: "<authentik_username>=<os_user>" — one mapping per line.
|
||||
# Lines starting with # and blank lines are ignored.
|
||||
# Authentik users WITHOUT a mapping here are denied (no default fallback).
|
||||
#
|
||||
# Adding a new user: append a mapping + extend /etc/sudoers.d/ttyd-users so
|
||||
# wizard can `sudo -n -u <os_user> /usr/bin/tmux ...` without a password.
|
||||
|
||||
vbarzin=wizard
|
||||
emil.barzin=emo
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
[Unit]
|
||||
Description=ttyd Terminal Service (multi-session lobby + per-Authentik-user attach on port 7681)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/ttyd -W -a -H X-authentik-username -t enableClipboard=true -I /usr/local/share/ttyd/index.html -p 7681 /usr/local/bin/tmux-attach.sh
|
||||
Restart=always
|
||||
User=wizard
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -1,950 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Terminal</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
||||
<style>
|
||||
/* Themes — set on <body> as theme-{carbon,slate,mono,ink}. Slate is the default. */
|
||||
body.theme-carbon {
|
||||
--bg-page: #0c0c0a;
|
||||
--bg-sidebar: #131311;
|
||||
--bg-card: #1a1916;
|
||||
--bg-card-hover: #22211d;
|
||||
--text-primary: #e8e3d6;
|
||||
--text-muted: #8a8474;
|
||||
--border: #2a2826;
|
||||
--border-strong: #3a3733;
|
||||
--accent: #d4a574;
|
||||
--danger: #c47a5a;
|
||||
--success: #88a888;
|
||||
--terminal-bg: #0c0c0a;
|
||||
--terminal-fg: #e8e3d6;
|
||||
--terminal-cursor: #d4a574;
|
||||
--terminal-selection: rgba(212,165,116,0.22);
|
||||
}
|
||||
body.theme-slate {
|
||||
--bg-page: #0d1117;
|
||||
--bg-sidebar: #11151c;
|
||||
--bg-card: #161b22;
|
||||
--bg-card-hover: #1c2128;
|
||||
--text-primary: #e6e8eb;
|
||||
--text-muted: #7d8590;
|
||||
--border: #1f242d;
|
||||
--border-strong: #30363d;
|
||||
--accent: #4493f8;
|
||||
--danger: #f47067;
|
||||
--success: #56d364;
|
||||
--terminal-bg: #0d1117;
|
||||
--terminal-fg: #e6e8eb;
|
||||
--terminal-cursor: #4493f8;
|
||||
--terminal-selection: rgba(68,147,248,0.22);
|
||||
}
|
||||
body.theme-mono {
|
||||
--bg-page: #0d0d0d;
|
||||
--bg-sidebar: #131313;
|
||||
--bg-card: #1a1a1a;
|
||||
--bg-card-hover: #232323;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-muted: #7d7d7d;
|
||||
--border: #2a2a2a;
|
||||
--border-strong: #3a3a3a;
|
||||
--accent: #e8e8e8;
|
||||
--danger: #cc7070;
|
||||
--success: #9ccb9c;
|
||||
--terminal-bg: #0d0d0d;
|
||||
--terminal-fg: #e0e0e0;
|
||||
--terminal-cursor: #e8e8e8;
|
||||
--terminal-selection: rgba(255,255,255,0.18);
|
||||
}
|
||||
body.theme-ink {
|
||||
--bg-page: #faf7f2;
|
||||
--bg-sidebar: #f1ebda;
|
||||
--bg-card: #ffffff;
|
||||
--bg-card-hover: #f4eee0;
|
||||
--text-primary: #1d1b18;
|
||||
--text-muted: #6e6862;
|
||||
--border: #d8d2c4;
|
||||
--border-strong: #b8b0a0;
|
||||
--accent: #b5482d;
|
||||
--danger: #b5482d;
|
||||
--success: #3d6b3d;
|
||||
--terminal-bg: #faf7f2;
|
||||
--terminal-fg: #1d1b18;
|
||||
--terminal-cursor: #b5482d;
|
||||
--terminal-selection: rgba(181,72,45,0.18);
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0; padding: 0; height: 100%; overflow: hidden;
|
||||
background: var(--bg-page, #0d1117);
|
||||
color: var(--text-primary, #e6e8eb);
|
||||
}
|
||||
#terminal { height: 100%; width: 100%; }
|
||||
.hidden { display: none !important; }
|
||||
#toast {
|
||||
position: fixed; top: 16px; right: 16px; z-index: 9999;
|
||||
background: var(--bg-card); color: var(--text-primary);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 10px 18px; font-family: monospace;
|
||||
font-size: 14px; opacity: 0; transition: opacity 0.3s;
|
||||
pointer-events: none; max-width: 500px; word-break: break-all;
|
||||
}
|
||||
#toast.visible { opacity: 1; }
|
||||
#toast.error { color: var(--danger); border-color: var(--danger); }
|
||||
#toast.success { color: var(--success); border-color: var(--success); }
|
||||
#paste-btn, #img-btn {
|
||||
position: fixed; bottom: 24px; z-index: 9999;
|
||||
width: 48px; height: 48px; border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
color: var(--text-primary); font-size: 22px; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
touch-action: manipulation; -webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
#paste-btn { right: 24px; }
|
||||
#img-btn { right: 80px; }
|
||||
#paste-btn:hover, #img-btn:hover {
|
||||
background: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
}
|
||||
#paste-btn:active, #img-btn:active { transform: scale(0.92); }
|
||||
#img-input { display: none; }
|
||||
|
||||
/* Lobby */
|
||||
#lobby {
|
||||
display: none; height: 100vh; box-sizing: border-box;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace;
|
||||
color: var(--text-primary); background: var(--bg-sidebar);
|
||||
}
|
||||
#lobby.visible { display: block; }
|
||||
#lobby.access-denied { padding: 32px; overflow-y: auto; }
|
||||
|
||||
#lobby-shell {
|
||||
display: grid; grid-template-columns: 260px 1fr; height: 100vh;
|
||||
}
|
||||
#lobby-sidebar {
|
||||
padding: 20px 14px; overflow-y: auto;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-sidebar); min-width: 0;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
#lobby-content {
|
||||
position: relative; background: var(--terminal-bg);
|
||||
min-width: 0; height: 100vh; overflow: hidden;
|
||||
}
|
||||
#session-frame { width: 100%; height: 100%; border: 0; display: block; }
|
||||
#session-frame.hidden { display: none; }
|
||||
#lobby-empty {
|
||||
color: var(--text-muted); display: flex; align-items: center; justify-content: center;
|
||||
height: 100%; font-style: italic; padding: 24px; text-align: center;
|
||||
}
|
||||
#lobby-empty.hidden { display: none; }
|
||||
|
||||
.lobby-header { font-size: 18px; color: var(--accent); margin: 0 0 4px 0; font-weight: 600; }
|
||||
.lobby-sub { color: var(--text-muted); font-size: 12px; margin: 0 0 18px 0; line-height: 1.5; }
|
||||
.new-row { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }
|
||||
.new-row input {
|
||||
box-sizing: border-box; padding: 8px 12px; border-radius: 8px;
|
||||
border: 1px solid var(--border); background: var(--bg-card);
|
||||
color: var(--text-primary); font-family: inherit; font-size: 13px;
|
||||
}
|
||||
.new-row input:focus { outline: none; border-color: var(--accent); }
|
||||
.new-row button {
|
||||
padding: 8px 12px; border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
color: var(--text-primary); font-family: inherit; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.new-row button:hover {
|
||||
background: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.session-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.session-card {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-left: 3px solid transparent; border-radius: 8px;
|
||||
padding: 10px 12px; cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
font-family: inherit; color: var(--text-primary); text-align: left;
|
||||
}
|
||||
.session-card:hover { background: var(--bg-card-hover); }
|
||||
.session-card:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.session-card.active {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.session-meta {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
min-width: 0; flex: 1;
|
||||
}
|
||||
.session-name {
|
||||
font-size: 14px; font-weight: 600; color: var(--text-primary);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.session-detail { font-size: 11px; color: var(--text-muted); }
|
||||
.session-detail.attached { color: var(--success); }
|
||||
.session-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
.session-actions button {
|
||||
padding: 4px 8px; border-radius: 6px; border: 1px solid var(--border);
|
||||
background: var(--bg-card-hover); color: var(--text-primary);
|
||||
font-family: inherit; font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.session-actions button.rename {
|
||||
color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
.session-actions button.kill {
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
|
||||
}
|
||||
.session-actions button:hover { filter: brightness(1.25); }
|
||||
.session-card.dragging { opacity: 0.4; cursor: grabbing; }
|
||||
.empty { color: var(--text-muted); font-style: italic; padding: 12px 0; font-size: 12px; }
|
||||
|
||||
/* Theme picker — sits at the bottom of the sidebar */
|
||||
.theme-picker {
|
||||
margin-top: auto; padding-top: 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px; color: var(--text-muted);
|
||||
}
|
||||
.theme-picker-label {
|
||||
display: block; margin-bottom: 6px;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
}
|
||||
.theme-options { display: flex; gap: 4px; }
|
||||
.theme-options button {
|
||||
flex: 1; padding: 5px 4px; border-radius: 6px;
|
||||
background: var(--bg-card); color: var(--text-muted);
|
||||
border: 1px solid var(--border); font-family: inherit;
|
||||
font-size: 11px; cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.theme-options button:hover {
|
||||
background: var(--bg-card-hover); color: var(--text-primary);
|
||||
}
|
||||
.theme-options button.active {
|
||||
border-color: var(--accent); color: var(--accent);
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
#lobby-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 60vh;
|
||||
}
|
||||
#lobby-sidebar {
|
||||
border-right: 0; border-bottom: 1px solid var(--border);
|
||||
max-height: 40vh;
|
||||
}
|
||||
#lobby-content { height: 60vh; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal"></div>
|
||||
<div id="lobby">
|
||||
<div id="lobby-shell">
|
||||
<aside id="lobby-sidebar">
|
||||
<h1 class="lobby-header">tmux sessions</h1>
|
||||
<p class="lobby-sub">Pick a session or create one. Sessions persist after you close the tab.</p>
|
||||
<div class="new-row">
|
||||
<input id="new-name" type="text" placeholder="new session name" maxlength="32" autocomplete="off">
|
||||
<button id="new-btn">Create & Open</button>
|
||||
</div>
|
||||
<div id="session-list" class="session-list"></div>
|
||||
<div id="theme-picker" class="theme-picker">
|
||||
<span class="theme-picker-label">Theme</span>
|
||||
<div class="theme-options">
|
||||
<button type="button" data-theme="carbon">Carbon</button>
|
||||
<button type="button" data-theme="slate">Slate</button>
|
||||
<button type="button" data-theme="mono">Mono</button>
|
||||
<button type="button" data-theme="ink">Ink</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main id="lobby-content">
|
||||
<div id="lobby-empty">Pick a session, or create one above.</div>
|
||||
<iframe id="session-frame" class="hidden" referrerpolicy="same-origin" allow="clipboard-read; clipboard-write" title="terminal session"></iframe>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast"></div>
|
||||
<button id="img-btn" title="Upload image">📷</button>
|
||||
<button id="paste-btn" title="Paste from clipboard">📋</button>
|
||||
<input type="file" id="img-input" accept="image/*">
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
|
||||
<script>
|
||||
(async function() {
|
||||
const NAME_RE = /^[a-zA-Z0-9_-]{1,32}$/;
|
||||
const SESSIONS_API = '/api/sessions/sessions';
|
||||
const WHOAMI_API = '/api/sessions/whoami';
|
||||
const params = new URLSearchParams(location.search);
|
||||
const rawArg = params.get('arg');
|
||||
const validArg = rawArg && NAME_RE.test(rawArg) ? rawArg : null;
|
||||
|
||||
// Theme — shared by lobby + terminal-mode iframe (same-origin localStorage).
|
||||
const THEMES = ['carbon', 'slate', 'mono', 'ink'];
|
||||
const THEME_KEY = 'tmux-theme';
|
||||
function getTheme() {
|
||||
try {
|
||||
const t = localStorage.getItem(THEME_KEY);
|
||||
return THEMES.includes(t) ? t : 'slate';
|
||||
} catch (e) { return 'slate'; }
|
||||
}
|
||||
function applyTheme(t) {
|
||||
THEMES.forEach(x => document.body.classList.remove('theme-' + x));
|
||||
document.body.classList.add('theme-' + t);
|
||||
}
|
||||
function readTerminalTheme() {
|
||||
const cs = getComputedStyle(document.body);
|
||||
const v = k => cs.getPropertyValue(k).trim();
|
||||
return {
|
||||
background: v('--terminal-bg') || '#0d1117',
|
||||
foreground: v('--terminal-fg') || '#e6e8eb',
|
||||
cursor: v('--terminal-cursor') || '#4493f8',
|
||||
selectionBackground: v('--terminal-selection') || 'rgba(68,147,248,0.22)'
|
||||
};
|
||||
}
|
||||
applyTheme(getTheme());
|
||||
|
||||
function showToast(msg, type, duration) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = 'visible' + (type ? ' ' + type : '');
|
||||
clearTimeout(el._timer);
|
||||
el._timer = setTimeout(() => { el.className = ''; }, duration || 3000);
|
||||
}
|
||||
|
||||
function clearChildren(el) {
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
}
|
||||
|
||||
function emptyState(text) {
|
||||
const e = document.createElement('div');
|
||||
e.className = 'empty';
|
||||
e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
function showAccessDenied(detail) {
|
||||
document.getElementById('terminal').classList.add('hidden');
|
||||
document.getElementById('paste-btn').classList.add('hidden');
|
||||
document.getElementById('img-btn').classList.add('hidden');
|
||||
const lobby = document.getElementById('lobby');
|
||||
clearChildren(lobby);
|
||||
const h = document.createElement('h1');
|
||||
h.className = 'lobby-header';
|
||||
h.textContent = 'Access denied';
|
||||
lobby.appendChild(h);
|
||||
const p = document.createElement('p');
|
||||
p.className = 'lobby-sub';
|
||||
p.textContent = detail || 'You do not have a terminal account on this server.';
|
||||
lobby.appendChild(p);
|
||||
const p2 = document.createElement('p');
|
||||
p2.className = 'lobby-sub';
|
||||
p2.textContent = 'Authentik logs you in; access here requires an OS-user mapping in /etc/ttyd-user-map. Ask Viktor to add one.';
|
||||
lobby.appendChild(p2);
|
||||
lobby.classList.add('visible', 'access-denied');
|
||||
document.title = 'access denied';
|
||||
}
|
||||
|
||||
// Preflight: ask tmux-api who we are. 403 = unmapped Authentik user → deny.
|
||||
let whoami = null;
|
||||
try {
|
||||
const resp = await fetch(WHOAMI_API, { credentials: 'same-origin' });
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
showAccessDenied((await resp.text()).trim());
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
showAccessDenied('Preflight failed: HTTP ' + resp.status);
|
||||
return;
|
||||
}
|
||||
whoami = await resp.json();
|
||||
} catch (err) {
|
||||
showAccessDenied('Preflight failed: ' + err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validArg) {
|
||||
// ============================================================
|
||||
// LOBBY MODE — no valid ?arg=, render sidebar + iframe shell
|
||||
// ============================================================
|
||||
document.getElementById('terminal').classList.add('hidden');
|
||||
document.getElementById('paste-btn').classList.add('hidden');
|
||||
document.getElementById('img-btn').classList.add('hidden');
|
||||
document.getElementById('lobby').classList.add('visible');
|
||||
const baseTitle = 'tmux sessions (' + whoami.osUser + ')';
|
||||
document.title = baseTitle;
|
||||
document.querySelector('#lobby .lobby-sub').textContent =
|
||||
'Logged in as ' + whoami.osUser + ' (' + whoami.authentik + '). Sessions are kernel-isolated per Unix user; you only see your own.';
|
||||
|
||||
const listEl = document.getElementById('session-list');
|
||||
const newNameEl = document.getElementById('new-name');
|
||||
const newBtnEl = document.getElementById('new-btn');
|
||||
const frameEl = document.getElementById('session-frame');
|
||||
const emptyEl = document.getElementById('lobby-empty');
|
||||
|
||||
let currentActive = null;
|
||||
let dragSrcCard = null;
|
||||
let isDragging = false;
|
||||
const ORDER_KEY = 'tmux-session-order-' + whoami.osUser;
|
||||
|
||||
function relativeTime(epochSec) {
|
||||
if (!epochSec) return '';
|
||||
const diff = Math.floor(Date.now() / 1000) - epochSec;
|
||||
if (diff < 60) return diff + 's ago';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
function getSavedOrder() {
|
||||
try {
|
||||
const raw = localStorage.getItem(ORDER_KEY);
|
||||
if (!raw) return [];
|
||||
const arr = JSON.parse(raw);
|
||||
return Array.isArray(arr) ? arr.filter(n => typeof n === 'string') : [];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
function saveOrder(names) {
|
||||
try { localStorage.setItem(ORDER_KEY, JSON.stringify(names)); } catch (e) {}
|
||||
}
|
||||
function captureOrderFromDom() {
|
||||
return Array.from(listEl.querySelectorAll('.session-card')).map(c => c.dataset.name);
|
||||
}
|
||||
function applySavedOrder(sessions) {
|
||||
const saved = getSavedOrder();
|
||||
const byName = new Map(sessions.map(s => [s.name, s]));
|
||||
const out = [];
|
||||
for (const name of saved) {
|
||||
const s = byName.get(name);
|
||||
if (s) { out.push(s); byName.delete(name); }
|
||||
}
|
||||
for (const s of sessions) {
|
||||
if (byName.has(s.name)) out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function renameInOrder(oldName, newName) {
|
||||
const order = getSavedOrder();
|
||||
const i = order.indexOf(oldName);
|
||||
if (i < 0) return;
|
||||
order[i] = newName;
|
||||
saveOrder(order);
|
||||
}
|
||||
|
||||
function updateActiveCard() {
|
||||
listEl.querySelectorAll('.session-card').forEach(c => {
|
||||
if (c.dataset.name === currentActive) c.classList.add('active');
|
||||
else c.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
function activateSession(name) {
|
||||
if (!NAME_RE.test(name)) { showToast('Invalid name', 'error'); return; }
|
||||
if (currentActive === name) { updateActiveCard(); return; }
|
||||
currentActive = name;
|
||||
emptyEl.classList.add('hidden');
|
||||
frameEl.classList.remove('hidden');
|
||||
frameEl.src = '/?arg=' + encodeURIComponent(name);
|
||||
if (location.hash.slice(1) !== name) {
|
||||
history.replaceState(null, '', '#' + name);
|
||||
}
|
||||
document.title = 'tmux: ' + whoami.osUser + '/' + name;
|
||||
updateActiveCard();
|
||||
}
|
||||
|
||||
function deactivateSession() {
|
||||
if (!currentActive && frameEl.classList.contains('hidden')) return;
|
||||
currentActive = null;
|
||||
frameEl.classList.add('hidden');
|
||||
emptyEl.classList.remove('hidden');
|
||||
frameEl.src = 'about:blank';
|
||||
if (location.hash) {
|
||||
history.replaceState(null, '', location.pathname + location.search);
|
||||
}
|
||||
document.title = baseTitle;
|
||||
updateActiveCard();
|
||||
}
|
||||
|
||||
async function killSession(name) {
|
||||
if (!NAME_RE.test(name)) { showToast('Invalid name', 'error'); return; }
|
||||
if (!confirm('Kill tmux session "' + name + '"? Any running processes inside it will be terminated.')) return;
|
||||
try {
|
||||
const resp = await fetch(SESSIONS_API + '/' + encodeURIComponent(name), {
|
||||
method: 'DELETE', credentials: 'same-origin'
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast('Killed ' + name, 'success');
|
||||
} else if (resp.status === 404) {
|
||||
showToast('Session not found', 'error');
|
||||
} else {
|
||||
showToast('Kill failed: HTTP ' + resp.status, 'error');
|
||||
}
|
||||
if (currentActive === name) deactivateSession();
|
||||
renderLobby();
|
||||
} catch (err) {
|
||||
showToast('Kill error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function renameSession(oldName) {
|
||||
if (!NAME_RE.test(oldName)) { showToast('Invalid name', 'error'); return; }
|
||||
const input = prompt('Rename "' + oldName + '" to:', oldName);
|
||||
if (input === null) return;
|
||||
const newName = input.trim();
|
||||
if (!NAME_RE.test(newName)) {
|
||||
showToast('Name must match ^[a-zA-Z0-9_-]{1,32}$', 'error', 4000);
|
||||
return;
|
||||
}
|
||||
if (newName === oldName) return;
|
||||
try {
|
||||
const resp = await fetch(SESSIONS_API + '/' + encodeURIComponent(oldName) + '/rename', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName })
|
||||
});
|
||||
if (resp.status === 204) {
|
||||
renameInOrder(oldName, newName);
|
||||
showToast('Renamed to ' + newName, 'success');
|
||||
if (currentActive === oldName) {
|
||||
currentActive = newName;
|
||||
frameEl.src = '/?arg=' + encodeURIComponent(newName);
|
||||
history.replaceState(null, '', '#' + newName);
|
||||
document.title = 'tmux: ' + whoami.osUser + '/' + newName;
|
||||
}
|
||||
renderLobby();
|
||||
} else if (resp.status === 404) {
|
||||
showToast('Session not found', 'error');
|
||||
} else if (resp.status === 409) {
|
||||
showToast('Name "' + newName + '" already taken', 'error', 4000);
|
||||
} else if (resp.status === 400) {
|
||||
showToast('Invalid name', 'error');
|
||||
} else {
|
||||
showToast('Rename failed: HTTP ' + resp.status, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Rename error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderCard(s) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'session-card';
|
||||
card.dataset.name = s.name;
|
||||
card.setAttribute('role', 'button');
|
||||
card.setAttribute('tabindex', '0');
|
||||
card.setAttribute('aria-label', 'Activate session ' + s.name);
|
||||
card.draggable = true;
|
||||
if (s.name === currentActive) card.classList.add('active');
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'session-meta';
|
||||
const name = document.createElement('div');
|
||||
name.className = 'session-name';
|
||||
name.textContent = s.name;
|
||||
meta.appendChild(name);
|
||||
const detail = document.createElement('div');
|
||||
detail.className = 'session-detail' + (s.attached > 0 ? ' attached' : '');
|
||||
const attachedText = s.attached > 0 ? (s.attached + ' attached') : 'idle';
|
||||
detail.textContent = attachedText + ' · ' + relativeTime(s.lastActivity);
|
||||
meta.appendChild(detail);
|
||||
card.appendChild(meta);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'session-actions';
|
||||
const renameBtn = document.createElement('button');
|
||||
renameBtn.className = 'rename';
|
||||
renameBtn.textContent = 'Rename';
|
||||
renameBtn.draggable = false;
|
||||
renameBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
renameSession(s.name);
|
||||
});
|
||||
actions.appendChild(renameBtn);
|
||||
const killBtn = document.createElement('button');
|
||||
killBtn.className = 'kill';
|
||||
killBtn.textContent = 'Kill';
|
||||
killBtn.draggable = false;
|
||||
killBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
killSession(s.name);
|
||||
});
|
||||
actions.appendChild(killBtn);
|
||||
card.appendChild(actions);
|
||||
|
||||
card.addEventListener('click', () => activateSession(s.name));
|
||||
card.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
activateSession(s.name);
|
||||
}
|
||||
});
|
||||
|
||||
card.addEventListener('dragstart', (e) => {
|
||||
dragSrcCard = card;
|
||||
isDragging = true;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', s.name);
|
||||
card.classList.add('dragging');
|
||||
});
|
||||
card.addEventListener('dragend', () => {
|
||||
card.classList.remove('dragging');
|
||||
if (dragSrcCard === card) saveOrder(captureOrderFromDom());
|
||||
dragSrcCard = null;
|
||||
isDragging = false;
|
||||
});
|
||||
card.addEventListener('dragover', (e) => {
|
||||
if (!dragSrcCard || dragSrcCard === card) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const rect = card.getBoundingClientRect();
|
||||
const after = (e.clientY - rect.top) > rect.height / 2;
|
||||
if (after) card.after(dragSrcCard);
|
||||
else card.before(dragSrcCard);
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
async function renderLobby() {
|
||||
if (isDragging) return;
|
||||
try {
|
||||
const resp = await fetch(SESSIONS_API, { credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
clearChildren(listEl);
|
||||
listEl.appendChild(emptyState('Failed to load sessions: HTTP ' + resp.status));
|
||||
return;
|
||||
}
|
||||
const sessions = await resp.json();
|
||||
if (currentActive && !sessions.some(s => s.name === currentActive)) {
|
||||
deactivateSession();
|
||||
}
|
||||
clearChildren(listEl);
|
||||
if (sessions.length === 0) {
|
||||
listEl.appendChild(emptyState('No sessions yet.'));
|
||||
return;
|
||||
}
|
||||
const ordered = applySavedOrder(sessions);
|
||||
for (const s of ordered) listEl.appendChild(renderCard(s));
|
||||
} catch (err) {
|
||||
clearChildren(listEl);
|
||||
listEl.appendChild(emptyState('Error: ' + err.message));
|
||||
}
|
||||
}
|
||||
|
||||
newBtnEl.addEventListener('click', () => {
|
||||
const name = newNameEl.value.trim();
|
||||
if (!NAME_RE.test(name)) {
|
||||
showToast('Name must match ^[a-zA-Z0-9_-]{1,32}$', 'error', 4000);
|
||||
return;
|
||||
}
|
||||
newNameEl.value = '';
|
||||
activateSession(name);
|
||||
});
|
||||
newNameEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') newBtnEl.click();
|
||||
});
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
const hash = location.hash.slice(1);
|
||||
if (hash && NAME_RE.test(hash)) {
|
||||
if (hash !== currentActive) activateSession(hash);
|
||||
} else if (currentActive) {
|
||||
deactivateSession();
|
||||
}
|
||||
});
|
||||
|
||||
// Theme picker — clicking a swatch swaps body class, persists, and
|
||||
// reloads the iframe so the inner terminal re-themes too.
|
||||
const themePickerEl = document.getElementById('theme-picker');
|
||||
function paintThemeButtons() {
|
||||
const active = getTheme();
|
||||
themePickerEl.querySelectorAll('button[data-theme]').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.theme === active);
|
||||
});
|
||||
}
|
||||
themePickerEl.querySelectorAll('button[data-theme]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const t = btn.dataset.theme;
|
||||
if (!THEMES.includes(t)) return;
|
||||
try { localStorage.setItem(THEME_KEY, t); } catch (e) {}
|
||||
applyTheme(t);
|
||||
paintThemeButtons();
|
||||
if (currentActive && frameEl.src) {
|
||||
// Reload iframe so xterm picks up the new theme.
|
||||
const url = frameEl.src;
|
||||
frameEl.src = 'about:blank';
|
||||
setTimeout(() => { frameEl.src = url; }, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
paintThemeButtons();
|
||||
|
||||
renderLobby();
|
||||
setInterval(renderLobby, 5000);
|
||||
|
||||
const initialHash = location.hash.slice(1);
|
||||
if (initialHash && NAME_RE.test(initialHash)) {
|
||||
activateSession(initialHash);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TERMINAL MODE — valid ?arg=<session>, attach via ttyd
|
||||
// ============================================================
|
||||
|
||||
// ttyd binary protocol
|
||||
const MSG_OUTPUT = 0x30; // '0' - terminal output
|
||||
const MSG_SET_PREFS = 0x31; // '1' - JSON preferences
|
||||
const MSG_SET_TITLE = 0x32; // '2' - window title
|
||||
const MSG_INPUT = '0'; // client → server: input data
|
||||
const MSG_RESIZE = '1'; // client → server: {columns, rows}
|
||||
|
||||
let ws = null;
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace",
|
||||
fontSize: 15,
|
||||
theme: readTerminalTheme(),
|
||||
allowProposedApi: true
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
try {
|
||||
const webglAddon = new WebglAddon.WebglAddon();
|
||||
webglAddon.onContextLoss(() => { webglAddon.dispose(); });
|
||||
term.loadAddon(webglAddon);
|
||||
} catch (e) {
|
||||
console.warn('WebGL addon failed:', e);
|
||||
}
|
||||
|
||||
term.open(document.getElementById('terminal'));
|
||||
fitAddon.fit();
|
||||
|
||||
document.title = 'tmux: ' + whoami.osUser + '/' + validArg;
|
||||
|
||||
function sendInput(data) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const payload = textEncoder.encode(data);
|
||||
const buf = new Uint8Array(payload.length + 1);
|
||||
buf[0] = MSG_INPUT.charCodeAt(0);
|
||||
buf.set(payload, 1);
|
||||
ws.send(buf.buffer);
|
||||
}
|
||||
|
||||
function sendResize() {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const json = JSON.stringify({ columns: term.cols, rows: term.rows });
|
||||
const payload = textEncoder.encode(json);
|
||||
const buf = new Uint8Array(payload.length + 1);
|
||||
buf[0] = MSG_RESIZE.charCodeAt(0);
|
||||
buf.set(payload, 1);
|
||||
ws.send(buf.buffer);
|
||||
}
|
||||
|
||||
term.onData(sendInput);
|
||||
term.onBinary((data) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const bytes = new Uint8Array(data.length + 1);
|
||||
bytes[0] = MSG_INPUT.charCodeAt(0);
|
||||
for (let i = 0; i < data.length; i++) bytes[i + 1] = data.charCodeAt(i);
|
||||
ws.send(bytes.buffer);
|
||||
});
|
||||
|
||||
term.onResize(() => sendResize());
|
||||
window.addEventListener('resize', () => fitAddon.fit());
|
||||
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && term.hasSelection()) {
|
||||
navigator.clipboard.writeText(term.getSelection());
|
||||
return false;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
|
||||
return false; // let browser paste event fire
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
document.addEventListener('paste', async (e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const blob = item.getAsFile();
|
||||
if (!blob) { showToast('Failed to read image', 'error'); return; }
|
||||
|
||||
showToast('Uploading image...', '');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', blob);
|
||||
const resp = await fetch('/clipboard/upload', { method: 'POST', body: formData });
|
||||
if (!resp.ok) { showToast('Upload failed: ' + await resp.text(), 'error', 5000); return; }
|
||||
const { path } = await resp.json();
|
||||
sendInput(path);
|
||||
showToast('Pasted: ' + path, 'success', 4000);
|
||||
} catch (err) {
|
||||
showToast('Upload error: ' + err.message, 'error', 5000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const text = e.clipboardData.getData('text');
|
||||
if (text) {
|
||||
e.preventDefault();
|
||||
sendInput(text);
|
||||
}
|
||||
}, true);
|
||||
|
||||
document.getElementById('paste-btn').addEventListener('click', async () => {
|
||||
try {
|
||||
if (navigator.clipboard.read) {
|
||||
const items = await navigator.clipboard.read();
|
||||
for (const item of items) {
|
||||
const imageType = item.types.find(t => t.startsWith('image/'));
|
||||
if (imageType) {
|
||||
const blob = await item.getType(imageType);
|
||||
showToast('Uploading image...', '');
|
||||
const formData = new FormData();
|
||||
formData.append('image', blob);
|
||||
const resp = await fetch('/clipboard/upload', { method: 'POST', body: formData });
|
||||
if (!resp.ok) { showToast('Upload failed: ' + await resp.text(), 'error', 5000); return; }
|
||||
const { path } = await resp.json();
|
||||
sendInput(path);
|
||||
showToast('Pasted: ' + path, 'success', 4000);
|
||||
return;
|
||||
}
|
||||
if (item.types.includes('text/plain')) {
|
||||
const blob = await item.getType('text/plain');
|
||||
const text = await blob.text();
|
||||
if (text) { sendInput(text); return; }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) sendInput(text);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Clipboard access denied', 'error', 3000);
|
||||
console.error('Clipboard read failed:', err);
|
||||
}
|
||||
term.focus();
|
||||
});
|
||||
|
||||
document.getElementById('img-btn').addEventListener('click', () => {
|
||||
document.getElementById('img-input').click();
|
||||
});
|
||||
document.getElementById('img-input').addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
e.target.value = ''; // reset so same file can be re-selected
|
||||
|
||||
showToast('Uploading image...', '');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
const resp = await fetch('/clipboard/upload', { method: 'POST', body: formData });
|
||||
if (!resp.ok) { showToast('Upload failed: ' + await resp.text(), 'error', 5000); return; }
|
||||
const { path } = await resp.json();
|
||||
sendInput(path);
|
||||
showToast('Pasted: ' + path, 'success', 4000);
|
||||
} catch (err) {
|
||||
showToast('Upload error: ' + err.message, 'error', 5000);
|
||||
}
|
||||
term.focus();
|
||||
});
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const argSuffix = '?arg=' + encodeURIComponent(validArg);
|
||||
const base = location.pathname.replace(/\/+$/, '');
|
||||
const wsUrl = proto + '//' + location.host + base + '/ws' + argSuffix;
|
||||
|
||||
fetch(base + '/token' + argSuffix, { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(tokenData => {
|
||||
const token = tokenData.token || '';
|
||||
ws = new WebSocket(wsUrl, ['tty']);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('Connected to ttyd (session: ' + validArg + ')');
|
||||
const initMsg = JSON.stringify({
|
||||
AuthToken: token,
|
||||
columns: term.cols,
|
||||
rows: term.rows
|
||||
});
|
||||
ws.send(initMsg);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = event.data;
|
||||
if (data instanceof ArrayBuffer) {
|
||||
const view = new Uint8Array(data);
|
||||
if (view.length < 1) return;
|
||||
const msgType = view[0];
|
||||
const payload = view.slice(1);
|
||||
|
||||
switch (msgType) {
|
||||
case MSG_OUTPUT:
|
||||
term.write(payload);
|
||||
break;
|
||||
case MSG_SET_PREFS:
|
||||
try {
|
||||
const prefs = JSON.parse(textDecoder.decode(payload));
|
||||
console.log('ttyd prefs:', prefs);
|
||||
} catch (e) {}
|
||||
break;
|
||||
case MSG_SET_TITLE:
|
||||
document.title = textDecoder.decode(payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
term.write('\r\n\x1b[31mDisconnected. Reconnecting...\x1b[0m\r\n');
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = (e) => console.error('WebSocket error:', e);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Token fetch failed:', err);
|
||||
term.write('\r\n\x1b[31mFailed to connect. Retrying...\x1b[0m\r\n');
|
||||
setTimeout(connect, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
connect();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -217,15 +217,24 @@ module "ingress_ro" {
|
|||
}
|
||||
}
|
||||
|
||||
# === Multi-session lobby cutover on terminal.viktorbarzin.me ===
|
||||
# === Multi-session lobby on terminal.viktorbarzin.me ===
|
||||
#
|
||||
# The `terminal` Service+Endpoints (port 7681) above now backs the
|
||||
# multi-session lobby — ttyd.service on the DevVM runs tmux-attach.sh with
|
||||
# `-a`, serving index.html (the lobby HTML, ex-index-multi.html). DevVM-side
|
||||
# units ship from files/devvm/ — see files/devvm/README.md.
|
||||
# Application code (frontend, tmux-api, clipboard-upload, DevVM
|
||||
# systemd units / scripts / config) lives in a separate Forgejo repo:
|
||||
# https://forgejo.viktorbarzin.me/viktor/terminal-lobby
|
||||
#
|
||||
# The lobby's REST API (`/api/sessions/*`) is reverse-proxied to a small Go
|
||||
# binary on port 7684 via the IngressRoute below.
|
||||
# That repo's ./scripts/deploy.sh ships everything to wizard@10.0.10.10
|
||||
# and restarts ttyd / ttyd-ro / tmux-api / clipboard-upload. This stack
|
||||
# only owns the Kubernetes side: Services, Endpoints pointing at
|
||||
# 10.0.10.10:{7681,7682,7683,7684}, the IngressRoutes, and the Traefik
|
||||
# middlewares that gate everything behind Authentik forward-auth.
|
||||
#
|
||||
# Service map (DevVM):
|
||||
# ttyd :7681 → serves lobby + xterm WS
|
||||
# ttyd-ro :7682 → read-only mirror at terminal-ro.viktorbarzin.me
|
||||
# clipboard-upload :7683 → POST /upload, returns saved path
|
||||
# tmux-api :7684 → GET /sessions, DELETE /sessions/<n>,
|
||||
# POST /sessions/<n>/rename, GET /whoami
|
||||
|
||||
# Service+Endpoints → tmux-api on the DevVM (port 7684).
|
||||
resource "kubernetes_service" "tmux_api" {
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
module tmux-api
|
||||
|
||||
go 1.21
|
||||
|
|
@ -1,265 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
listenAddr = "0.0.0.0:7684"
|
||||
mapPath = "/etc/ttyd-user-map"
|
||||
authHeader = "X-Authentik-Username"
|
||||
tmuxBinary = "/usr/bin/tmux"
|
||||
sudoBinary = "/usr/bin/sudo"
|
||||
tmuxListFmt = "#{session_name}|#{session_attached}|#{session_activity}|#{session_created}"
|
||||
)
|
||||
|
||||
var sessionNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,32}$`)
|
||||
|
||||
var selfUser = func() string {
|
||||
if u, err := user.Current(); err == nil {
|
||||
return u.Username
|
||||
}
|
||||
return ""
|
||||
}()
|
||||
|
||||
type Session struct {
|
||||
Name string `json:"name"`
|
||||
Attached int `json:"attached"`
|
||||
LastActivity int64 `json:"lastActivity"`
|
||||
Created int64 `json:"created"`
|
||||
}
|
||||
|
||||
// loadUserMap reads /etc/ttyd-user-map → map[authentik_local]os_user.
|
||||
// Format: "<auth>=<os_user>[:<cwd>]" per line. Comments (#) and blanks ignored.
|
||||
// Re-read on every request — file is small and changes are rare.
|
||||
func loadUserMap() map[string]string {
|
||||
m := map[string]string{}
|
||||
f, err := os.Open(mapPath)
|
||||
if err != nil {
|
||||
log.Printf("loadUserMap: %v", err)
|
||||
return m
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
eq := strings.IndexByte(line, '=')
|
||||
if eq <= 0 {
|
||||
continue
|
||||
}
|
||||
auth := strings.TrimSpace(line[:eq])
|
||||
rhs := strings.TrimSpace(line[eq+1:])
|
||||
if c := strings.IndexByte(rhs, ':'); c > 0 {
|
||||
rhs = rhs[:c]
|
||||
}
|
||||
if auth != "" && rhs != "" {
|
||||
m[auth] = rhs
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// resolveOSUser → mapped OS user from the Authentik header, or "" after
|
||||
// writing the appropriate 401/403/500 to w.
|
||||
func resolveOSUser(w http.ResponseWriter, r *http.Request) string {
|
||||
authUser := r.Header.Get(authHeader)
|
||||
if authUser == "" {
|
||||
http.Error(w, "missing "+authHeader, http.StatusUnauthorized)
|
||||
return ""
|
||||
}
|
||||
local := authUser
|
||||
if i := strings.IndexByte(local, '@'); i > 0 {
|
||||
local = local[:i]
|
||||
}
|
||||
osUser := loadUserMap()[local]
|
||||
if osUser == "" {
|
||||
http.Error(w, fmt.Sprintf("no terminal account for '%s'", authUser), http.StatusForbidden)
|
||||
return ""
|
||||
}
|
||||
if _, err := user.Lookup(osUser); err != nil {
|
||||
log.Printf("mapped OS user %q missing on this host: %v", osUser, err)
|
||||
http.Error(w, "mapped OS user missing on this host", http.StatusInternalServerError)
|
||||
return ""
|
||||
}
|
||||
return osUser
|
||||
}
|
||||
|
||||
// tmuxCmd builds an exec.Cmd that runs `tmux <args...>` AS osUser. When
|
||||
// osUser is the current process owner, sudo is skipped; otherwise we use
|
||||
// `sudo -n -u <user> tmux ...` (passwordless grant via /etc/sudoers.d/ttyd-users).
|
||||
func tmuxCmd(osUser string, args ...string) *exec.Cmd {
|
||||
if osUser == selfUser {
|
||||
return exec.Command(tmuxBinary, args...)
|
||||
}
|
||||
full := append([]string{"-n", "-u", osUser, tmuxBinary}, args...)
|
||||
return exec.Command(sudoBinary, full...)
|
||||
}
|
||||
|
||||
func main() {
|
||||
http.HandleFunc("/sessions", handleSessions)
|
||||
http.HandleFunc("/sessions/", handleSessionByName)
|
||||
http.HandleFunc("/whoami", handleWhoami)
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
log.Printf("tmux-api listening on %s (self=%s)", listenAddr, selfUser)
|
||||
log.Fatal(http.ListenAndServe(listenAddr, nil))
|
||||
}
|
||||
|
||||
// /whoami → {authentik, osUser}. Used by the lobby HTML to render the
|
||||
// current identity and to preflight access before opening a session.
|
||||
func handleWhoami(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
authUser := r.Header.Get(authHeader)
|
||||
osUser := resolveOSUser(w, r)
|
||||
if osUser == "" {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"authentik": authUser,
|
||||
"osUser": osUser,
|
||||
})
|
||||
}
|
||||
|
||||
func handleSessions(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
osUser := resolveOSUser(w, r)
|
||||
if osUser == "" {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := tmuxCmd(osUser, "list-sessions", "-F", tmuxListFmt).Output()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err != nil {
|
||||
// tmux exits non-zero when no server is running or there are no
|
||||
// sessions for this uid — treat both as an empty list.
|
||||
w.Write([]byte("[]"))
|
||||
return
|
||||
}
|
||||
|
||||
sessions := make([]Session, 0)
|
||||
for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, "|")
|
||||
if len(parts) != 4 {
|
||||
continue
|
||||
}
|
||||
attached, _ := strconv.Atoi(parts[1])
|
||||
activity, _ := strconv.ParseInt(parts[2], 10, 64)
|
||||
created, _ := strconv.ParseInt(parts[3], 10, 64)
|
||||
sessions = append(sessions, Session{
|
||||
Name: parts[0],
|
||||
Attached: attached,
|
||||
LastActivity: activity,
|
||||
Created: created,
|
||||
})
|
||||
}
|
||||
json.NewEncoder(w).Encode(sessions)
|
||||
}
|
||||
|
||||
func handleSessionByName(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/sessions/")
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
parts := strings.Split(path, "/")
|
||||
name := parts[0]
|
||||
if !sessionNameRe.MatchString(name) {
|
||||
http.Error(w, "invalid session name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
osUser := resolveOSUser(w, r)
|
||||
if osUser == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
killSession(w, osUser, name)
|
||||
return
|
||||
}
|
||||
if len(parts) == 2 && parts[1] == "rename" {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
renameSession(w, r, osUser, name)
|
||||
return
|
||||
}
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func killSession(w http.ResponseWriter, osUser, name string) {
|
||||
out, err := tmuxCmd(osUser, "kill-session", "-t", name).CombinedOutput()
|
||||
if err != nil {
|
||||
msg := string(out)
|
||||
if strings.Contains(msg, "can't find session") || strings.Contains(msg, "no server running") {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("kill-session %s as %s failed: %v: %s", name, osUser, err, msg)
|
||||
http.Error(w, "kill-session failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func renameSession(w http.ResponseWriter, r *http.Request, osUser, oldName string) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
newName := strings.TrimSpace(body.Name)
|
||||
if !sessionNameRe.MatchString(newName) {
|
||||
http.Error(w, "invalid new name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if newName == oldName {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := tmuxCmd(osUser, "rename-session", "-t", oldName, newName).CombinedOutput()
|
||||
if err != nil {
|
||||
msg := string(out)
|
||||
if strings.Contains(msg, "can't find session") || strings.Contains(msg, "no server running") {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if strings.Contains(msg, "duplicate session") || strings.Contains(msg, "session already exists") {
|
||||
http.Error(w, "target name already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
log.Printf("rename-session %s→%s as %s failed: %v: %s", oldName, newName, osUser, err, msg)
|
||||
http.Error(w, "rename-session failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue