1476 lines
52 KiB
Go
1476 lines
52 KiB
Go
|
|
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">>_</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>shell</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>>_</em> GoWebShell</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')">×</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')">×</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{"GoWebShell"}, CommonName: "localhost"},
|
|||
|
|
NotBefore: time.Now(),
|
|||
|
|
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
|||
|
|
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
|||
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|||
|
|
DNSNames: []string{"localhost"},
|
|||
|
|
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
|
|||
|
|
}
|
|||
|
|
der, _ := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
|
|||
|
|
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}, nil
|
|||
|
|
}
|