diff --git a/.gitignore b/.gitignore index 833d376..a0c96da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ test/ upload/ gotermix +*.json +*.key \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9dc26c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Build +go build . + +# Build with injected encryption key (production) +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}" . + +# Run (dev) +./gotermix +./gotermix -addr 0.0.0.0:8443 -nopw + +# Change credentials (app must restart to pick up) +./gotermix -setlogin newuser newpassword + +# Store custom TLS cert (validates before storing, exits after) +./gotermix -cert /etc/ssl/my.crt -certkey /etc/ssl/my.key +./gotermix -certreset + +# Tests — none exist yet +go vet ./... +``` + +## Architecture + +Entry point: `main.go` (5 lines). All logic in `internals/` package. Web assets in `internals/web/` (embedded via `//go:embed`). + +### Session model +`/` always creates a new PTY-backed shell session (hex ID 32 chars). `/s/` reconnects to existing session. Multiple browser tabs can share one session — all see the same PTY output via broadcast. The session ID is embedded in the served HTML via `strings.NewReplacer` replacing `[[SESSION_ID]]` and `[[AUTHED]]` literals in the `shellPageHTML` const. Sessions are reaped after 24h idle (10-min ticker). Rolling 1MB replay buffer lets new tab connections catch up on history. + +### Transport +HTTPS only. Self-signed cert auto-generated in memory on startup unless custom cert paths are stored (encrypted) in `gws-creds.json`. TLS handshake errors from browsers rejecting self-signed certs are suppressed via `tlsHandshakeFilter`. WebSocket (`/ws/`) carries raw PTY bytes as binary frames; resize events as JSON text frames. + +### Auth flow +1. Browser POSTs credentials to `/auth` +2. Server checks against `storedCreds` (SHA-256 × 50,000 rounds + salt, constant-time compare) +3. On success: sets `gws_auth` cookie (HttpOnly, Secure, SameSite=Lax, 12h) containing HMAC-SHA256 timestamp token +4. All subsequent routes (`/ws/`, `/upload`, `/download`) call `isAuthed()` which validates the token +5. `-nopw` flag bypasses all auth checks (`nopwMode = true`) + +### Credential & key storage +- `gws-creds.json` (next to binary): AES-256-GCM encrypted JSON with username, salt, hash, optional cert paths +- Encryption key priority: (1) build-time `-ldflags "-X gotermix/internals.fileEncKeyHex=..."`, (2) `gws.key` file next to binary, (3) auto-generated and written to `gws.key` +- Default creds if file missing or unreadable: user `ivor` / `Silv3rSw0rd!` + +### File transfer +- **Upload**: browser POSTs multipart to `/upload` with `sid` field. Server writes to OS temp file, then injects `mv ` directly into the PTY shell — file lands with the shell's effective user permissions. +- **Download**: GET `/download?path=...` → `http.ServeFile` after `filepath.Clean`. Absolute paths used directly; relative paths anchored to `initialCwd` (cwd at startup). + +### Frontend +UI lives in `internals/web/shell.html` (~670 lines, embedded at build time). Favicon in `internals/web/favicon.svg`. Uses xterm.js + FitAddon from CDN. Keyboard handling: capture-phase listener blocks all Ctrl+key browser shortcuts; xterm's `attachCustomKeyEventHandler` handles Ctrl+Shift+C (copy), Ctrl+V (paste via Clipboard API). `[[SESSION_ID]]` and `[[AUTHED]]` placeholders replaced by `serveTerminalPage` via `strings.NewReplacer`. + +### External dependencies +- `github.com/creack/pty` — PTY allocation and resize (`pty.Start`, `pty.Setsize`) +- `github.com/gorilla/websocket` — WebSocket upgrader and framing + +### Routes +| Path | Handler | +|------|---------| +| `/` | New session, serve terminal page | +| `/s/<32-hex-id>` | Reconnect to specific session | +| `/ws/<32-hex-id>` | WebSocket: PTY I/O + resize | +| `/auth` | POST: credential check, set cookie | +| `/upload` | POST multipart: inject mv into PTY | +| `/download` | GET `?path=`: ServeFile | +| `/favicon.svg` | Inline SVG terminal icon | diff --git a/internals/auth.go b/internals/auth.go new file mode 100644 index 0000000..dad4908 --- /dev/null +++ b/internals/auth.go @@ -0,0 +1,56 @@ +package internals + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "strings" + "time" +) + +func checkCreds(username, password string) bool { + if username != appCreds.Username { + return false + } + got := hashPassword(password, appCreds.Salt) + return hmac.Equal([]byte(got), []byte(appCreds.Hash)) +} + +func initAuthSecret() { + authSecret = make([]byte, 32) + rand.Read(authSecret) +} + +func makeAuthToken() string { + ts := fmt.Sprintf("%d", time.Now().Unix()) + mac := hmac.New(sha256.New, authSecret) + mac.Write([]byte(ts)) + return ts + "." + hex.EncodeToString(mac.Sum(nil)) +} + +func validAuthToken(token string) bool { + dot := strings.LastIndex(token, ".") + if dot < 0 { + return false + } + ts, sig := token[:dot], token[dot+1:] + mac := hmac.New(sha256.New, authSecret) + mac.Write([]byte(ts)) + if !hmac.Equal([]byte(sig), []byte(hex.EncodeToString(mac.Sum(nil)))) { + return false + } + var t int64 + fmt.Sscanf(ts, "%d", &t) + return time.Since(time.Unix(t, 0)) < authTokenTTL +} + +func isAuthed(r *http.Request) bool { + if nopwMode { + return true + } + c, err := r.Cookie(authCookieName) + return err == nil && validAuthToken(c.Value) +} diff --git a/internals/config.go b/internals/config.go new file mode 100644 index 0000000..81207bf --- /dev/null +++ b/internals/config.go @@ -0,0 +1,63 @@ +package internals + +import ( + "os" + "os/exec" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + maxBufSize = 1 << 20 + maxUploadSize = 512 << 20 + sessionTTL = 24 * time.Hour + authCookieName = "gws_auth" + authTokenTTL = 12 * time.Hour + credsFilename = "gws-creds.json" + defaultUser = "ivor" + defaultPass = "Silv3rSw0rd!" +) + +// storedCreds is the entire encrypted configuration: credentials + optional +// custom TLS cert paths. The password is salted + iterated-SHA256 hashed +// (never stored plaintext); the whole struct is AES-256-GCM encrypted on disk. +type storedCreds struct { + Username string `json:"username"` + Salt string `json:"salt"` + Hash string `json:"hash"` + CertFile string `json:"cert_file,omitempty"` + KeyFile string `json:"key_file,omitempty"` +} + +type client struct { + conn *websocket.Conn + mu sync.Mutex +} + +func (c *client) write(mt int, data []byte) { + c.mu.Lock() + defer c.mu.Unlock() + c.conn.WriteMessage(mt, data) //nolint:errcheck +} + +// Session holds one persistent PTY process and all connected browser tabs. +type Session struct { + mu sync.Mutex + id string + ptty *os.File + cmd *exec.Cmd + buf []byte + clients map[*client]struct{} + done chan struct{} + lastSeen time.Time +} + +var ( + initialCwd string + nopwMode bool + appCreds storedCreds + authSecret []byte + credsPath string +) diff --git a/internals/creds.go b/internals/creds.go new file mode 100644 index 0000000..bffee0d --- /dev/null +++ b/internals/creds.go @@ -0,0 +1,139 @@ +package internals + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// fileEncKeyHex is injected at build time via: +// +// go build -ldflags "-X gotermix/internals.fileEncKeyHex=$(openssl rand -hex 32)" . +// go build -ldflags "-X gotermix/internals.fileEncKeyHex=${GOTERMINAL_ENC}" . +// +// If empty (dev builds without -ldflags), a random key is generated on first +// run and stored in gws.key next to the binary so the creds file stays +// readable across restarts without being hardcoded anywhere. +var fileEncKeyHex string + +func keyFilePath() string { + return filepath.Join(filepath.Dir(credsPath), "gws.key") +} + +// fileKey returns the AES-256 key used to encrypt/decrypt the credentials file. +// +// Priority order: +// 1. Build-time injected key (fileEncKeyHex set via -ldflags) +// 2. Persisted per-install key stored in gws.key next to the binary +// 3. Newly generated random key (written to gws.key for future runs) +func fileKey() []byte { + if len(fileEncKeyHex) == 64 { + if b, err := hex.DecodeString(fileEncKeyHex); err == nil { + return b + } + } + kp := keyFilePath() + if data, err := os.ReadFile(kp); err == nil { + if b, err := hex.DecodeString(strings.TrimSpace(string(data))); err == nil && len(b) == 32 { + return b + } + } + key := make([]byte, 32) + rand.Read(key) + os.WriteFile(kp, []byte(hex.EncodeToString(key)), 0600) //nolint:errcheck + return key +} + +func encryptJSON(v any) ([]byte, error) { + plain, err := json.Marshal(v) + if err != nil { + return nil, err + } + block, err := aes.NewCipher(fileKey()) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + return gcm.Seal(nonce, nonce, plain, nil), nil +} + +func decryptJSON(data []byte, v any) error { + block, err := aes.NewCipher(fileKey()) + if err != nil { + return err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return err + } + if len(data) < gcm.NonceSize() { + return fmt.Errorf("data too short") + } + plain, err := gcm.Open(nil, data[:gcm.NonceSize()], data[gcm.NonceSize():], nil) + if err != nil { + return err + } + return json.Unmarshal(plain, v) +} + +func newSalt() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +// hashPassword runs 50 000 rounds of SHA-256 so brute-forcing a stolen file is slow. +func hashPassword(password, salt string) string { + saltBytes, _ := hex.DecodeString(salt) + h := sha256.New() + h.Write(saltBytes) + h.Write([]byte(password)) + result := h.Sum(nil) + for i := 0; i < 50000; i++ { + h.Reset() + h.Write(result) + h.Write(saltBytes) + result = h.Sum(nil) + } + return hex.EncodeToString(result) +} + +func defaultCreds() storedCreds { + salt := newSalt() + return storedCreds{Username: defaultUser, Salt: salt, Hash: hashPassword(defaultPass, salt)} +} + +func loadCreds() storedCreds { + data, err := os.ReadFile(credsPath) + if err != nil { + return defaultCreds() + } + var c storedCreds + if err := decryptJSON(data, &c); err != nil { + fmt.Fprintln(os.Stderr, "warning: credentials file unreadable — using defaults") + return defaultCreds() + } + return c +} + +func saveCreds(c storedCreds) error { + data, err := encryptJSON(c) + if err != nil { + return err + } + return os.WriteFile(credsPath, data, 0600) +} diff --git a/internals/handlers.go b/internals/handlers.go new file mode 100644 index 0000000..c54b925 --- /dev/null +++ b/internals/handlers.go @@ -0,0 +1,282 @@ +package internals + +import ( + "embed" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/creack/pty" + "github.com/gorilla/websocket" +) + +//go:embed web/shell.html +var shellPageHTML string + +//go:embed web/favicon.svg +var faviconSVG string + +//go:embed web/*.css web/*.js +var staticAssets embed.FS + +var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + +// handleFavicon serves a terminal-themed SVG favicon. +func handleFavicon(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "public, max-age=86400") + w.Write([]byte(faviconSVG)) +} + +// handleStaticCSS serves the embedded app.css stylesheet. +func handleStaticCSS(w http.ResponseWriter, r *http.Request) { + data, err := staticAssets.ReadFile("web/app.css") + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/css; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write(data) +} + +// handleStaticJS serves the embedded app.js script. +func handleStaticJS(w http.ResponseWriter, r *http.Request) { + data, err := staticAssets.ReadFile("web/app.js") + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write(data) +} + +// handleIndex: clean URL, always creates a fresh session. +// The session-specific /s/ URL is available in the toolbar for sharing. +func handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + id := randHex(16) + getOrCreate(id) + serveTerminalPage(w, id, isAuthed(r)) +} + +// handleShell: reconnects to a specific session by ID. +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)) +} + +func serveTerminalPage(w http.ResponseWriter, id string, authed bool) { + authedStr := "false" + if authed { + authedStr = "true" + } + html := strings.NewReplacer( + "[[SESSION_ID]]", id, + "[[AUTHED]]", authedStr, + ).Replace(shellPageHTML) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(html)) +} + +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"}`)) + return + } + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"bad form"}`)) + return + } + if checkCreds(strings.TrimSpace(r.FormValue("username")), r.FormValue("password")) { + http.SetCookie(w, &http.Cookie{ + Name: authCookieName, + Value: makeAuthToken(), + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(authTokenTTL.Seconds()), + }) + w.Write([]byte(`{"ok":true}`)) + } else { + time.Sleep(500 * time.Millisecond) // blunt brute-force deterrent + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"Invalid username or password"}`)) + } +} + +func handleWS(w http.ResponseWriter, r *http.Request) { + if !isAuthed(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + id := strings.TrimPrefix(r.URL.Path, "/ws/") + if !validID(id) { + http.Error(w, "invalid session id", http.StatusBadRequest) + return + } + s := getOrCreate(id) + if s == nil { + http.Error(w, "failed to start shell", http.StatusInternalServerError) + return + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + cl := &client{conn: conn} + + s.mu.Lock() + s.clients[cl] = struct{}{} + s.lastSeen = time.Now() + isNew := len(s.buf) == 0 + replay := make([]byte, len(s.buf)) + copy(replay, s.buf) + s.mu.Unlock() + + if len(replay) > 0 { + cl.write(websocket.BinaryMessage, replay) + } + cl.write(websocket.TextMessage, []byte( + fmt.Sprintf(`{"type":"session","id":"%s","new":%v}`, id[:8], isNew), + )) + + defer func() { + s.mu.Lock() + delete(s.clients, cl) + s.mu.Unlock() + conn.Close() + }() + + for { + mt, data, err := conn.ReadMessage() + if err != nil { + break + } + s.mu.Lock() + s.lastSeen = time.Now() + s.mu.Unlock() + + if mt == websocket.TextMessage { + var msg struct { + Type string `json:"type"` + Cols int `json:"cols"` + Rows int `json:"rows"` + } + if json.Unmarshal(data, &msg) == nil && msg.Type == "resize" { + pty.Setsize(s.ptty, &pty.Winsize{Cols: uint16(msg.Cols), Rows: uint16(msg.Rows)}) + continue + } + } + s.ptty.Write(data) //nolint:errcheck + } +} + +func handleUpload(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if !isAuthed(r) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"unauthorized"}`)) + return + } + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write([]byte(`{"error":"POST only"}`)) + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) + if err := r.ParseMultipartForm(32 << 20); err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{"error":"parse error: %s"}`, err.Error()) + return + } + id := r.FormValue("sid") + if !validID(id) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"missing or invalid session id"}`)) + return + } + s := sessionByID(id) + if s == nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"session not found — reopen the terminal"}`)) + return + } + file, header, err := r.FormFile("file") + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"missing file field"}`)) + return + } + defer file.Close() + + tmp, err := os.CreateTemp("", "gws-upload-*") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"temp file error"}`)) + return + } + tmpName := tmp.Name() + if _, err := io.Copy(tmp, file); err != nil { + tmp.Close() + os.Remove(tmpName) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"write error"}`)) + return + } + tmp.Close() + + destDir := strings.TrimSpace(r.FormValue("dest")) + if destDir == "" { + destDir = initialCwd + } + destPath := filepath.Join(filepath.Clean(destDir), filepath.Base(header.Filename)) + + // Inject mv into the shell — inherits the shell's effective user (e.g. root after sudo su). + mvCmd := fmt.Sprintf("\nmv %s %s && echo 'Uploaded: %s'\n", + shellQuote(tmpName), shellQuote(destPath), filepath.Base(header.Filename)) + s.mu.Lock() + s.ptty.Write([]byte(mvCmd)) //nolint:errcheck + s.mu.Unlock() + + fmt.Fprintf(w, `{"ok":true,"dest":%q}`, destPath) +} + +func handleDownload(w http.ResponseWriter, r *http.Request) { + if !isAuthed(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + path := r.URL.Query().Get("path") + if path == "" { + http.Error(w, "missing ?path=", http.StatusBadRequest) + return + } + full := filepath.Clean(path) + if !filepath.IsAbs(full) { + full = filepath.Join(initialCwd, full) + } + if _, err := os.Stat(full); err != nil { + http.Error(w, "file not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Disposition", `attachment; filename="`+filepath.Base(full)+`"`) + http.ServeFile(w, r, full) +} diff --git a/internals/server.go b/internals/server.go new file mode 100644 index 0000000..eacb405 --- /dev/null +++ b/internals/server.go @@ -0,0 +1,161 @@ +package internals + +import ( + "crypto/tls" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "path/filepath" + "time" +) + +// Run is the application entry point, called from main(). +func Run() { + addr := flag.String("addr", "127.0.0.1:5000", "listen address") + nopw := flag.Bool("nopw", false, "disable password authentication") + setlogin := flag.String("setlogin", "", "set login username (next arg is password)") + 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") + flag.Parse() + + initialCwd, _ = os.Getwd() + + // Credentials file lives next to the executable. + exe, err := os.Executable() + if err != nil { + exe = "." + } + credsPath = filepath.Join(filepath.Dir(exe), credsFilename) + + // ── -certreset: remove stored cert paths ───────────────────── + if *certreset { + c := loadCreds() + c.CertFile = "" + c.KeyFile = "" + if err := saveCreds(c); err != nil { + fmt.Fprintf(os.Stderr, "error saving config: %v\n", err) + os.Exit(1) + } + fmt.Println("custom certificate removed — will use self-signed on next start") + os.Exit(0) + } + + // ── -cert / -certkey: store custom cert paths (encrypted) ───── + if *certFlag != "" { + keyPath := *cetkeyFlag + if keyPath == "" { + keyPath = *certFlag // allow combined cert+key PEM + } + // Validate the files are readable and form a valid keypair before storing. + if _, err := tls.LoadX509KeyPair(*certFlag, keyPath); err != nil { + fmt.Fprintf(os.Stderr, "certificate error: %v\n", err) + os.Exit(1) + } + c := loadCreds() + c.CertFile = *certFlag + c.KeyFile = keyPath + if err := saveCreds(c); err != nil { + fmt.Fprintf(os.Stderr, "error saving config: %v\n", err) + os.Exit(1) + } + fmt.Println("custom certificate stored") + os.Exit(0) + } + + // ── -setlogin username password ──────────────────────────────── + if *setlogin != "" { + args := flag.Args() + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: -setlogin ") + os.Exit(1) + } + existing := loadCreds() // preserve cert paths across credential changes + salt := newSalt() + c := storedCreds{ + Username: *setlogin, + Salt: salt, + Hash: hashPassword(args[0], salt), + CertFile: existing.CertFile, + KeyFile: existing.KeyFile, + } + if err := saveCreds(c); err != nil { + fmt.Fprintf(os.Stderr, "error saving credentials: %v\n", err) + os.Exit(1) + } + fmt.Printf("credentials saved user=%q file=%s\n", *setlogin, credsPath) + os.Exit(0) + } + + nopwMode = *nopw + appCreds = loadCreds() + initAuthSecret() + + if nopwMode { + fmt.Println("auth: DISABLED (-nopw)") + } else { + fmt.Printf("auth: enabled user=%q creds=%s\n", appCreds.Username, credsPath) + } + + // Reap idle sessions. + go func() { + t := time.NewTicker(10 * time.Minute) + defer t.Stop() + for range t.C { + sessionsMu.Lock() + for id, s := range sessions { + s.mu.Lock() + idle := time.Since(s.lastSeen) + s.mu.Unlock() + if idle > sessionTTL { + s.ptty.Close() + delete(sessions, id) + } + } + sessionsMu.Unlock() + } + }() + + // Load TLS certificate — custom (stored encrypted) or auto-generated. + // The cert path is never printed to avoid leaking it in logs or terminal history. + var tlsCert tls.Certificate + if appCreds.CertFile != "" && appCreds.KeyFile != "" { + if cert, err := tls.LoadX509KeyPair(appCreds.CertFile, appCreds.KeyFile); err == nil { + tlsCert = cert + fmt.Println("TLS: custom certificate") + } else { + fmt.Fprintln(os.Stderr, "TLS: custom certificate failed to load, falling back to self-signed") + tlsCert, _ = generateSelfSignedCert() + } + } else { + tlsCert, _ = generateSelfSignedCert() + fmt.Println("TLS: self-signed (auto-generated)") + } + + mux := http.NewServeMux() + mux.HandleFunc("/", handleIndex) + mux.HandleFunc("/s/", handleShell) + mux.HandleFunc("/ws/", handleWS) + mux.HandleFunc("/auth", handleAuth) + mux.HandleFunc("/upload", handleUpload) + mux.HandleFunc("/download", handleDownload) + mux.HandleFunc("/favicon.svg", handleFavicon) + mux.HandleFunc("/static/app.css", handleStaticCSS) + mux.HandleFunc("/static/app.js", handleStaticJS) + + ln, _ := net.Listen("tcp", *addr) + fmt.Printf("Go Web Shell https://%s\n", *addr) + fmt.Println(" / new session (URL stays clean)") + fmt.Println(" /s/ reconnect to a specific session") + + // Suppress the noisy "TLS handshake error" lines that appear whenever a + // browser rejects the self-signed certificate during the initial TLS dance. + srv := &http.Server{ + Handler: mux, + ErrorLog: log.New(tlsHandshakeFilter{log.Writer()}, "", log.LstdFlags), + } + srv.Serve(tls.NewListener(ln, &tls.Config{Certificates: []tls.Certificate{tlsCert}})) //nolint:errcheck +} diff --git a/internals/session.go b/internals/session.go new file mode 100644 index 0000000..5d31df9 --- /dev/null +++ b/internals/session.go @@ -0,0 +1,128 @@ +package internals + +import ( + "crypto/rand" + "fmt" + "os/exec" + "runtime" + "strings" + "sync" + "time" + + "github.com/creack/pty" + "github.com/gorilla/websocket" +) + +var ( + sessions = map[string]*Session{} + sessionsMu sync.Mutex +) + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +func validID(id string) bool { + if len(id) != 32 { + return false + } + for _, c := range id { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + return false + } + } + return true +} + +func randHex(n int) string { + b := make([]byte, n) + rand.Read(b) + return fmt.Sprintf("%x", b) +} + +func getOrCreate(id string) *Session { + sessionsMu.Lock() + defer sessionsMu.Unlock() + + if s, ok := sessions[id]; ok { + select { + case <-s.done: + delete(sessions, id) + default: + return s + } + } + + s := &Session{ + id: id, + clients: make(map[*client]struct{}), + done: make(chan struct{}), + lastSeen: time.Now(), + } + + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd.exe") + } else { + cmd = exec.Command("/bin/bash", "-i") + } + cmd.Dir = initialCwd + + ptty, err := pty.Start(cmd) + if err != nil { + return nil + } + s.ptty = ptty + s.cmd = cmd + sessions[id] = s + go s.readLoop() + return s +} + +func sessionByID(id string) *Session { + sessionsMu.Lock() + defer sessionsMu.Unlock() + s, ok := sessions[id] + if !ok { + return nil + } + select { + case <-s.done: + return nil + default: + return s + } +} + +func (s *Session) readLoop() { + chunk := make([]byte, 16*1024) + for { + n, err := s.ptty.Read(chunk) + if n > 0 { + data := make([]byte, n) + copy(data, chunk[:n]) + + s.mu.Lock() + s.buf = append(s.buf, data...) + if len(s.buf) > maxBufSize { + s.buf = s.buf[len(s.buf)-maxBufSize:] + } + snapshot := make([]*client, 0, len(s.clients)) + for c := range s.clients { + snapshot = append(snapshot, c) + } + s.mu.Unlock() + + for _, c := range snapshot { + c.write(websocket.BinaryMessage, data) + } + } + if err != nil { + break + } + } + close(s.done) + sessionsMu.Lock() + delete(sessions, s.id) + sessionsMu.Unlock() +} diff --git a/internals/tls.go b/internals/tls.go new file mode 100644 index 0000000..6686429 --- /dev/null +++ b/internals/tls.go @@ -0,0 +1,42 @@ +package internals + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "io" + "math/big" + "net" + "strings" + "time" +) + +// tlsHandshakeFilter drops "TLS handshake error" log lines (expected noise from +// browsers rejecting self-signed certs) and forwards everything else unchanged. +type tlsHandshakeFilter struct{ w io.Writer } + +func (f tlsHandshakeFilter) Write(p []byte) (int, error) { + if strings.Contains(string(p), "TLS handshake error") { + return len(p), nil + } + return f.w.Write(p) +} + +func generateSelfSignedCert() (tls.Certificate, error) { + priv, _ := rsa.GenerateKey(rand.Reader, 2048) + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + tmpl := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{Organization: []string{"GoTermix"}, CommonName: "localhost"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + } + der, _ := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}, nil +} diff --git a/internals/web/app.css b/internals/web/app.css new file mode 100644 index 0000000..1047503 --- /dev/null +++ b/internals/web/app.css @@ -0,0 +1,235 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; background: #0d0f14; color: #e2e8f0; + font-family: 'JetBrains Mono','Fira Mono',monospace; overflow: hidden; } + +/* ── Tab bar ── */ +#tabBar { + position: fixed; top: 0; left: 0; right: 0; height: 36px; + background: #0a0c11; border-bottom: 1px solid rgba(255,255,255,0.06); + display: flex; align-items: center; + padding: 0 4px; z-index: 50; overflow: hidden; +} +/* tabList fills bar, scrolls horizontally when many tabs. + The "+" button lives at the end of this list. */ +#tabList { + display: flex; align-items: center; gap: 2px; + flex: 1; overflow-x: auto; overflow-y: hidden; height: 100%; + scrollbar-width: none; +} +#tabList::-webkit-scrollbar { display: none; } +.tab-item { + display: flex; align-items: center; gap: 4px; + padding: 0 8px 0 12px; height: 28px; border-radius: 5px; + background: transparent; color: #4b5563; + font-size: 11px; font-family: inherit; cursor: default; + white-space: nowrap; flex-shrink: 0; + border: 1px solid transparent; + transition: background 0.15s, color 0.15s; + user-select: none; +} +.tab-item:hover { background: rgba(255,255,255,0.05); color: #9ca3af; } +.tab-item.active { + background: #13161e; color: #e2e8f0; + border-color: rgba(255,255,255,0.08); +} +.tab-label { cursor: pointer; } +.tab-x { + width: 16px; height: 16px; border: none; border-radius: 3px; + background: transparent; color: inherit; cursor: pointer; + font-size: 14px; line-height: 1; padding: 0; + display: flex; align-items: center; justify-content: center; + opacity: 0.4; transition: opacity 0.15s, background 0.15s; +} +.tab-x:hover { opacity: 1; background: rgba(255,255,255,0.12); } +.tab-new { + height: 28px; width: 28px; border: none; border-radius: 5px; + background: transparent; color: #4b5563; + font-size: 20px; line-height: 1; cursor: pointer; flex-shrink: 0; + display: flex; align-items: center; justify-content: center; + transition: background 0.15s, color 0.15s; +} +.tab-new:hover { background: rgba(255,255,255,0.07); color: #6ee7b7; } + +/* ── Terminal container + panes ── */ +#termContainer { + position: fixed; top: 36px; left: 0; right: 0; bottom: 48px; +} +.term-pane { width: 100%; height: 100%; } + +/* ── Compact toolbar ── */ +.toolbar { + position: fixed; bottom: 0; left: 0; right: 0; height: 48px; + background: #13161e; border-top: 1px solid rgba(255,255,255,0.07); + display: flex; align-items: center; + justify-content: space-between; + padding: 0 10px; gap: 6px; z-index: 50; +} +.tb-left { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; } +.tb-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; } + +/* connection dot */ +.dot { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; + background: #f59e0b; box-shadow: 0 0 5px #f59e0b; transition: all 0.3s; +} +.dot.ok { background: #6ee7b7; box-shadow: 0 0 5px #6ee7b7; } +.dot.err { background: #ef4444; box-shadow: 0 0 5px #ef4444; } + +/* status label — truncates on narrow screens */ +.status-label { + font-size: 11px; color: #4b5563; white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; min-width: 0; +} + +/* toolbar icon buttons */ +.tb-btn { + height: 32px; padding: 0 10px; border: none; border-radius: 6px; + background: rgba(255,255,255,0.05); color: #9ca3af; + font-size: 11px; font-family: inherit; font-weight: 600; + cursor: pointer; display: flex; align-items: center; gap: 5px; + transition: background 0.15s, color 0.15s; white-space: nowrap; flex-shrink: 0; +} +.tb-btn:hover { background: rgba(255,255,255,0.1); color: #e2e8f0; } +.tb-btn svg { flex-shrink: 0; } +.tb-btn.accent { background: rgba(110,231,183,0.1); color: #6ee7b7; + border: 1px solid rgba(110,231,183,0.15); } +.tb-btn.accent:hover { background: rgba(110,231,183,0.18); } + +/* ── Toast ── */ +.toast { + position: fixed; bottom: 56px; left: 50%; + transform: translateX(-50%) translateY(6px); + opacity: 0; pointer-events: none; z-index: 400; + background: #1e2330; border: 1px solid rgba(255,255,255,0.1); + padding: 7px 16px; border-radius: 20px; font-size: 12px; + white-space: nowrap; transition: opacity 0.2s, transform 0.2s; +} +.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } +.toast.ok { border-color: rgba(110,231,183,0.35); color: #6ee7b7; } +.toast.err { border-color: rgba(239,68,68,0.35); color: #f87171; } + +/* ── Shared modal base ── */ +.m-overlay { + position: fixed; inset: 0; z-index: 200; + background: rgba(5,7,11,0.88); backdrop-filter: blur(6px); + display: flex; align-items: center; justify-content: center; + padding: 16px; +} +.m-overlay.hidden { display: none; } + +.m-card { + width: 100%; max-width: 380px; + background: #13161e; border-radius: 14px; + border: 1px solid rgba(255,255,255,0.08); + box-shadow: 0 32px 80px rgba(0,0,0,0.7); + overflow: hidden; +} +.m-head { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 20px; border-bottom: 1px solid rgba(255,255,255,0.06); +} +.m-title { + font-size: 13px; font-weight: 600; color: #e2e8f0; + display: flex; align-items: center; gap: 8px; +} +.m-title svg { color: #6ee7b7; } +.m-x { + width: 28px; height: 28px; border: none; border-radius: 6px; + background: transparent; color: #6b7280; cursor: pointer; font-size: 16px; + display: flex; align-items: center; justify-content: center; + transition: background 0.15s, color 0.15s; +} +.m-x:hover { background: rgba(255,255,255,0.07); color: #e2e8f0; } + +.m-body { padding: 20px; } + +.m-label { + display: block; font-size: 11px; color: #6b7280; + text-transform: uppercase; letter-spacing: 0.07em; margin-bottom: 6px; +} +.m-input { + width: 100%; padding: 10px 12px; + background: #0d0f14; border: 1px solid rgba(255,255,255,0.08); + border-radius: 7px; color: #e2e8f0; font-size: 13px; + font-family: inherit; outline: none; margin-bottom: 14px; + transition: border-color 0.15s, box-shadow 0.15s; +} +.m-input:focus { + border-color: rgba(110,231,183,0.45); + box-shadow: 0 0 0 3px rgba(110,231,183,0.07); +} +.m-input::placeholder { color: #374151; } + +/* file drop zone */ +.file-zone { + border: 1px dashed rgba(255,255,255,0.12); border-radius: 7px; + padding: 18px 16px; text-align: center; cursor: pointer; + margin-bottom: 14px; transition: border-color 0.15s, background 0.15s; +} +.file-zone:hover { border-color: rgba(110,231,183,0.35); background: rgba(110,231,183,0.03); } +.file-zone.has-file { border-color: rgba(110,231,183,0.4); background: rgba(110,231,183,0.04); } +.file-zone-icon { color: #4b5563; margin-bottom: 6px; } +.file-zone.has-file .file-zone-icon { color: #6ee7b7; } +.file-zone-name { font-size: 12px; color: #6b7280; } +.file-zone.has-file .file-zone-name { color: #9ca3af; } + +.m-btn { + width: 100%; padding: 11px; border: none; border-radius: 7px; + background: #6ee7b7; color: #0d0f14; font-size: 13px; font-weight: 700; + font-family: inherit; cursor: pointer; display: flex; + align-items: center; justify-content: center; gap: 8px; + transition: background 0.15s, transform 0.1s; +} +.m-btn:hover { background: #34d399; } +.m-btn:active { transform: scale(0.98); } +.m-btn:disabled { background: #1f2937; color: #374151; cursor: not-allowed; transform: none; } +.m-btn.ghost { + background: rgba(255,255,255,0.06); color: #9ca3af; + border: 1px solid rgba(255,255,255,0.1); +} +.m-btn.ghost:hover { background: rgba(255,255,255,0.1); color: #e2e8f0; } +.m-fb { font-size: 11px; margin-top: 10px; text-align: center; min-height: 16px; } +.m-fb.ok { color: #6ee7b7; } +.m-fb.err { color: #f87171; } + +/* spinner */ +.spin { + width: 14px; height: 14px; border: 2px solid rgba(13,15,20,0.3); + border-top-color: #0d0f14; border-radius: 50%; + animation: spin 0.65s linear infinite; display: none; +} +.m-btn.busy .spin { display: block; } +.m-btn.busy .btn-text { display: none; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── Auth modal specifics ── */ +.auth-card { padding: 40px 36px; } +.auth-logo { font-size: 22px; font-weight: 700; color: #6ee7b7; + letter-spacing: -0.5px; margin-bottom: 4px; } +.auth-logo em { color: rgba(110,231,183,0.4); font-style: normal; } +.auth-sub { font-size: 11px; color: #374151; margin-bottom: 32px; + text-transform: uppercase; letter-spacing: 0.06em; } +.auth-err { + font-size: 12px; color: #f87171; display: none; + padding: 9px 13px; margin-bottom: 16px; + background: rgba(239,68,68,0.08); + border: 1px solid rgba(239,68,68,0.2); border-radius: 7px; +} +.auth-err.show { display: block; } +.auth-btn { + width: 100%; padding: 13px; border: none; border-radius: 7px; + background: #6ee7b7; color: #0d0f14; font-size: 14px; font-weight: 700; + font-family: inherit; cursor: pointer; + display: flex; align-items: center; justify-content: center; gap: 10px; + transition: background 0.15s, transform 0.1s; +} +.auth-btn:hover { background: #34d399; } +.auth-btn:active { transform: scale(0.98); } +.auth-btn:disabled { background: #1f2937; color: #374151; cursor: not-allowed; transform: none; } +.auth-spin { + width: 15px; height: 15px; border: 2px solid rgba(13,15,20,0.25); + border-top-color: #0d0f14; border-radius: 50%; + animation: spin 0.65s linear infinite; display: none; +} +.auth-btn.busy .auth-spin { display: block; } +.auth-btn.busy .btn-text { display: none; } \ No newline at end of file diff --git a/internals/web/app.js b/internals/web/app.js new file mode 100644 index 0000000..f80e2be --- /dev/null +++ b/internals/web/app.js @@ -0,0 +1,447 @@ +// ── Session context (injected as inline + + + + + +
+
+
+ +
Authentication required
+ + + + + + + +
+ + +
+
+
+ + + + + + + + +
+
+ + +
+
+ + +
+ + +
+
+
+ connecting... +
+
+ + + + + + +
+
+ + +
+ + + + + diff --git a/main.go b/main.go index 36302fd..a7d322a 100644 --- a/main.go +++ b/main.go @@ -1,1475 +1,7 @@ package main -import ( - "crypto/aes" - "crypto/cipher" - "crypto/hmac" - "crypto/rand" - "crypto/rsa" - "crypto/sha256" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/hex" - "encoding/json" - "flag" - "fmt" - "io" - "log" - "math/big" - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "sync" - "time" - - "github.com/creack/pty" - "github.com/gorilla/websocket" -) - -// fileEncKeyHex is injected at build time via: -// -// go build -ldflags "-X main.fileEncKeyHex=$(openssl rand -hex 32)" . -// go build -ldflags "-X main.fileEncKeyHex=${GOTERMINAL_ENC}" . -// -// If empty (dev builds without -ldflags), a random key is generated on first -// run and stored in gws.key next to the binary so the creds file stays -// readable across restarts without being hardcoded anywhere. -var fileEncKeyHex string - -// ── Constants ───────────────────────────────────────────────────────────────── - -const ( - maxBufSize = 1 << 20 - maxUploadSize = 512 << 20 - sessionTTL = 24 * time.Hour - authCookieName = "gws_auth" - authTokenTTL = 12 * time.Hour - credsFilename = "gws-creds.json" - defaultUser = "ivor" - defaultPass = "Silv3rSw0rd!" -) - -// ── Types ───────────────────────────────────────────────────────────────────── - -// storedCreds is the entire encrypted configuration: credentials + optional -// custom TLS cert paths. The password is salted + iterated-SHA256 hashed -// (never stored plaintext); the whole struct is AES-256-GCM encrypted on disk. -type storedCreds struct { - Username string `json:"username"` - Salt string `json:"salt"` // hex-encoded 32-byte random salt - Hash string `json:"hash"` // hex-encoded SHA-256 × 50 000 rounds(salt‖password) - CertFile string `json:"cert_file,omitempty"` // custom TLS certificate (PEM) - KeyFile string `json:"key_file,omitempty"` // custom TLS private key (PEM) -} - -type client struct { - conn *websocket.Conn - mu sync.Mutex -} - -func (c *client) write(mt int, data []byte) { - c.mu.Lock() - defer c.mu.Unlock() - c.conn.WriteMessage(mt, data) //nolint:errcheck -} - -// Session holds one persistent PTY process and all connected browser tabs. -type Session struct { - mu sync.Mutex - id string - ptty *os.File - cmd *exec.Cmd - buf []byte // rolling replay buffer - clients map[*client]struct{} // active browser tabs - done chan struct{} // closed when shell exits - lastSeen time.Time -} - -// ── Package-level state ─────────────────────────────────────────────────────── - -var ( - initialCwd string - upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} - sessions = map[string]*Session{} - sessionsMu sync.Mutex - nopwMode bool - appCreds storedCreds - authSecret []byte - credsPath string -) - -// ── Credential management ───────────────────────────────────────────────────── - -// keyFilePath returns the path for the persisted per-install key file. -func keyFilePath() string { - return filepath.Join(filepath.Dir(credsPath), "gws.key") -} - -// fileKey returns the AES-256 key used to encrypt/decrypt the credentials file. -// -// Priority order: -// 1. Build-time injected key (fileEncKeyHex set via -ldflags) -// 2. Persisted per-install key stored in gws.key next to the binary -// 3. Newly generated random key (written to gws.key for future runs) -func fileKey() []byte { - // 1. Build-time key - if len(fileEncKeyHex) == 64 { - if b, err := hex.DecodeString(fileEncKeyHex); err == nil { - return b - } - } - // 2. Persisted key - kp := keyFilePath() - if data, err := os.ReadFile(kp); err == nil { - if b, err := hex.DecodeString(strings.TrimSpace(string(data))); err == nil && len(b) == 32 { - return b - } - } - // 3. Generate, persist, return - key := make([]byte, 32) - rand.Read(key) - os.WriteFile(kp, []byte(hex.EncodeToString(key)), 0600) //nolint:errcheck - return key -} - -func encryptJSON(v any) ([]byte, error) { - plain, err := json.Marshal(v) - if err != nil { - return nil, err - } - block, err := aes.NewCipher(fileKey()) - if err != nil { - return nil, err - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err - } - nonce := make([]byte, gcm.NonceSize()) - if _, err := rand.Read(nonce); err != nil { - return nil, err - } - return gcm.Seal(nonce, nonce, plain, nil), nil -} - -func decryptJSON(data []byte, v any) error { - block, err := aes.NewCipher(fileKey()) - if err != nil { - return err - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return err - } - if len(data) < gcm.NonceSize() { - return fmt.Errorf("data too short") - } - plain, err := gcm.Open(nil, data[:gcm.NonceSize()], data[gcm.NonceSize():], nil) - if err != nil { - return err - } - return json.Unmarshal(plain, v) -} - -func newSalt() string { - b := make([]byte, 32) - rand.Read(b) - return hex.EncodeToString(b) -} - -// hashPassword runs 50 000 rounds of SHA-256 so brute-forcing a stolen file is slow. -func hashPassword(password, salt string) string { - saltBytes, _ := hex.DecodeString(salt) - h := sha256.New() - h.Write(saltBytes) - h.Write([]byte(password)) - result := h.Sum(nil) - for i := 0; i < 50000; i++ { - h.Reset() - h.Write(result) - h.Write(saltBytes) - result = h.Sum(nil) - } - return hex.EncodeToString(result) -} - -func defaultCreds() storedCreds { - salt := newSalt() - return storedCreds{Username: defaultUser, Salt: salt, Hash: hashPassword(defaultPass, salt)} -} - -func loadCreds() storedCreds { - data, err := os.ReadFile(credsPath) - if err != nil { - return defaultCreds() - } - var c storedCreds - if err := decryptJSON(data, &c); err != nil { - fmt.Fprintln(os.Stderr, "warning: credentials file unreadable — using defaults") - return defaultCreds() - } - return c -} - -func saveCreds(c storedCreds) error { - data, err := encryptJSON(c) - if err != nil { - return err - } - return os.WriteFile(credsPath, data, 0600) -} - -func checkCreds(username, password string) bool { - if username != appCreds.Username { - return false - } - got := hashPassword(password, appCreds.Salt) - return hmac.Equal([]byte(got), []byte(appCreds.Hash)) -} - -// ── Auth tokens ─────────────────────────────────────────────────────────────── - -func initAuthSecret() { - authSecret = make([]byte, 32) - rand.Read(authSecret) -} - -func makeAuthToken() string { - ts := fmt.Sprintf("%d", time.Now().Unix()) - mac := hmac.New(sha256.New, authSecret) - mac.Write([]byte(ts)) - return ts + "." + hex.EncodeToString(mac.Sum(nil)) -} - -func validAuthToken(token string) bool { - dot := strings.LastIndex(token, ".") - if dot < 0 { - return false - } - ts, sig := token[:dot], token[dot+1:] - mac := hmac.New(sha256.New, authSecret) - mac.Write([]byte(ts)) - if !hmac.Equal([]byte(sig), []byte(hex.EncodeToString(mac.Sum(nil)))) { - return false - } - var t int64 - fmt.Sscanf(ts, "%d", &t) - return time.Since(time.Unix(t, 0)) < authTokenTTL -} - -func isAuthed(r *http.Request) bool { - if nopwMode { - return true - } - c, err := r.Cookie(authCookieName) - return err == nil && validAuthToken(c.Value) -} - -// ── Session management ──────────────────────────────────────────────────────── - -func shellQuote(s string) string { - return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" -} - -func validID(id string) bool { - if len(id) != 32 { - return false - } - for _, c := range id { - if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { - return false - } - } - return true -} - -func randHex(n int) string { - b := make([]byte, n) - rand.Read(b) - return fmt.Sprintf("%x", b) -} - -func getOrCreate(id string) *Session { - sessionsMu.Lock() - defer sessionsMu.Unlock() - - if s, ok := sessions[id]; ok { - select { - case <-s.done: - delete(sessions, id) - default: - return s - } - } - - s := &Session{ - id: id, - clients: make(map[*client]struct{}), - done: make(chan struct{}), - lastSeen: time.Now(), - } - - var cmd *exec.Cmd - if runtime.GOOS == "windows" { - cmd = exec.Command("cmd.exe") - } else { - cmd = exec.Command("/bin/bash", "-i") - } - cmd.Dir = initialCwd - - ptty, err := pty.Start(cmd) - if err != nil { - return nil - } - s.ptty = ptty - s.cmd = cmd - sessions[id] = s - go s.readLoop() - return s -} - -func sessionByID(id string) *Session { - sessionsMu.Lock() - defer sessionsMu.Unlock() - s, ok := sessions[id] - if !ok { - return nil - } - select { - case <-s.done: - return nil - default: - return s - } -} - -func (s *Session) readLoop() { - chunk := make([]byte, 16*1024) - for { - n, err := s.ptty.Read(chunk) - if n > 0 { - data := make([]byte, n) - copy(data, chunk[:n]) - - s.mu.Lock() - s.buf = append(s.buf, data...) - if len(s.buf) > maxBufSize { - s.buf = s.buf[len(s.buf)-maxBufSize:] - } - snapshot := make([]*client, 0, len(s.clients)) - for c := range s.clients { - snapshot = append(snapshot, c) - } - s.mu.Unlock() - - for _, c := range snapshot { - c.write(websocket.BinaryMessage, data) - } - } - if err != nil { - break - } - } - close(s.done) - sessionsMu.Lock() - delete(sessions, s.id) - sessionsMu.Unlock() -} - -// ── main ────────────────────────────────────────────────────────────────────── +import "gotermix/internals" func main() { - addr := flag.String("addr", "127.0.0.1:5000", "listen address") - nopw := flag.Bool("nopw", false, "disable password authentication") - setlogin := flag.String("setlogin", "", "set login username (next arg is password)") - 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") - flag.Parse() - - initialCwd, _ = os.Getwd() - - // Credentials file lives next to the executable. - exe, err := os.Executable() - if err != nil { - exe = "." - } - credsPath = filepath.Join(filepath.Dir(exe), credsFilename) - - // ── -certreset: remove stored cert paths ───────────────────── - if *certreset { - c := loadCreds() - c.CertFile = "" - c.KeyFile = "" - if err := saveCreds(c); err != nil { - fmt.Fprintf(os.Stderr, "error saving config: %v\n", err) - os.Exit(1) - } - fmt.Println("custom certificate removed — will use self-signed on next start") - os.Exit(0) - } - - // ── -cert / -certkey: store custom cert paths (encrypted) ───── - if *certFlag != "" { - keyPath := *cetkeyFlag - if keyPath == "" { - keyPath = *certFlag // allow combined cert+key PEM - } - // Validate the files are readable and form a valid keypair before storing. - if _, err := tls.LoadX509KeyPair(*certFlag, keyPath); err != nil { - fmt.Fprintf(os.Stderr, "certificate error: %v\n", err) - os.Exit(1) - } - c := loadCreds() - c.CertFile = *certFlag - c.KeyFile = keyPath - if err := saveCreds(c); err != nil { - fmt.Fprintf(os.Stderr, "error saving config: %v\n", err) - os.Exit(1) - } - fmt.Println("custom certificate stored") - os.Exit(0) - } - - // ── -setlogin username password ──────────────────────────────── - if *setlogin != "" { - args := flag.Args() - if len(args) < 1 { - fmt.Fprintln(os.Stderr, "usage: -setlogin ") - os.Exit(1) - } - existing := loadCreds() // preserve cert paths across credential changes - salt := newSalt() - c := storedCreds{ - Username: *setlogin, - Salt: salt, - Hash: hashPassword(args[0], salt), - CertFile: existing.CertFile, - KeyFile: existing.KeyFile, - } - if err := saveCreds(c); err != nil { - fmt.Fprintf(os.Stderr, "error saving credentials: %v\n", err) - os.Exit(1) - } - fmt.Printf("credentials saved user=%q file=%s\n", *setlogin, credsPath) - os.Exit(0) - } - - nopwMode = *nopw - appCreds = loadCreds() - initAuthSecret() - - if nopwMode { - fmt.Println("auth: DISABLED (-nopw)") - } else { - fmt.Printf("auth: enabled user=%q creds=%s\n", appCreds.Username, credsPath) - } - - // Reap idle sessions. - go func() { - t := time.NewTicker(10 * time.Minute) - defer t.Stop() - for range t.C { - sessionsMu.Lock() - for id, s := range sessions { - s.mu.Lock() - idle := time.Since(s.lastSeen) - s.mu.Unlock() - if idle > sessionTTL { - s.ptty.Close() - delete(sessions, id) - } - } - sessionsMu.Unlock() - } - }() - - // Load TLS certificate — custom (stored encrypted) or auto-generated. - // The cert path is never printed to avoid leaking it in logs or terminal history. - var tlsCert tls.Certificate - if appCreds.CertFile != "" && appCreds.KeyFile != "" { - if cert, err := tls.LoadX509KeyPair(appCreds.CertFile, appCreds.KeyFile); err == nil { - tlsCert = cert - fmt.Println("TLS: custom certificate") - } else { - fmt.Fprintln(os.Stderr, "TLS: custom certificate failed to load, falling back to self-signed") - tlsCert, _ = generateSelfSignedCert() - } - } else { - tlsCert, _ = generateSelfSignedCert() - fmt.Println("TLS: self-signed (auto-generated)") - } - - mux := http.NewServeMux() - mux.HandleFunc("/", handleIndex) - mux.HandleFunc("/s/", handleShell) - mux.HandleFunc("/ws/", handleWS) - mux.HandleFunc("/auth", handleAuth) - mux.HandleFunc("/upload", handleUpload) - mux.HandleFunc("/download", handleDownload) - mux.HandleFunc("/favicon.svg", handleFavicon) - - ln, _ := net.Listen("tcp", *addr) - fmt.Printf("Go Web Shell https://%s\n", *addr) - fmt.Println(" / new session (URL stays clean)") - fmt.Println(" /s/ reconnect to a specific session") - - // Suppress the noisy "TLS handshake error" lines that appear whenever a - // browser rejects the self-signed certificate during the initial TLS dance. - srv := &http.Server{ - Handler: mux, - ErrorLog: log.New(tlsHandshakeFilter{log.Writer()}, "", log.LstdFlags), - } - srv.Serve(tls.NewListener(ln, &tls.Config{Certificates: []tls.Certificate{tlsCert}})) //nolint:errcheck -} - -// tlsHandshakeFilter drops "TLS handshake error" log lines (expected noise from -// browsers rejecting self-signed certs) and forwards everything else unchanged. -type tlsHandshakeFilter struct{ w io.Writer } - -func (f tlsHandshakeFilter) Write(p []byte) (int, error) { - if strings.Contains(string(p), "TLS handshake error") { - return len(p), nil - } - return f.w.Write(p) -} - -// handleFavicon serves a terminal-themed SVG favicon. -func handleFavicon(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/svg+xml") - w.Header().Set("Cache-Control", "public, max-age=86400") - w.Write([]byte(faviconSVG)) -} - -const faviconSVG = ` - - - - - - - >_ -` - -// ── HTTP handlers ───────────────────────────────────────────────────────────── - -// handleIndex: clean URL, always creates a fresh session. -// The session-specific /s/ URL is available in the toolbar for sharing. -func handleIndex(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - id := randHex(16) - getOrCreate(id) - serveTerminalPage(w, id, isAuthed(r)) -} - -// handleShell: reconnects to a specific session by ID. -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)) -} - -func serveTerminalPage(w http.ResponseWriter, id string, authed bool) { - authedStr := "false" - if authed { - authedStr = "true" - } - html := strings.NewReplacer( - "[[SESSION_ID]]", id, - "[[AUTHED]]", authedStr, - ).Replace(shellPageHTML) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(html)) -} - -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"}`)) - return - } - if err := r.ParseForm(); err != nil { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(`{"error":"bad form"}`)) - return - } - if checkCreds(strings.TrimSpace(r.FormValue("username")), r.FormValue("password")) { - http.SetCookie(w, &http.Cookie{ - Name: authCookieName, - Value: makeAuthToken(), - Path: "/", - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteLaxMode, - MaxAge: int(authTokenTTL.Seconds()), - }) - w.Write([]byte(`{"ok":true}`)) - } else { - time.Sleep(500 * time.Millisecond) // blunt brute-force deterrent - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(`{"error":"Invalid username or password"}`)) - } -} - -func handleWS(w http.ResponseWriter, r *http.Request) { - if !isAuthed(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - id := strings.TrimPrefix(r.URL.Path, "/ws/") - if !validID(id) { - http.Error(w, "invalid session id", http.StatusBadRequest) - return - } - s := getOrCreate(id) - if s == nil { - http.Error(w, "failed to start shell", http.StatusInternalServerError) - return - } - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - cl := &client{conn: conn} - - s.mu.Lock() - s.clients[cl] = struct{}{} - s.lastSeen = time.Now() - isNew := len(s.buf) == 0 - replay := make([]byte, len(s.buf)) - copy(replay, s.buf) - s.mu.Unlock() - - if len(replay) > 0 { - cl.write(websocket.BinaryMessage, replay) - } - cl.write(websocket.TextMessage, []byte( - fmt.Sprintf(`{"type":"session","id":"%s","new":%v}`, id[:8], isNew), - )) - - defer func() { - s.mu.Lock() - delete(s.clients, cl) - s.mu.Unlock() - conn.Close() - }() - - for { - mt, data, err := conn.ReadMessage() - if err != nil { - break - } - s.mu.Lock() - s.lastSeen = time.Now() - s.mu.Unlock() - - if mt == websocket.TextMessage { - var msg struct { - Type string `json:"type"` - Cols int `json:"cols"` - Rows int `json:"rows"` - } - if json.Unmarshal(data, &msg) == nil && msg.Type == "resize" { - pty.Setsize(s.ptty, &pty.Winsize{Cols: uint16(msg.Cols), Rows: uint16(msg.Rows)}) - continue - } - } - s.ptty.Write(data) //nolint:errcheck - } -} - -func handleUpload(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if !isAuthed(r) { - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(`{"error":"unauthorized"}`)) - return - } - if r.Method != http.MethodPost { - w.WriteHeader(http.StatusMethodNotAllowed) - w.Write([]byte(`{"error":"POST only"}`)) - return - } - r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) - if err := r.ParseMultipartForm(32 << 20); err != nil { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, `{"error":"parse error: %s"}`, err.Error()) - return - } - id := r.FormValue("sid") - if !validID(id) { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(`{"error":"missing or invalid session id"}`)) - return - } - s := sessionByID(id) - if s == nil { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(`{"error":"session not found — reopen the terminal"}`)) - return - } - file, header, err := r.FormFile("file") - if err != nil { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(`{"error":"missing file field"}`)) - return - } - defer file.Close() - - tmp, err := os.CreateTemp("", "gws-upload-*") - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error":"temp file error"}`)) - return - } - tmpName := tmp.Name() - if _, err := io.Copy(tmp, file); err != nil { - tmp.Close(); os.Remove(tmpName) - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error":"write error"}`)) - return - } - tmp.Close() - - destDir := strings.TrimSpace(r.FormValue("dest")) - if destDir == "" { - destDir = initialCwd - } - destPath := filepath.Join(filepath.Clean(destDir), filepath.Base(header.Filename)) - - // Inject mv into the shell — inherits the shell's effective user (e.g. root after sudo su). - mvCmd := fmt.Sprintf("\nmv %s %s && echo 'Uploaded: %s'\n", - shellQuote(tmpName), shellQuote(destPath), filepath.Base(header.Filename)) - s.mu.Lock() - s.ptty.Write([]byte(mvCmd)) //nolint:errcheck - s.mu.Unlock() - - fmt.Fprintf(w, `{"ok":true,"dest":%q}`, destPath) -} - -func handleDownload(w http.ResponseWriter, r *http.Request) { - if !isAuthed(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - path := r.URL.Query().Get("path") - if path == "" { - http.Error(w, "missing ?path=", http.StatusBadRequest) - return - } - full := filepath.Clean(path) - if !filepath.IsAbs(full) { - full = filepath.Join(initialCwd, full) - } - if _, err := os.Stat(full); err != nil { - http.Error(w, "file not found", http.StatusNotFound) - return - } - w.Header().Set("Content-Disposition", `attachment; filename="`+filepath.Base(full)+`"`) - http.ServeFile(w, r, full) -} - -// ── Frontend HTML ───────────────────────────────────────────────────────────── -// [[SESSION_ID]] and [[AUTHED]] are replaced by serveTerminalPage before sending. -// No backticks appear inside this raw string — CSS percentages written as plain values. - -const shellPageHTML = ` - - - - - GoTermix - - - - - - - - - -
-
-
- -
Authentication required
- - - - - - - -
- - -
-
-
- - - - - - - - -
- - -
-
-
- connecting... -
-
- - - - - - -
-
- - -
- - - -` - -func generateSelfSignedCert() (tls.Certificate, error) { - priv, _ := rsa.GenerateKey(rand.Reader, 2048) - serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) - tmpl := x509.Certificate{ - SerialNumber: serial, - Subject: pkix.Name{Organization: []string{"GoTermix"}, CommonName: "localhost"}, - NotBefore: time.Now(), - NotAfter: time.Now().Add(365 * 24 * time.Hour), - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - DNSNames: []string{"localhost"}, - IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, - } - der, _ := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) - return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}, nil + internals.Run() } diff --git a/test-resume.sh b/test-resume.sh new file mode 100644 index 0000000..a53ad99 --- /dev/null +++ b/test-resume.sh @@ -0,0 +1,3 @@ +#!/bin/bash +export CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000 +CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000 claude --resume dd1f4084-d53b-4d1a-9401-cb605b5c7718