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 }