added tabs to terminal, split single .go to separate files

This commit is contained in:
2026-05-23 15:29:05 +00:00
parent ea85a2b833
commit 330cf01985
15 changed files with 1806 additions and 1470 deletions
+2
View File
@@ -1,3 +1,5 @@
test/ test/
upload/ upload/
gotermix gotermix
*.json
*.key
+75
View File
@@ -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/<id>` 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/<id>`) 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 <tmp> <dest>` 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 |
+56
View File
@@ -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)
}
+63
View File
@@ -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
)
+139
View File
@@ -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)
}
+282
View File
@@ -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/<id> 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)
}
+161
View File
@@ -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 <username> <password>")
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/<id> 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
}
+128
View File
@@ -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()
}
+42
View File
@@ -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
}
+235
View File
@@ -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; }
+447
View File
@@ -0,0 +1,447 @@
// ── Session context (injected as inline <script> in shell.html) ─────
// SESSION_ID and AUTHED are defined before this file loads.
// ── Terminal theme ──────────────────────────────────────────────────
const TERM_THEME = {
background: '#0d0f14', foreground: '#e2e8f0',
cursor: '#6ee7b7', cursorAccent: '#0d0f14',
selectionBackground: 'rgba(110,231,183,0.25)',
};
// ── Global state ─────────────────────────────────────────────────────
let tabs = [];
let activeTab = null;
let tabCounter = 0;
// Runtime auth flag — AUTHED is the page-load value; this tracks whether
// the user has authenticated in THIS session (including via the auth modal
// after page load, where AUTHED remains false but we are now connected).
let isAuthenticated = AUTHED;
// ── Helpers ──────────────────────────────────────────────────────────
function randHexClient(n) {
const arr = new Uint8Array(n);
crypto.getRandomValues(arr);
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
}
function pasteToTerminal() {
navigator.clipboard.readText().then(
text => { if (text && activeTab) activeTab.term.paste(text); },
() => toast('Clipboard access denied — allow it in the browser address bar', 'err')
);
}
// ── URL hash: persist tab layout ──────────────────────────────────────
//
// Format: /s/<firstTabId>#t=id1,id2,id3
// When another browser opens this URL it reads the hash and opens the
// same sessions. The server only sees /s/<firstTabId> (hash is client-only).
function updateURLHash() {
if (tabs.length === 0) return;
const ids = tabs.map(t => t.id).join(',');
history.replaceState(null, '', '/s/' + tabs[0].id + '#t=' + ids);
}
function getTabIDsFromHash() {
const hash = location.hash; // e.g. "#t=aabb...,ccdd..."
if (!hash.startsWith('#t=')) return null;
const ids = hash.slice(3).split(',').filter(id => /^[0-9a-f]{32}$/.test(id));
return ids.length ? ids : null;
}
// ── Global keyboard handler (capture phase) ───────────────────────────
document.addEventListener('keydown', function(e) {
const C = e.ctrlKey, S = e.shiftKey, A = e.altKey, M = e.metaKey;
if (A && !C && !M) {
// Alt+T → new tab
if (!S && e.code === 'KeyT') { e.preventDefault(); newTab(); return; }
// Alt+W → close active tab (last tab stays)
if (!S && e.code === 'KeyW') { e.preventDefault(); if (activeTab) closeTab(activeTab); return; }
// Alt+Shift+← / Alt+Shift+→ → prev / next tab
// (plain Alt+← / Alt+→ is word-nav below; Shift disambiguates)
if (S && e.code === 'ArrowLeft') {
e.preventDefault();
const idx = tabs.indexOf(activeTab);
if (idx > 0) switchTab(tabs[idx - 1]);
return;
}
if (S && e.code === 'ArrowRight') {
e.preventDefault();
const idx = tabs.indexOf(activeTab);
if (idx < tabs.length - 1) switchTab(tabs[idx + 1]);
return;
}
// Alt+← / Alt+→ → readline word-backward / word-forward
if (!S && (e.code === 'ArrowLeft' || e.code === 'ArrowRight')) {
e.preventDefault();
const t = activeTab;
if (t && t.socket && t.socket.readyState === WebSocket.OPEN)
t.socket.send(e.code === 'ArrowLeft' ? '\x1bb' : '\x1bf');
return;
}
}
// Block all Ctrl+key browser shortcuts; xterm still receives the event.
if (C && !A && !M) {
e.preventDefault();
if (!S && e.code === 'KeyW') {
const t = activeTab;
if (t && t.socket && t.socket.readyState === WebSocket.OPEN) t.socket.send('\x17');
}
return;
}
// Block all function keys
if (!M && /^F\d+$/.test(e.key)) { e.preventDefault(); return; }
}, true); // capture phase
// Chrome processes Ctrl+W before dispatching keydown — last guard.
window.addEventListener('beforeunload', function(e) {
if (tabs.some(t => t.socket && t.socket.readyState === WebSocket.OPEN)) {
e.preventDefault();
return e.returnValue = '';
}
});
// ── Toast ─────────────────────────────────────────────────────────────
let toastTimer;
function toast(msg, type) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast show ' + (type || '');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.className = 'toast'; }, 2200);
}
// ── Status bar ────────────────────────────────────────────────────────
const dotEl = document.getElementById('dot');
const labelEl = document.getElementById('statusLabel');
function setStatus(text, state) {
labelEl.textContent = text;
dotEl.className = 'dot' + (state === 'ok' ? ' ok' : state === 'err' ? ' err' : '');
}
function updateStatus() {
if (!activeTab) return;
const s = activeTab.socket;
if (!s || s.readyState === WebSocket.CONNECTING) setStatus('connecting...', '');
else if (s.readyState === WebSocket.OPEN) setStatus('connected ' + activeTab.id.slice(0, 8) + '...', 'ok');
else setStatus('disconnected', 'err');
}
// copyLink encodes all open tab IDs in the URL hash so the recipient
// browser opens the same session layout.
function copyLink() {
const ids = tabs.map(t => t.id).join(',');
const url = location.protocol + '//' + location.host +
'/s/' + tabs[0].id + '#t=' + ids;
navigator.clipboard.writeText(url).then(
() => toast('Session link copied!', 'ok'),
() => toast('Copy failed', 'err')
);
}
// ── Modal helpers ─────────────────────────────────────────────────────
function openModal(id) {
document.getElementById(id).classList.remove('hidden');
const inp = document.querySelector('#' + id + ' .m-input');
if (inp) setTimeout(() => inp.focus(), 60);
}
function closeModal(id) {
document.getElementById(id).classList.add('hidden');
if (activeTab) activeTab.term.focus();
}
function bgClose(evt, id) {
if (evt.target.id === id) closeModal(id);
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
['upOverlay', 'dlOverlay'].forEach(id => {
if (!document.getElementById(id).classList.contains('hidden')) closeModal(id);
});
}
});
// ── Auth ──────────────────────────────────────────────────────────────
async function doAuth() {
const username = document.getElementById('fUser').value.trim();
const password = document.getElementById('fPass').value;
const btn = document.getElementById('authBtn');
const errDiv = document.getElementById('authErr');
if (!username || !password) { showAuthErr('Please enter username and password'); return; }
btn.disabled = true; btn.classList.add('busy');
errDiv.classList.remove('show');
const form = new URLSearchParams();
form.append('username', username);
form.append('password', password);
try {
const res = await fetch('/auth', { method: 'POST', body: form,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
const data = await res.json();
if (data.ok) {
isAuthenticated = true; // runtime flag: future newTab() calls will connect
document.getElementById('authOverlay').classList.add('hidden');
tabs.forEach(t => { if (!t.socket) connectTab(t); });
if (activeTab) activeTab.term.focus();
} else {
showAuthErr(data.error || 'Authentication failed');
}
} catch (_) {
showAuthErr('Network error — try again');
} finally {
btn.disabled = false; btn.classList.remove('busy');
}
}
function showAuthErr(msg) {
const e = document.getElementById('authErr');
e.textContent = msg; e.classList.add('show');
document.getElementById('fPass').value = '';
document.getElementById('fPass').focus();
}
document.getElementById('fUser').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('fPass').focus();
});
document.getElementById('fPass').addEventListener('keydown', e => {
if (e.key === 'Enter') doAuth();
});
// ── Tab management ────────────────────────────────────────────────────
//
// Each tab:
// id 32-char hex session ID (server PTY key)
// term xterm.js Terminal
// fit FitAddon
// pane .term-pane <div>
// socket WebSocket /ws/<id>
// tabEl .tab-item <div>
//
// The "+" button lives inside #tabList; new tab elements are inserted
// before it so "+" always appears right after the last tab.
//
// Tab IDs are encoded in the URL hash (#t=id1,id2,...) so the link
// button produces a URL that reopens the same sessions in another browser.
function newTab(sessionId) {
const id = sessionId || randHexClient(16);
const tabNewBtn = document.getElementById('tabNew');
tabCounter++;
const tabLabel = 'bash ' + tabCounter;
// Terminal pane
const pane = document.createElement('div');
pane.className = 'term-pane';
pane.style.display = 'none';
document.getElementById('termContainer').appendChild(pane);
const term = new Terminal({
theme: TERM_THEME,
cursorBlink: true, fontSize: 14, scrollback: 20000,
fontFamily: '"JetBrains Mono","Fira Mono",monospace',
});
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open(pane);
const tab = { id, label: tabLabel, term, fit, pane, socket: null, tabEl: null };
tabs.push(tab);
// Per-terminal key handler
term.attachCustomKeyEventHandler(evt => {
if (evt.type !== 'keydown') return true;
const C = evt.ctrlKey, S = evt.shiftKey, A = evt.altKey;
if (C && S && evt.code === 'KeyC') {
const sel = term.getSelection();
if (sel) navigator.clipboard.writeText(sel).then(
() => toast('Copied!', 'ok'),
() => toast('Copy failed', 'err')
);
return false;
}
if (C && evt.code === 'KeyV') { pasteToTerminal(); return false; }
if (C && !S && !A && evt.code === 'KeyW') return false;
if (C && S && evt.code === 'KeyW') return false;
return true;
});
// Tab button — inserted BEFORE the "+" button so "+" stays at end
const tabEl = document.createElement('div');
tabEl.className = 'tab-item';
tabEl.title = 'Session: ' + id;
tabEl.innerHTML =
`<span class="tab-label">${tabLabel}</span>` +
`<button class="tab-x" title="Close tab (Alt+W)">&#215;</button>`;
tabEl.querySelector('.tab-label').addEventListener('click', () => switchTab(tab));
tabEl.querySelector('.tab-x').addEventListener('click', ev => {
ev.stopPropagation(); closeTab(tab);
});
tabNewBtn.parentElement.insertBefore(tabEl, tabNewBtn); // right before "+"
tab.tabEl = tabEl;
switchTab(tab);
updateURLHash();
if (isAuthenticated) connectTab(tab); // connect immediately if already authed
return tab;
}
function switchTab(tab) {
if (activeTab) {
activeTab.pane.style.display = 'none';
activeTab.tabEl.classList.remove('active');
}
activeTab = tab;
tab.pane.style.display = 'block';
tab.tabEl.classList.add('active');
tab.fit.fit();
tab.term.focus();
updateStatus();
}
function closeTab(tab) {
if (tabs.length === 1) return; // never close the last tab
if (tab.socket) {
tab.socket.onclose = null; // prevent auto-reconnect
tab.socket.close();
}
tab.term.dispose();
tab.pane.remove();
tab.tabEl.remove();
const idx = tabs.indexOf(tab);
tabs.splice(idx, 1);
if (activeTab === tab)
switchTab(tabs[Math.min(idx, tabs.length - 1)]);
updateURLHash();
}
// ── WebSocket (per tab) ───────────────────────────────────────────────
function connectTab(tab) {
tab.socket = new WebSocket('wss://' + location.host + '/ws/' + tab.id);
tab.socket.binaryType = 'arraybuffer';
tab.socket.onopen = () => {
tab.fit.fit();
sendResizeFor(tab);
if (activeTab === tab) { updateStatus(); tab.term.focus(); }
};
tab.socket.onmessage = e => {
if (typeof e.data === 'string') {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'session' && activeTab === tab)
setStatus((msg.new ? 'new' : 'resumed') + ' ' + tab.id.slice(0, 8) + '...', 'ok');
} catch (_) {}
return;
}
tab.term.write(new Uint8Array(e.data));
};
tab.socket.onclose = () => {
if (activeTab === tab) setStatus('reconnecting...', 'err');
setTimeout(() => connectTab(tab), 2000);
};
tab.socket.onerror = () => tab.socket.close();
tab.term.onData(d => {
if (tab.socket && tab.socket.readyState === WebSocket.OPEN) tab.socket.send(d);
});
tab.term.onResize(() => sendResizeFor(tab));
}
function sendResizeFor(tab) {
if (tab.socket && tab.socket.readyState === WebSocket.OPEN)
tab.socket.send(JSON.stringify({ type: 'resize', cols: tab.term.cols, rows: tab.term.rows }));
}
window.addEventListener('resize', () => { if (activeTab) activeTab.fit.fit(); });
// ── Upload ────────────────────────────────────────────────────────────
function onFileChosen() {
const f = document.getElementById('upFile').files[0];
const zone = document.getElementById('fileZone');
const name = document.getElementById('upFileName');
if (f) { zone.classList.add('has-file'); name.textContent = f.name; }
else { zone.classList.remove('has-file'); name.textContent = 'Click to select file'; }
}
async function doUpload() {
const f = document.getElementById('upFile').files[0];
if (!f) { modalFb('upFb', 'Select a file first', 'err'); return; }
const btn = document.getElementById('upBtn');
const form = new FormData();
form.append('file', f);
form.append('dest', document.getElementById('upDest').value.trim());
form.append('sid', activeTab ? activeTab.id : SESSION_ID);
btn.disabled = true; btn.classList.add('busy');
modalFb('upFb', '', '');
try {
const res = await fetch('/upload', { method: 'POST', body: form });
const data = await res.json();
if (data.error) {
modalFb('upFb', data.error, 'err');
} else {
modalFb('upFb', 'Staged to ' + data.dest, 'ok');
document.getElementById('upFile').value = '';
onFileChosen();
setTimeout(() => closeModal('upOverlay'), 1400);
}
} catch (_) {
modalFb('upFb', 'Network error', 'err');
} finally {
btn.disabled = false; btn.classList.remove('busy');
}
}
// ── Download ──────────────────────────────────────────────────────────
function doDownload() {
const p = document.getElementById('dlPath').value.trim();
if (!p) { modalFb('dlFb', 'Enter a file path', 'err'); return; }
const a = document.createElement('a');
a.href = '/download?path=' + encodeURIComponent(p);
a.download = '';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
modalFb('dlFb', 'Download started', 'ok');
setTimeout(() => closeModal('dlOverlay'), 1000);
}
function modalFb(id, msg, type) {
const el = document.getElementById(id);
el.textContent = msg;
el.className = 'm-fb' + (type ? ' ' + type : '');
}
// ── Bootstrap ─────────────────────────────────────────────────────────
//
// If the URL hash encodes tab IDs, open all of them (another browser
// shared the link). Otherwise open a single tab with the server-provided
// SESSION_ID.
(function bootstrap() {
const hashIds = getTabIDsFromHash();
if (hashIds && hashIds.length > 0) {
// Restore all sessions from the shared link.
// SESSION_ID might or might not be in the list — use hash as source of truth.
hashIds.forEach(id => newTab(id));
} else {
newTab(SESSION_ID);
}
if (!isAuthenticated) {
setTimeout(() => document.getElementById('fUser').focus(), 80);
}
})();
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#13161e"/>
<rect x="1" y="1" width="30" height="30" rx="5" fill="none" stroke="#6ee7b7" stroke-width="0.75" stroke-opacity="0.35"/>
<rect x="3" y="6" width="26" height="3" rx="1.5" fill="#1a1f2e"/>
<circle cx="7" cy="7.5" r="1.2" fill="#ef4444" fill-opacity="0.7"/>
<circle cx="11" cy="7.5" r="1.2" fill="#f59e0b" fill-opacity="0.7"/>
<circle cx="15" cy="7.5" r="1.2" fill="#6ee7b7" fill-opacity="0.7"/>
<text x="4" y="26" font-family="monospace" font-size="11" font-weight="700" fill="#6ee7b7">&gt;_</text>
</svg>

