From 25c8e8c9e831318ea6027858b1c076dbf05c8810 Mon Sep 17 00:00:00 2001 From: nahakubuilder Date: Fri, 22 May 2026 19:04:00 +0100 Subject: [PATCH] terminal added --- .gitignore | 3 + README.md | 29 +- go.mod | 8 + go.sum | 4 + main.go | 1475 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1514 insertions(+), 5 deletions(-) create mode 100644 .gitignore create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..833d376 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +test/ +upload/ +gotermix diff --git a/README.md b/README.md index f28775e..ef6c442 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,25 @@ +# Go web terminal +- 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!` -# 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)" . + +## Usage: +`./gotermix -addr ` + - listen address (default "127.0.0.1:5000") +`./gotermix -nopw` + - disable password authentication +`./gotermix -setlogin ` + - set login username (next arg is password) and restart the app. +`./gotermix -cert /etc/ssl/my.crt -certkey /etc/ssl/my.key` + -s et a cert (validates it first, then stores paths encrypted, exits) +`./gotermix -cert /etc/ssl/combined.pem` + - combined cert+key PEM file (omit -certkey) +`./gotermix -certreset` + - remove stored cert, revert to self-signed + +## 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)" .` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9c61689 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module gotermix + +go 1.26.3 + +require ( + github.com/creack/pty v1.1.24 + github.com/gorilla/websocket v1.5.3 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a15fd45 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..98e558d --- /dev/null +++ b/main.go @@ -0,0 +1,1475 @@ +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 ────────────────────────────────────────────────────────────────────── + +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 = ` + + + + + shell + + + + + + + + + +
+
+
+ +
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{"GoWebShell"}, 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 +}