diff --git a/README.md b/README.md index 03b98e9..2b55d57 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ Change with `-setlogin` before first use. | `-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) | +| `-mfa on` | — | Enable TOTP MFA for user — prints secret + QR code | +| `-mfa off` | — | Disable TOTP MFA for user | --- @@ -97,6 +99,29 @@ Structured JSON-lines, one entry per login attempt: - Compatible with CrowdSec and fail2ban custom parsers - Console output always on; file output controlled by `-log` +## Run as service +- `gotermix.service` is pretty limitted, you can change settings there to suit your needs +```bash +# 1. Create unprivileged system user (no shell, no home) +useradd --system --no-create-home --shell /sbin/nologin gotermix + +# 2. Deploy binary and set ownership +mkdir -p /opt/gotermix +cp gotermix /opt/gotermix/ +chown -R gotermix:gotermix /opt/gotermix +chmod 750 /opt/gotermix +chmod 750 /opt/gotermix/gotermix + +# 3. Install and enable service +cp gotermix.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now gotermix + +# 4. Check it's up +systemctl status gotermix +journalctl -u gotermix -f +``` + --- ## Keyboard shortcuts diff --git a/go.mod b/go.mod index 9c61689..d3333a9 100644 --- a/go.mod +++ b/go.mod @@ -6,3 +6,5 @@ require ( github.com/creack/pty v1.1.24 github.com/gorilla/websocket v1.5.3 ) + +require github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect diff --git a/go.sum b/go.sum index a15fd45..65e5031 100644 --- a/go.sum +++ b/go.sum @@ -2,3 +2,5 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= diff --git a/gotermix-limitted.service b/gotermix-limitted.service new file mode 100644 index 0000000..05d5736 --- /dev/null +++ b/gotermix-limitted.service @@ -0,0 +1,65 @@ +[Unit] +Description=GoTermix — web terminal +Documentation=https://ghb.freebede.com/nahakubuilder/gotermix +After=network.target +Wants=network.target + +[Service] +Type=simple + +# Run as a dedicated unprivileged user. +# Create it first: +# useradd --system --no-create-home --shell /sbin/nologin gotermix +User=gotermix +Group=gotermix + +# Working directory — binary, gws-creds.json and gotermix.log live here. +WorkingDirectory=/opt/gotermix + +# Absolute path to the binary. +ExecStart=/opt/gotermix/gotermix -addr 0.0.0.0:5000 + +# Restart policy +Restart=on-failure +RestartSec=5s + +# ── Hardening ──────────────────────────────────────────────────────── +# No new privileges beyond what the service user already has. +NoNewPrivileges=true + +# Private /tmp — isolates temp files from other services. +PrivateTmp=true + +# Read-only access to the real /usr, /boot, /etc. +ProtectSystem=strict + +# Allow the service to write its own data directory. +ReadWritePaths=/opt/gotermix + +# Hide /home and /root from the process. +ProtectHome=true + +# Prevent loading kernel modules. +ProtectKernelModules=true + +# Prevent writing to kernel tunables. +ProtectKernelTunables=true + +# Prevent altering control groups. +ProtectControlGroups=true + +# Allow only necessary syscall groups. +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM + +# Restrict address families to IPv4/IPv6 (needed for HTTP listener + WebSocket). +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX + +# Deny ptrace and other debugging interfaces. +RestrictRealtime=true +LockPersonality=true +MemoryDenyWriteExecute=false +# Note: MemoryDenyWriteExecute left off — Go runtime needs JIT-style writes. + +[Install] +WantedBy=multi-user.target diff --git a/gotermix.service b/gotermix.service new file mode 100644 index 0000000..93b51ac --- /dev/null +++ b/gotermix.service @@ -0,0 +1,43 @@ +[Unit] +Description=GoTermix — web terminal +Documentation=https://ghb.freebede.com/nahakubuilder/gotermix +After=network.target +Wants=network.target + +[Service] +Type=simple + +# ── User ───────────────────────────────────────────────────────────── +# Option A (default): run as your own user so the terminal inherits +# your permissions, sudo rights, and home directory. +# Replace "youruser" with the actual username. +User=youruser +Group=youruser + +# Option B: dedicated unprivileged system user (no sudo inside terminal). +# Create first: useradd --system --no-create-home --shell /sbin/nologin gotermix +# Then swap the User/Group lines above and remove NoNewPrivileges below. + +# Working directory — binary, gws-creds.json and gotermix.log live here. +WorkingDirectory=/opt/gotermix + +# Absolute path to the binary. +ExecStart=/opt/gotermix/gotermix -addr 0.0.0.0:5000 + +# Restart policy +Restart=on-failure +RestartSec=5s + +# Kernel hardening — safe for both options. +ProtectKernelModules=true +ProtectKernelTunables=true +ProtectControlGroups=true + +# Restrict address families to IPv4/IPv6/Unix sockets. +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX + +RestrictRealtime=true +LockPersonality=true + +[Install] +WantedBy=multi-user.target diff --git a/internals/config.go b/internals/config.go index f107d57..61bd297 100644 --- a/internals/config.go +++ b/internals/config.go @@ -28,6 +28,8 @@ type storedCreds struct { Username string `json:"username"` Salt string `json:"salt"` Hash string `json:"hash"` + MFASecret string `json:"mfa_secret,omitempty"` + MFAEnabled bool `json:"mfa_enabled,omitempty"` CertFile string `json:"cert_file,omitempty"` KeyFile string `json:"key_file,omitempty"` Workspaces map[string]*WorkspaceLayout `json:"workspaces,omitempty"` diff --git a/internals/handlers.go b/internals/handlers.go index f3f8fcc..d74230c 100644 --- a/internals/handlers.go +++ b/internals/handlers.go @@ -158,8 +158,9 @@ func handleAuth(w http.ResponseWriter, r *http.Request) { return } - username := strings.TrimSpace(r.FormValue("username")) - password := r.FormValue("password") + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + totpCode := strings.TrimSpace(r.FormValue("totp_code")) // Input bounds — reject obviously bad values before touching the hasher if len(username) == 0 || len(username) > 64 || len(password) == 0 || len(password) > 1024 { @@ -170,24 +171,43 @@ func handleAuth(w http.ResponseWriter, r *http.Request) { return } - if checkCreds(username, password) { - http.SetCookie(w, &http.Cookie{ - Name: authCookieName, - Value: makeAuthToken(), - Path: "/", - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteLaxMode, - MaxAge: int(authTokenTTL.Seconds()), - }) - logAuthAttempt(r, username, true, "login_success") - w.Write([]byte(`{"ok":true}`)) //nolint:errcheck - } else { - time.Sleep(500 * time.Millisecond) // blunt brute-force deterrent + // Step 1: verify password + if !checkCreds(username, password) { + time.Sleep(500 * time.Millisecond) logAuthAttempt(r, username, false, "invalid_credentials") w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{"error":"Invalid username or password"}`)) //nolint:errcheck + return } + + // Step 2: MFA (if enabled) + if appCreds.MFAEnabled && appCreds.MFASecret != "" { + if totpCode == "" { + // Signal to frontend: show TOTP input + w.Write([]byte(`{"mfa_required":true}`)) //nolint:errcheck + return + } + if !validateTOTP(appCreds.MFASecret, totpCode) { + time.Sleep(500 * time.Millisecond) + logAuthAttempt(r, username, false, "invalid_totp") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"Invalid authenticator code"}`)) //nolint:errcheck + return + } + } + + // All checks passed — issue auth cookie + http.SetCookie(w, &http.Cookie{ + Name: authCookieName, + Value: makeAuthToken(), + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(authTokenTTL.Seconds()), + }) + logAuthAttempt(r, username, true, "login_success") + w.Write([]byte(`{"ok":true}`)) //nolint:errcheck } func handleWS(w http.ResponseWriter, r *http.Request) { diff --git a/internals/server.go b/internals/server.go index 89aa285..08ffad6 100644 --- a/internals/server.go +++ b/internals/server.go @@ -9,7 +9,10 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" + + qrcode "github.com/skip2/go-qrcode" ) // Run is the application entry point, called from main(). @@ -21,6 +24,7 @@ func Run() { 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)") + mfaFlag := flag.String("mfa", "", "manage MFA for a user: -mfa on|off") flag.Parse() initialCwd, _ = os.Getwd() @@ -91,6 +95,52 @@ func Run() { os.Exit(0) } + // ── -mfa on|off ──────────────────────────────────────── + if *mfaFlag != "" { + args := flag.Args() + if len(args) < 1 || (args[0] != "on" && args[0] != "off") { + fmt.Fprintln(os.Stderr, "usage: -mfa on|off") + os.Exit(1) + } + c := loadCreds() + if !strings.EqualFold(c.Username, *mfaFlag) { + fmt.Fprintf(os.Stderr, "unknown user %q (current user is %q)\n", *mfaFlag, c.Username) + os.Exit(1) + } + if args[0] == "off" { + c.MFAEnabled = false + c.MFASecret = "" + if err := saveCreds(c); err != nil { + fmt.Fprintf(os.Stderr, "error saving credentials: %v\n", err) + os.Exit(1) + } + fmt.Printf("MFA disabled for user %q\n", c.Username) + os.Exit(0) + } + // on: generate new secret + secret := newTOTPSecret() + c.MFASecret = secret + c.MFAEnabled = true + if err := saveCreds(c); err != nil { + fmt.Fprintf(os.Stderr, "error saving credentials: %v\n", err) + os.Exit(1) + } + otpauthURL := totpOtpauthURL(secret, c.Username, "GoTermix") + fmt.Printf("MFA enabled for user %q\n\n", c.Username) + fmt.Printf("Secret (manual entry): %s\n\n", secret) + fmt.Printf("otpauth URL: %s\n\n", otpauthURL) + fmt.Println("Scan with your authenticator app (Google Authenticator, Aegis, Authy ...):") + fmt.Println() + qr, err := qrcode.New(otpauthURL, qrcode.Medium) + if err != nil { + fmt.Fprintf(os.Stderr, "QR error: %v\n", err) + } else { + fmt.Println(qr.ToSmallString(false)) + } + fmt.Println("If the QR code does not render, enter the secret manually in your app.") + os.Exit(0) + } + nopwMode = *nopw appCreds = loadCreds() initAuthSecret() diff --git a/internals/totp.go b/internals/totp.go new file mode 100644 index 0000000..de02b4b --- /dev/null +++ b/internals/totp.go @@ -0,0 +1,93 @@ +package internals + +// TOTP implementation per RFC 6238 (TOTP) and RFC 4226 (HOTP). +// No external dependencies — uses only crypto/hmac, crypto/sha1, encoding/base32. + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "encoding/base32" + "encoding/binary" + "fmt" + "math" + "net/url" + "strings" + "time" +) + +const ( + totpDigits = 6 + totpStep = 30 // seconds per time window + totpWindow = 1 // ±1 step tolerance for clock drift (~30 s either side) +) + +// newTOTPSecret generates a random 160-bit (20-byte) base32 secret. +// Compatible with Google Authenticator, Aegis, Authy, etc. +func newTOTPSecret() string { + b := make([]byte, 20) + rand.Read(b) //nolint:errcheck + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b) +} + +// decodeTOTPSecret decodes a base32 secret; case-insensitive, padding optional. +func decodeTOTPSecret(secret string) ([]byte, error) { + s := strings.ToUpper(strings.TrimSpace(secret)) + if pad := len(s) % 8; pad != 0 { + s += strings.Repeat("=", 8-pad) + } + return base32.StdEncoding.DecodeString(s) +} + +// hotpAt computes one HOTP value for the given key and counter (RFC 4226 §5). +func hotpAt(key []byte, counter uint64) string { + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, counter) + mac := hmac.New(sha1.New, key) + mac.Write(buf) //nolint:errcheck + h := mac.Sum(nil) + // Dynamic truncation + offset := h[len(h)-1] & 0x0f + code := (uint32(h[offset])&0x7f)<<24 | + uint32(h[offset+1])<<16 | + uint32(h[offset+2])<<8 | + uint32(h[offset+3]) + code %= uint32(math.Pow10(totpDigits)) + return fmt.Sprintf("%0*d", totpDigits, code) +} + +// validateTOTP returns true if code matches the current window ± totpWindow steps. +func validateTOTP(secret, code string) bool { + if len(code) != totpDigits { + return false + } + for _, c := range code { + if c < '0' || c > '9' { + return false + } + } + key, err := decodeTOTPSecret(secret) + if err != nil { + return false + } + step := uint64(time.Now().Unix()) / totpStep + for d := -totpWindow; d <= totpWindow; d++ { + if hotpAt(key, uint64(int64(step)+int64(d))) == code { + return true + } + } + return false +} + +// totpOtpauthURL returns the otpauth:// URI used to provision authenticator apps. +func totpOtpauthURL(secret, username, issuer string) string { + return fmt.Sprintf( + "otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d", + url.PathEscape(issuer), + url.PathEscape(username), + secret, + url.QueryEscape(issuer), + totpDigits, + totpStep, + ) +} diff --git a/internals/web/login.html b/internals/web/login.html index 1c2dc11..3f0bb50 100644 --- a/internals/web/login.html +++ b/internals/web/login.html @@ -7,8 +7,11 @@ @@ -16,19 +19,37 @@
-
Authentication required
- - + +
+
Authentication required
- - + + + + + +
+ + +
@@ -43,27 +64,57 @@ const CSRF_TOKEN = "[[CSRF_TOKEN]]"; const NEXT = "[[NEXT]]"; +let mfaRequired = false; +let savedUsername = ''; +let savedPassword = ''; + +// ── Keyboard nav ───────────────────────────────────────────────────── document.getElementById('fUser').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('fPass').focus(); }); document.getElementById('fPass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); }); +document.getElementById('fTOTP').addEventListener('keydown', e => { + if (e.key === 'Enter') doLogin(); +}); +// Auto-submit when 6 digits entered +document.getElementById('fTOTP').addEventListener('input', e => { + const v = e.target.value.replace(/\D/g, ''); + e.target.value = v; + if (v.length === 6) doLogin(); +}); +// ── Login flow ──────────────────────────────────────────────────────── async function doLogin() { + const btn = document.getElementById('authBtn'); + document.getElementById('authErr').classList.remove('show'); + + if (mfaRequired) { + const code = document.getElementById('fTOTP').value.trim(); + if (code.length !== 6) { showErr('Enter 6-digit code'); return; } + await submitAuth(savedUsername, savedPassword, code); + return; + } + const username = document.getElementById('fUser').value.trim(); const password = document.getElementById('fPass').value; - const btn = document.getElementById('authBtn'); - if (!username || !password) { showErr('Enter username and password'); return; } + savedUsername = username; + savedPassword = password; + await submitAuth(username, password, ''); +} + +async function submitAuth(username, password, totpCode) { + const btn = document.getElementById('authBtn'); btn.disabled = true; btn.classList.add('busy'); - document.getElementById('authErr').classList.remove('show'); const form = new URLSearchParams(); form.append('username', username); form.append('password', password); form.append('csrf_token', CSRF_TOKEN); + if (totpCode) form.append('totp_code', totpCode); try { const res = await fetch('/auth', { @@ -74,8 +125,14 @@ async function doLogin() { const data = await res.json(); if (data.ok) { window.location.href = NEXT || '/'; + } else if (data.mfa_required) { + showTOTPStep(); } else { showErr(data.error || 'Authentication failed'); + if (mfaRequired) { + document.getElementById('fTOTP').value = ''; + document.getElementById('fTOTP').focus(); + } } } catch (_) { showErr('Network error — try again'); @@ -84,11 +141,17 @@ async function doLogin() { } } +function showTOTPStep() { + mfaRequired = true; + document.getElementById('credSection').style.display = 'none'; + document.getElementById('totpSection').style.display = 'block'; + document.getElementById('authBtn').querySelector('.btn-text').textContent = 'Verify'; + setTimeout(() => document.getElementById('fTOTP').focus(), 60); +} + function showErr(msg) { const e = document.getElementById('authErr'); e.textContent = msg; e.classList.add('show'); - document.getElementById('fPass').value = ''; - document.getElementById('fPass').focus(); }