After

Width:  |  Height:  |  Size: 633 B

+162
View File
@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GoTermix</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
<link rel="stylesheet" href="/static/app.css" />
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
</head>
<body>
<!-- ── Auth modal ─────────────────────────────────────────────────── -->
<div class="m-overlay" id="authOverlay">
<div class="m-card">
<div class="auth-card">
<div class="auth-logo"><em>&gt;_</em> GoTermix</div>
<div class="auth-sub">Authentication required</div>
<label class="m-label" for="fUser">Username</label>
<input class="m-input" type="text" id="fUser"
autocomplete="username" placeholder="username" spellcheck="false">
<label class="m-label" for="fPass">Password</label>
<input class="m-input" type="password" id="fPass"
autocomplete="current-password" placeholder="password">
<div class="auth-err" id="authErr"></div>
<button class="auth-btn" id="authBtn" onclick="doAuth()">
<div class="auth-spin"></div>
<span class="btn-text">Sign in</span>
</button>
</div>
</div>
</div>
<!-- ── Upload modal ───────────────────────────────────────────────── -->
<div class="m-overlay hidden" id="upOverlay" onclick="bgClose(event,'upOverlay')">
<div class="m-card">
<div class="m-head">
<span class="m-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Upload File
</span>
<button class="m-x" onclick="closeModal('upOverlay')">&#215;</button>
</div>
<div class="m-body">
<div class="file-zone" id="fileZone" onclick="document.getElementById('upFile').click()">
<div class="file-zone-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<div class="file-zone-name" id="upFileName">Click to select file</div>
<input type="file" id="upFile" style="display:none" onchange="onFileChosen()">
</div>
<label class="m-label">Destination directory</label>
<input class="m-input" type="text" id="upDest" placeholder="leave empty for cwd, or /opt/myapp">
<button class="m-btn" id="upBtn" onclick="doUpload()">
<div class="spin"></div>
<span class="btn-text">Upload</span>
</button>
<div class="m-fb" id="upFb"></div>
</div>
</div>
</div>
<!-- ── Download modal ─────────────────────────────────────────────── -->
<div class="m-overlay hidden" id="dlOverlay" onclick="bgClose(event,'dlOverlay')">
<div class="m-card">
<div class="m-head">
<span class="m-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download File
</span>
<button class="m-x" onclick="closeModal('dlOverlay')">&#215;</button>
</div>
<div class="m-body">
<label class="m-label">File path on server</label>
<input class="m-input" type="text" id="dlPath"
placeholder="/var/log/syslog"
onkeydown="if(event.key==='Enter') doDownload()">
<button class="m-btn ghost" onclick="doDownload()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download
</button>
<div class="m-fb" id="dlFb"></div>
</div>
</div>
</div>
<!-- ── Tab bar ────────────────────────────────────────────────────── -->
<div id="tabBar">
<div id="tabList">
<!-- tab items injected before this button -->
<button class="tab-new" id="tabNew" title="New tab (Alt+T)" onclick="newTab()">+</button>
</div>
</div>
<!-- ── Terminal container (panes injected by JS) ─────────────────── -->
<div id="termContainer"></div>
<!-- ── Compact toolbar ────────────────────────────────────────────── -->
<div class="toolbar">
<div class="tb-left">
<div class="dot" id="dot"></div>
<span class="status-label" id="statusLabel">connecting...</span>
</div>
<div class="tb-right">
<!-- Copy session link -->
<button class="tb-btn accent" id="copyBtn" onclick="copyLink()" title="Copy session link">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
Link
</button>
<!-- Upload -->
<button class="tb-btn" onclick="openModal('upOverlay')" title="Upload file">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Upload
</button>
<!-- Download -->
<button class="tb-btn" onclick="openModal('dlOverlay')" title="Download file">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Down
</button>
</div>
</div>
<!-- ── Toast ──────────────────────────────────────────────────────── -->
<div class="toast" id="toast"></div>
<script>
// Session context injected server-side — consumed by app.js
const SESSION_ID = "[[SESSION_ID]]";
const AUTHED = [[AUTHED]];
</script>
<script src="/static/app.js"></script>
</body>
</html>
+2 -1470
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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