Files
gotermix/main.go
T
2026-05-22 19:19:53 +01:00

1476 lines
52 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <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)
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
}
// 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 = `<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>`
// ── HTTP handlers ─────────────────────────────────────────────────────────────
// 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)
}
// ── 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 = `<!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" />
<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>
<style>
*, *::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; }
/* ── Terminal ── */
#terminal { width: 100vw; height: calc(100vh - 48px); }
/* ── 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; }
</style>
</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>
<!-- ── Terminal ───────────────────────────────────────────────────── -->
<div id="terminal"></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>
const SESSION_ID = "[[SESSION_ID]]";
const AUTHED = [[AUTHED]];
// ── xterm ──────────────────────────────────────────────────────────
const term = new Terminal({
theme: {
background: '#0d0f14', foreground: '#e2e8f0',
cursor: '#6ee7b7', cursorAccent: '#0d0f14',
selectionBackground: 'rgba(110,231,183,0.25)',
},
cursorBlink: true, fontSize: 14, scrollback: 20000,
fontFamily: '"JetBrains Mono","Fira Mono",monospace',
});
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open(document.getElementById('terminal'));
fit.fit();
window.addEventListener('resize', () => fit.fit());
// ── Paste helper ─────────────────────────────────────────────────
// Uses the Clipboard API so paste works for both Ctrl+V and Ctrl+Shift+V
// regardless of the browser's native paste-event routing.
// Chrome may show a one-time "Allow clipboard access?" prompt in the
// address bar — grant it and it will be remembered.
function pasteToTerminal() {
navigator.clipboard.readText().then(
text => { if (text) term.paste(text); },
() => toast('Clipboard access denied — allow it in the browser address bar', 'err')
);
}
// ── Comprehensive browser shortcut interception ───────────────────
//
// Capture phase (true) fires BEFORE any browser built-in handler, so
// preventDefault() actually stops the browser action.
//
// Paste (Ctrl+V / Ctrl+Shift+V) is handled explicitly via pasteToTerminal()
// in the xterm handler below, so Ctrl+V is NOT exempted here — the blanket
// preventDefault() blocks the browser paste event (which xterm no longer
// needs), and the xterm handler does the paste itself via Clipboard API.
//
// Ctrl+W / Ctrl+Shift+W: Chrome intercepts these BEFORE dispatching keydown,
// so preventDefault() here cannot reliably stop tab/window close. The
// beforeunload listener below is the real guard for that.
//
document.addEventListener('keydown', function(e) {
const C = e.ctrlKey, S = e.shiftKey, A = e.altKey, M = e.metaKey;
// ── ALL Ctrl+key combinations ─────────────────────────────
// Block every browser Ctrl shortcut (new tab, close, find, reload,
// devtools, address bar, print, save, history, bookmark, zoom, …).
// xterm still receives the keydown event (we never stopPropagation)
// and sends the correct control character to the PTY.
if (C && !A && !M) {
e.preventDefault();
// Ctrl+W → kill-word-backward (\x17).
// Also handled by beforeunload below in case the browser
// still initiates tab close before keydown fires.
if (!S && e.code === 'KeyW') {
if (socket && socket.readyState === WebSocket.OPEN) socket.send('\x17');
}
return;
}
// ── Alt+Left / Alt+Right (browser back / forward) ────────
// readline / bash: word-backward (\x1bb) and word-forward (\x1bf).
if (A && !C && !M && (e.code === 'ArrowLeft' || e.code === 'ArrowRight')) {
e.preventDefault();
if (socket && socket.readyState === WebSocket.OPEN)
socket.send(e.code === 'ArrowLeft' ? '\x1bb' : '\x1bf');
return;
}
// ── All function keys ─────────────────────────────────────
// Block F1 (help), F3 (find), F5 (reload), F6 (address bar),
// F7 (caret), F11 (fullscreen), F12 (devtools), etc.
// xterm still sees the keydown and sends the right escape sequence.
if (!M && /^F\d+$/.test(e.key)) {
e.preventDefault();
return;
}
}, true); // ← capture phase
// ── Tab/window-close safety net ───────────────────────────────────
// Chrome processes Ctrl+W before dispatching keydown, so the capture
// listener above cannot stop it. beforeunload is fired after the browser
// decides to close — this is our last chance to ask for confirmation.
// The \x17 sent above in the capture listener will reach the shell if
// the user clicks "Stay on page" and presses Ctrl+W again intentionally.
window.addEventListener('beforeunload', function(e) {
if (socket && socket.readyState === WebSocket.OPEN) {
e.preventDefault(); // standard
return e.returnValue = ''; // Chrome requires this to show the dialog
}
});
// ── xterm keystroke handler ───────────────────────────────────────
// Return false → xterm ignores this key (we already handled it above).
// Return true → xterm processes it normally, sending to the PTY.
term.attachCustomKeyEventHandler(evt => {
if (evt.type !== 'keydown') return true;
const C = evt.ctrlKey, S = evt.shiftKey, A = evt.altKey;
// Ctrl+Shift+C → copy terminal selection to clipboard
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;
}
// Ctrl+V / Ctrl+Shift+V → paste from clipboard
// Both are blocked by the capture listener above, so we handle them
// here explicitly. Ctrl+Shift+V is the standard Linux terminal paste
// shortcut; we support both for convenience.
if (C && evt.code === 'KeyV') {
pasteToTerminal();
return false;
}
// Ctrl+W → \x17 already sent in capture listener; prevent xterm double-send
if (C && !S && !A && evt.code === 'KeyW') return false;
// Ctrl+Shift+W → blocked at capture level; nothing meaningful to send
if (C && S && evt.code === 'KeyW') return false;
return true;
});
// ── 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 helpers ─────────────────────────────────────────────────
const dot = document.getElementById('dot');
const label = document.getElementById('statusLabel');
function setStatus(text, state) {
label.textContent = text;
dot.className = 'dot' + (state === 'ok' ? ' ok' : state === 'err' ? ' err' : '');
}
function copyLink() {
const url = location.protocol + '//' + location.host + '/s/' + SESSION_ID;
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');
// Focus first input inside
const inp = document.querySelector('#' + id + ' .m-input');
if (inp) setTimeout(() => inp.focus(), 60);
}
function closeModal(id) {
document.getElementById(id).classList.add('hidden');
term.focus();
}
function bgClose(evt, id) {
// Only close when clicking the backdrop, not the card itself
if (evt.target.id === id) closeModal(id);
}
// Escape closes any open modal
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
['upOverlay','dlOverlay'].forEach(id => {
if (!document.getElementById(id).classList.contains('hidden')) closeModal(id);
});
}
});
// ── Auth modal ─────────────────────────────────────────────────────
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) {
document.getElementById('authOverlay').classList.add('hidden');
connect();
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();
});
// ── WebSocket ──────────────────────────────────────────────────────
let socket;
function connect() {
setStatus('connecting...', 'wait');
socket = new WebSocket('wss://' + location.host + '/ws/' + SESSION_ID);
socket.binaryType = 'arraybuffer';
socket.onopen = () => {
fit.fit(); sendResize();
term.focus(); // focus terminal whenever (re)connected
};
socket.onmessage = e => {
if (typeof e.data === 'string') {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'session') {
const tag = msg.new ? 'new' : 'resumed';
setStatus(tag + ' ' + SESSION_ID.slice(0,8) + '...', 'ok');
}
} catch (_) {}
return;
}
term.write(new Uint8Array(e.data));
};
socket.onclose = () => { setStatus('reconnecting...', 'err'); setTimeout(connect, 2000); };
socket.onerror = () => socket.close();
}
function sendResize() {
if (socket && socket.readyState === WebSocket.OPEN)
socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
}
term.onData(d => { if (socket && socket.readyState === WebSocket.OPEN) socket.send(d); });
term.onResize(() => sendResize());
// ── Bootstrap ──────────────────────────────────────────────────────
if (AUTHED) {
document.getElementById('authOverlay').classList.add('hidden');
connect();
term.focus();
} else {
setTimeout(() => document.getElementById('fUser').focus(), 80);
}
// ── 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', 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 : '');
}
</script>
</body>
</html>`
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
}