diff --git a/.gitignore b/.gitignore index 225f26c..80dab3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ test/ upload/ gotermix +gotermix.log *.json *.key test* \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 9dc26c2..afbfb69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,15 +5,18 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Commands ```bash -# Build -go build . +# Build (static — no glibc dep, runs on NixOS / Alpine / any Linux) +CGO_ENABLED=0 go build . # Build with injected encryption key (production) -go build -ldflags "-X gotermix/internals.fileEncKeyHex=$(openssl rand -hex 32)" . +CGO_ENABLED=0 go build -ldflags "-X gotermix/internals.fileEncKeyHex=$(openssl rand -hex 32)" . # Build with env-var key export GOTERMINAL_ENC="your64hexchars" -go build -ldflags "-X gotermix/internals.fileEncKeyHex=${GOTERMINAL_ENC}" . +CGO_ENABLED=0 go build -ldflags "-X gotermix/internals.fileEncKeyHex=${GOTERMINAL_ENC}" . + +# Cross-compile for Linux amd64 from any OS +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build . # Run (dev) ./gotermix diff --git a/README.md b/README.md index 97d2385..7f6ae4a 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,119 @@ -# Go web terminal -- the app runs with access as user who started it! -- it runs on https ( default is random ssl certifcate generated during start) -- you can turn off user account required to access it during startup -- default user account is `ivor` and pw `Silv3rSw0rd!` -- you can upload and download files - as long as user has to it access who has terminal session +# GoTermix — Web Terminal +- Runs with access as the user who started it +- HTTPS only (auto-generates self-signed cert on startup) +- Multi-tab support with split panes (horizontal & vertical) +- Workspace layout saved and shareable via a single URL +- Upload and download files (respects shell user permissions) +- Auth logs with real IP detection (Cloudflare / Traefik aware) -## Usage: +## Default credentials -- listen address (default "127.0.0.1:5000") - `./gotermix -addr ` -- disable password authentication - `./gotermix -nopw` -- set login username (next arg is password) and restart the app. - `./gotermix -setlogin ` --s et a cert (validates it first, then stores paths encrypted, exits) - `./gotermix -cert /etc/ssl/my.crt -certkey /etc/ssl/my.key` -- combined cert+key PEM file (omit -certkey) - `./gotermix -cert /etc/ssl/combined.pem` -- remove stored cert, revert to self-signed - `./gotermix -certreset` +User: `ivor` / Password: `Silv3rSw0rd!` -## Set custom encryption password for the .json file during build -`export GOTERMINAL_ENC="SoMeStRongPasSwoR2d"` -`go build -ldflags "-X main.fileEncKeyHex=${GOTERMINAL_ENC}" .` -## or one-liner: -`go build -ldflags "-X main.fileEncKeyHex=$(openssl rand -hex 32)" .` +Change with `-setlogin` before first use. + +--- + +## Usage + +``` +./gotermix [flags] +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `-addr ` | `127.0.0.1:5000` | Listen address | +| `-nopw` | off | Disable password authentication | +| `-setlogin ` | — | Set credentials (app must restart to pick up) | +| `-cert ` | — | Store custom TLS cert PEM (validates first, then exits) | +| `-certkey ` | — | Private key PEM (omit if combined with `-cert`) | +| `-certreset` | — | Remove stored cert, revert to self-signed | +| `-log ` | `gotermix.log` next to binary | Auth log file path | +| `-log off` | — | Disable file logging (console output always on) | + +--- + +## Build + +> **Always build with `CGO_ENABLED=0`** for a fully static binary that runs on NixOS, Alpine, and any Linux without glibc. + +```bash +# Dev build +CGO_ENABLED=0 go build . + +# Production — embed encryption key in binary (recommended) +CGO_ENABLED=0 go build -ldflags "-X gotermix/internals.fileEncKeyHex=$(openssl rand -hex 32)" . + +# Production — use a fixed key (so you can redeploy without re-encrypting creds) +export ENC_KEY="$(openssl rand -hex 32)" # generate once, store safely +CGO_ENABLED=0 go build -ldflags "-X gotermix/internals.fileEncKeyHex=${ENC_KEY}" . + +# Cross-compile for Linux amd64 from any OS +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build . +``` + +### Encryption key priority + +The credentials file (`gws-creds.json`) is AES-256-GCM encrypted. The key is resolved in this order: + +1. **Build-time** (`-ldflags "-X gotermix/internals.fileEncKeyHex=<64 hex chars>"`) — key inside binary, no extra file needed +2. **`gws.key`** — auto-read if present next to the binary +3. **Auto-generate** — creates `gws.key` on first run if neither of the above exist + +> If you copy the binary to another machine without embedding the key, a new `gws.key` is generated and the existing `gws-creds.json` becomes unreadable. Always embed the key at build time for portable deployments. + +Verify the key is embedded: +```bash +strings gotermix | grep -E '^[0-9a-f]{64}$' +``` + +--- + +## TLS certificate + +```bash +# Use custom cert (stored encrypted, exits after) +./gotermix -cert /etc/ssl/my.crt -certkey /etc/ssl/my.key + +# Combined cert+key PEM (omit -certkey) +./gotermix -cert /etc/ssl/combined.pem + +# Revert to self-signed +./gotermix -certreset +``` + +--- + +## Auth logging + +Structured JSON-lines, one entry per login attempt: + +```json +{"time":"2026-05-24T12:34:56Z","remote_ip":"1.2.3.4","username":"admin","success":false,"message":"invalid_credentials"} +``` + +- Real client IP extracted from `CF-Connecting-IP` → `X-Forwarded-For` → `X-Real-IP` → `RemoteAddr` +- Compatible with CrowdSec and fail2ban custom parsers +- Console output always on; file output controlled by `-log` + +--- + +## Keyboard shortcuts + +| Shortcut | Action | +|----------|--------| +| `Alt+T` | New tab | +| `Alt+W` | Close tab | +| `Alt+Shift+←/→` | Previous / next tab | +| `Alt+\` | Split pane left/right | +| `Alt+-` | Split pane top/bottom | +| `Alt+X` | Close active pane | +| `Ctrl+Shift+C` | Copy selection | +| `Ctrl+V` | Paste | + +--- ![Login Page](.samples/login-page.png "Login Page") - ![Terminal](.samples/terminal.png "Terminal") diff --git a/internals/auth.go b/internals/auth.go index dad4908..8849abd 100644 --- a/internals/auth.go +++ b/internals/auth.go @@ -11,6 +11,44 @@ import ( "time" ) +// ── CSRF ────────────────────────────────────────────────────────────── + +func newCSRFToken() string { + b := make([]byte, 24) + rand.Read(b) //nolint:errcheck + return hex.EncodeToString(b) +} + +// setCSRFCookie writes a fresh CSRF token to a short-lived cookie and returns +// the token value so it can be embedded in the rendered HTML form. +func setCSRFCookie(w http.ResponseWriter) string { + tok := newCSRFToken() + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, + Value: tok, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: 900, // 15 min — covers slow typists + }) + return tok +} + +// checkCSRF returns true iff the submitted csrf_token form field matches the +// cookie value (constant-time compare to prevent timing side-channels). +func checkCSRF(r *http.Request) bool { + c, err := r.Cookie(csrfCookieName) + if err != nil || c.Value == "" { + return false + } + formTok := r.FormValue("csrf_token") + if formTok == "" { + return false + } + return hmac.Equal([]byte(c.Value), []byte(formTok)) +} + func checkCreds(username, password string) bool { if username != appCreds.Username { return false diff --git a/internals/config.go b/internals/config.go index 820983a..f107d57 100644 --- a/internals/config.go +++ b/internals/config.go @@ -14,6 +14,7 @@ const ( maxUploadSize = 512 << 20 sessionTTL = 24 * time.Hour authCookieName = "gws_auth" + csrfCookieName = "gws_csrf" authTokenTTL = 12 * time.Hour credsFilename = "gws-creds.json" defaultUser = "ivor" diff --git a/internals/handlers.go b/internals/handlers.go index e637c77..f3f8fcc 100644 --- a/internals/handlers.go +++ b/internals/handlers.go @@ -18,6 +18,9 @@ import ( //go:embed web/shell.html var shellPageHTML string +//go:embed web/login.html +var loginPageHTML string + //go:embed web/favicon.svg var faviconSVG string @@ -57,24 +60,68 @@ func handleStaticJS(w http.ResponseWriter, r *http.Request) { w.Write(data) } -// handleIndex: always creates a fresh workspace and redirects to its stable URL. -// PTY sessions are started lazily by the frontend via WebSocket connections. +// handleLogin serves the standalone login page (GET) or redirects authed users. +func handleLogin(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/login" { + http.NotFound(w, r) + return + } + if isAuthed(r) { + http.Redirect(w, r, "/", http.StatusFound) + return + } + next := r.URL.Query().Get("next") + if !isValidNext(next) { + next = "/" + } + tok := setCSRFCookie(w) + html := strings.NewReplacer( + "[[CSRF_TOKEN]]", tok, + "[[NEXT]]", next, + ).Replace(loginPageHTML) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Write([]byte(html)) //nolint:errcheck +} + +// isValidNext rejects open-redirect targets; only "/" and "/s/" allowed. +func isValidNext(next string) bool { + if next == "" || next == "/" { + return true + } + if strings.HasPrefix(next, "/s/") { + return validID(strings.TrimPrefix(next, "/s/")) + } + return false +} + +// handleIndex: creates a fresh workspace and redirects to its stable URL. +// Unauthenticated requests are sent to the login page first. func handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } + if !isAuthed(r) { + http.Redirect(w, r, "/login", http.StatusFound) + return + } http.Redirect(w, r, "/s/"+randHex(16), http.StatusFound) } -// handleShell: serves the terminal page for an existing (or new) workspace ID. +// handleShell: serves the terminal page for a workspace ID. +// Unauthenticated requests are redirected to /login?next=... func handleShell(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/s/") if !validID(id) { http.NotFound(w, r) return } - serveTerminalPage(w, id, isAuthed(r)) + if !isAuthed(r) { + http.Redirect(w, r, "/login?next=/s/"+id, http.StatusFound) + return + } + serveTerminalPage(w, id, true) } func serveTerminalPage(w http.ResponseWriter, workspaceID string, authed bool) { @@ -94,15 +141,36 @@ func handleAuth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) - w.Write([]byte(`{"error":"POST only"}`)) + w.Write([]byte(`{"error":"POST only"}`)) //nolint:errcheck return } if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(`{"error":"bad form"}`)) + w.Write([]byte(`{"error":"bad form"}`)) //nolint:errcheck return } - if checkCreds(strings.TrimSpace(r.FormValue("username")), r.FormValue("password")) { + + // CSRF validation (skipped in -nopw mode which never shows the login page) + if !nopwMode && !checkCSRF(r) { + logAuthAttempt(r, "", false, "csrf_invalid") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":"invalid request"}`)) //nolint:errcheck + return + } + + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + + // Input bounds — reject obviously bad values before touching the hasher + if len(username) == 0 || len(username) > 64 || len(password) == 0 || len(password) > 1024 { + time.Sleep(500 * time.Millisecond) + logAuthAttempt(r, username, false, "invalid_input") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid input"}`)) //nolint:errcheck + return + } + + if checkCreds(username, password) { http.SetCookie(w, &http.Cookie{ Name: authCookieName, Value: makeAuthToken(), @@ -112,11 +180,13 @@ func handleAuth(w http.ResponseWriter, r *http.Request) { SameSite: http.SameSiteLaxMode, MaxAge: int(authTokenTTL.Seconds()), }) - w.Write([]byte(`{"ok":true}`)) + logAuthAttempt(r, username, true, "login_success") + w.Write([]byte(`{"ok":true}`)) //nolint:errcheck } else { time.Sleep(500 * time.Millisecond) // blunt brute-force deterrent + logAuthAttempt(r, username, false, "invalid_credentials") w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(`{"error":"Invalid username or password"}`)) + w.Write([]byte(`{"error":"Invalid username or password"}`)) //nolint:errcheck } } diff --git a/internals/logging.go b/internals/logging.go new file mode 100644 index 0000000..9af69e9 --- /dev/null +++ b/internals/logging.go @@ -0,0 +1,106 @@ +package internals + +import ( + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// authFileLog writes structured JSON-lines to a file; nil = file logging off. +// Console logging always fires regardless of this setting. +var authFileLog *log.Logger + +// authLogEntry is the structured format for each auth event. +// One JSON object per line — compatible with CrowdSec, fail2ban, jq. +type authLogEntry struct { + Time string `json:"time"` // RFC3339 UTC + RemoteIP string `json:"remote_ip"` // real client IP (proxy-aware) + Username string `json:"username"` + Success bool `json:"success"` + Message string `json:"message"` // login_success | invalid_credentials | csrf_invalid | invalid_input +} + +// initAuthLogger opens (or creates) the log file. +// path "off" disables file logging; console output is always on. +func initAuthLogger(path string) { + if strings.EqualFold(path, "off") { + fmt.Println("auth log: disabled (console only)") + return + } + dir := filepath.Dir(path) + if dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0750); err != nil { + fmt.Fprintf(os.Stderr, "auth log: cannot create dir %q: %v\n", dir, err) + return + } + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640) + if err != nil { + fmt.Fprintf(os.Stderr, "auth log: cannot open %q: %v\n", path, err) + return + } + // log.New with empty flags → raw lines, no timestamp prefix (timestamp is in JSON) + authFileLog = log.New(f, "", 0) + fmt.Printf("auth log: %s\n", path) +} + +// logAuthAttempt records one auth event. +// Always prints to stdout; also writes to file if enabled. +func logAuthAttempt(r *http.Request, username string, success bool, message string) { + entry := authLogEntry{ + Time: time.Now().UTC().Format(time.RFC3339), + RemoteIP: realIP(r), + Username: username, + Success: success, + Message: message, + } + b, _ := json.Marshal(entry) + line := string(b) + + // Console — always visible + fmt.Println(line) + + // File — if enabled + if authFileLog != nil { + authFileLog.Println(line) // log.Logger serialises concurrent writes + } +} + +// realIP returns the originating client IP, respecting common reverse-proxy +// headers in priority order: Cloudflare → X-Forwarded-For → X-Real-IP → RemoteAddr. +func realIP(r *http.Request) string { + // Cloudflare sets CF-Connecting-IP to the unmodified client IP. + if ip := r.Header.Get("CF-Connecting-IP"); ip != "" && net.ParseIP(ip) != nil { + return ip + } + + // X-Forwarded-For may be a comma-separated list; the leftmost entry is the + // originating client (rightmost entries are added by each successive proxy). + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if idx := strings.IndexByte(xff, ','); idx != -1 { + xff = xff[:idx] + } + xff = strings.TrimSpace(xff) + if net.ParseIP(xff) != nil { + return xff + } + } + + // Nginx / Traefik single-value header. + if ip := r.Header.Get("X-Real-IP"); ip != "" && net.ParseIP(ip) != nil { + return ip + } + + // Direct connection. + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/internals/server.go b/internals/server.go index 07c2f9e..89aa285 100644 --- a/internals/server.go +++ b/internals/server.go @@ -20,6 +20,7 @@ func Run() { certFlag := flag.String("cert", "", "set custom TLS certificate PEM file (stored encrypted)") cetkeyFlag := flag.String("certkey", "", "set custom TLS private key PEM file") certreset := flag.Bool("certreset", false, "remove stored custom certificate, revert to self-signed") + logFlag := flag.String("log", "", "auth log file path; 'off' disables file logging (default: gotermix.log next to binary)") flag.Parse() initialCwd, _ = os.Getwd() @@ -100,6 +101,13 @@ func Run() { fmt.Printf("auth: enabled user=%q creds=%s\n", appCreds.Username, credsPath) } + // Auth logging — default path is gotermix.log next to the binary. + logPath := *logFlag + if logPath == "" { + logPath = filepath.Join(filepath.Dir(exe), "gotermix.log") + } + initAuthLogger(logPath) + // Reap idle sessions. go func() { t := time.NewTicker(10 * time.Minute) @@ -137,6 +145,7 @@ func Run() { mux := http.NewServeMux() mux.HandleFunc("/", handleIndex) + mux.HandleFunc("/login", handleLogin) mux.HandleFunc("/s/", handleShell) mux.HandleFunc("/ws/", handleWS) mux.HandleFunc("/auth", handleAuth) diff --git a/internals/web/login.html b/internals/web/login.html new file mode 100644 index 0000000..1c2dc11 --- /dev/null +++ b/internals/web/login.html @@ -0,0 +1,95 @@ + + + + + + GoTermix — Sign in + + + + + + +
+
+ +
Authentication required
+ + + + + + + +
+ + +
+
+ + + +