MVP: Go daemon, password auth, persistent PTY terminals, React/xterm UI, PWA
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
190604247f
commit
436732dac0
@@ -0,0 +1,4 @@
|
|||||||
|
bin/
|
||||||
|
internal/web/dist/
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
|
GO ?= go
|
||||||
|
PREFIX ?= /usr/local/bin
|
||||||
|
|
||||||
|
.PHONY: all web build dev deploy clean
|
||||||
|
|
||||||
|
all: build
|
||||||
|
|
||||||
|
web:
|
||||||
|
cd web && npm ci --no-audit --no-fund && npm run build
|
||||||
|
|
||||||
|
build: web
|
||||||
|
$(GO) build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o bin/hamlet ./cmd/hamlet
|
||||||
|
|
||||||
|
# backend only (assumes web/dist already built)
|
||||||
|
go:
|
||||||
|
$(GO) build -ldflags "-X main.version=$(VERSION)" -o bin/hamlet ./cmd/hamlet
|
||||||
|
|
||||||
|
dev:
|
||||||
|
cd web && npm run dev
|
||||||
|
|
||||||
|
deploy: build
|
||||||
|
./deploy/install.sh
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf bin internal/web/dist web/dist
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// Hamlet — Proxmox-style web console for a bare-metal Debian host.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
|
||||||
|
"hamlet/internal/api"
|
||||||
|
"hamlet/internal/auth"
|
||||||
|
"hamlet/internal/config"
|
||||||
|
"hamlet/internal/store"
|
||||||
|
hterm "hamlet/internal/term"
|
||||||
|
"hamlet/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
defaultCfg := os.Getenv("HAMLET_CONFIG")
|
||||||
|
if defaultCfg == "" {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
defaultCfg = filepath.Join(home, "apps", "hamlet", "config.json")
|
||||||
|
}
|
||||||
|
cfgPath := flag.String("config", defaultCfg, "path to config.json")
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintf(os.Stderr, "usage: hamlet [-config path] [serve|passwd|version]\n")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
}
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cmd := "serve"
|
||||||
|
if flag.NArg() > 0 {
|
||||||
|
cmd = flag.Arg(0)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(*cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
switch cmd {
|
||||||
|
case "version":
|
||||||
|
fmt.Println("hamlet", version)
|
||||||
|
case "passwd":
|
||||||
|
if err := passwd(cfg); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
case "serve":
|
||||||
|
if err := serve(cfg); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func passwd(cfg *config.Config) error {
|
||||||
|
fmt.Fprint(os.Stderr, "New password: ")
|
||||||
|
p1, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprint(os.Stderr, "Again: ")
|
||||||
|
p2, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if string(p1) != string(p2) {
|
||||||
|
return errors.New("passwords do not match")
|
||||||
|
}
|
||||||
|
if len(p1) < 12 {
|
||||||
|
return errors.New("password must be at least 12 characters")
|
||||||
|
}
|
||||||
|
h, err := auth.HashPassword(string(p1))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.PasswordHash = h
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "password set in", cfg.Path())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serve(cfg *config.Config) error {
|
||||||
|
if cfg.PasswordHash == "" {
|
||||||
|
return fmt.Errorf("no password set — run: hamlet -config %s passwd", cfg.Path())
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(cfg.DataDir(), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sessions := auth.NewSessions(st)
|
||||||
|
|
||||||
|
u, err := user.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
shell := os.Getenv("SHELL")
|
||||||
|
if shell == "" {
|
||||||
|
shell = "/bin/bash"
|
||||||
|
}
|
||||||
|
mgr := hterm.NewManager(shell, u.HomeDir)
|
||||||
|
mgr.OnChange = func(list []hterm.Info) {
|
||||||
|
_ = st.Update(func(s *store.State) {
|
||||||
|
s.Terminals = s.Terminals[:0]
|
||||||
|
for _, t := range list {
|
||||||
|
s.Terminals = append(s.Terminals, store.Terminal{ID: t.ID, Name: t.Name})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Re-open the shells that existed before the last restart (fresh processes,
|
||||||
|
// same names/order) so the sidebar comes back as it was.
|
||||||
|
var saved []store.Terminal
|
||||||
|
st.View(func(s *store.State) { saved = append(saved, s.Terminals...) })
|
||||||
|
for _, t := range saved {
|
||||||
|
if _, err := mgr.Create(t.ID, t.Name); err != nil {
|
||||||
|
log.Printf("restore terminal %q: %v", t.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := api.New(cfg, sessions, mgr, web.FS(), version)
|
||||||
|
hs := &http.Server{
|
||||||
|
Addr: cfg.Listen,
|
||||||
|
Handler: srv.Handler(),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sig
|
||||||
|
log.Print("shutting down")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = hs.Shutdown(ctx)
|
||||||
|
mgr.Shutdown()
|
||||||
|
}()
|
||||||
|
log.Printf("hamlet %s listening on %s as %s (shell %s)", version, cfg.Listen, u.Username, shell)
|
||||||
|
if err := hs.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Hamlet web console
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=core
|
||||||
|
Group=core
|
||||||
|
WorkingDirectory=/home/core
|
||||||
|
Environment=HAMLET_CONFIG=/home/core/apps/hamlet/config.json
|
||||||
|
ExecStart=/usr/local/bin/hamlet serve
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
KillMode=control-group
|
||||||
|
TimeoutStopSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Install the built binary and unit on this host, then (re)start the service.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
sudo install -m 0755 bin/hamlet /usr/local/bin/hamlet
|
||||||
|
sudo install -m 0644 deploy/hamlet.service /etc/systemd/system/hamlet.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now hamlet.service >/dev/null
|
||||||
|
sudo systemctl restart hamlet.service
|
||||||
|
sleep 1
|
||||||
|
systemctl is-active hamlet.service && /usr/local/bin/hamlet version
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
module hamlet
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/coder/websocket v1.8.15
|
||||||
|
github.com/creack/pty v1.1.24
|
||||||
|
golang.org/x/crypto v0.57.0
|
||||||
|
golang.org/x/term v0.46.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.48.0 // indirect
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||||
|
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||||
|
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
// Package api is the HTTP/WebSocket surface: auth, terminal CRUD, attach, static UI.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
|
||||||
|
"hamlet/internal/auth"
|
||||||
|
"hamlet/internal/config"
|
||||||
|
"hamlet/internal/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cookieName = "hamlet_session"
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
cfg *config.Config
|
||||||
|
sessions *auth.Sessions
|
||||||
|
limiter auth.Limiter
|
||||||
|
mgr *term.Manager
|
||||||
|
version string
|
||||||
|
started time.Time
|
||||||
|
user string
|
||||||
|
host string
|
||||||
|
ui fs.FS
|
||||||
|
mux *http.ServeMux
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, ui fs.FS, version string) *Server {
|
||||||
|
host, _ := os.Hostname()
|
||||||
|
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, ui: ui, version: version,
|
||||||
|
started: time.Now(), host: host, user: os.Getenv("USER")}
|
||||||
|
if s.user == "" {
|
||||||
|
s.user = "core"
|
||||||
|
}
|
||||||
|
m := http.NewServeMux()
|
||||||
|
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
|
||||||
|
m.HandleFunc("POST /api/login", s.login)
|
||||||
|
m.HandleFunc("POST /api/logout", s.auth(s.logout))
|
||||||
|
m.HandleFunc("GET /api/me", s.auth(s.me))
|
||||||
|
m.HandleFunc("GET /api/terminals", s.auth(s.listTerminals))
|
||||||
|
m.HandleFunc("POST /api/terminals", s.auth(s.createTerminal))
|
||||||
|
m.HandleFunc("PATCH /api/terminals/{id}", s.auth(s.renameTerminal))
|
||||||
|
m.HandleFunc("DELETE /api/terminals/{id}", s.auth(s.deleteTerminal))
|
||||||
|
m.HandleFunc("GET /api/terminals/{id}/attach", s.auth(s.attach))
|
||||||
|
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
|
||||||
|
m.HandleFunc("/", s.static)
|
||||||
|
s.mux = m
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Handler() http.Handler { return s.mux }
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
func jsonOK(w http.ResponseWriter, code int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonErr(w http.ResponseWriter, code int, msg string) {
|
||||||
|
jsonOK(w, code, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func secure(r *http.Request) bool {
|
||||||
|
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, maxAge int) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieName, Value: tok, Path: "/", MaxAge: maxAge,
|
||||||
|
HttpOnly: true, Secure: secure(r), SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := r.Cookie(cookieName)
|
||||||
|
if err != nil || !s.sessions.Valid(c.Value) {
|
||||||
|
jsonErr(w, 401, "unauthenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- auth ----
|
||||||
|
|
||||||
|
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := s.limiter.Allow(); err != nil {
|
||||||
|
jsonErr(w, 429, "too many attempts, try again later")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||||
|
jsonErr(w, 400, "bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
|
||||||
|
s.limiter.Fail()
|
||||||
|
log.Printf("login failed from %s", clientIP(r))
|
||||||
|
jsonErr(w, 401, "wrong password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.limiter.Reset()
|
||||||
|
tok, err := s.sessions.Create()
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, "session error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.setCookie(w, r, tok, int(auth.SessionTTL.Seconds()))
|
||||||
|
log.Printf("login ok from %s", clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if c, err := r.Cookie(cookieName); err == nil {
|
||||||
|
s.sessions.Delete(c.Value)
|
||||||
|
}
|
||||||
|
s.setCookie(w, r, "", -1)
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
return strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- me ----
|
||||||
|
|
||||||
|
func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
load := ""
|
||||||
|
if b, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||||
|
f := strings.Fields(string(b))
|
||||||
|
if len(f) >= 3 {
|
||||||
|
load = strings.Join(f[:3], " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uptime := 0.0
|
||||||
|
if b, err := os.ReadFile("/proc/uptime"); err == nil {
|
||||||
|
if f := strings.Fields(string(b)); len(f) > 0 {
|
||||||
|
uptime, _ = json.Number(f[0]).Float64()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonOK(w, 200, map[string]any{
|
||||||
|
"host": s.host, "user": s.user, "version": s.version,
|
||||||
|
"load": load, "uptime": uptime, "service_started": s.started,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- terminals ----
|
||||||
|
|
||||||
|
func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
jsonOK(w, 200, s.mgr.List())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createTerminal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
||||||
|
name := strings.TrimSpace(body.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = "shell " + time.Now().Format("15:04")
|
||||||
|
}
|
||||||
|
sess, err := s.mgr.Create("", name)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("create terminal: %v", err)
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, 201, map[string]string{"id": sess.ID, "name": sess.Name})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renameTerminal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
|
||||||
|
jsonErr(w, 400, "name required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.mgr.Rename(r.PathValue("id"), strings.TrimSpace(body.Name)); err != nil {
|
||||||
|
jsonErr(w, 404, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteTerminal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := s.mgr.Close(r.PathValue("id")); err != nil {
|
||||||
|
jsonErr(w, 404, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
type resizeMsg struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Cols uint16 `json:"cols"`
|
||||||
|
Rows uint16 `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) attach(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
client, snapshot, running, err := s.mgr.Attach(id)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 404, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Origin must match Host (coder/websocket default) — blocks cross-site attaches.
|
||||||
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
|
||||||
|
if err != nil {
|
||||||
|
s.mgr.Detach(client)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer s.mgr.Detach(client)
|
||||||
|
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||||
|
conn.SetReadLimit(1 << 20)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(r.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if len(snapshot) > 0 {
|
||||||
|
if err := conn.Write(ctx, websocket.MessageBinary, snapshot); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !running {
|
||||||
|
_ = conn.Write(ctx, websocket.MessageText, []byte(`{"type":"exit"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// writer: PTY → socket, plus keepalive pings so idle proxies don't cut us.
|
||||||
|
go func() {
|
||||||
|
defer cancel()
|
||||||
|
ping := time.NewTicker(25 * time.Second)
|
||||||
|
defer ping.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ping.C:
|
||||||
|
pctx, pc := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
err := conn.Ping(pctx)
|
||||||
|
pc()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case msg, ok := <-client.Ch:
|
||||||
|
if !ok {
|
||||||
|
_ = conn.Write(ctx, websocket.MessageText, []byte(`{"type":"closed"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msg.Data != nil {
|
||||||
|
if err := conn.Write(ctx, websocket.MessageBinary, msg.Data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if msg.Event == "exit" {
|
||||||
|
b, _ := json.Marshal(map[string]any{"type": "exit", "code": msg.Code})
|
||||||
|
_ = conn.Write(ctx, websocket.MessageText, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// reader: socket → PTY (binary) or control (text JSON).
|
||||||
|
for {
|
||||||
|
typ, data, err := conn.Read(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch typ {
|
||||||
|
case websocket.MessageBinary:
|
||||||
|
if err := s.mgr.Write(id, data); err != nil && !errors.Is(err, term.ErrNotFound) {
|
||||||
|
log.Printf("pty write %s: %v", id, err)
|
||||||
|
}
|
||||||
|
case websocket.MessageText:
|
||||||
|
var m resizeMsg
|
||||||
|
if json.Unmarshal(data, &m) == nil && m.Type == "resize" {
|
||||||
|
_ = s.mgr.Resize(id, m.Cols, m.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- static UI with SPA fallback ----
|
||||||
|
|
||||||
|
func (s *Server) static(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
if p == "" {
|
||||||
|
p = "index.html"
|
||||||
|
}
|
||||||
|
f, err := s.ui.Open(p)
|
||||||
|
if err == nil {
|
||||||
|
st, _ := f.Stat()
|
||||||
|
f.Close()
|
||||||
|
if st != nil && !st.IsDir() {
|
||||||
|
if strings.HasPrefix(p, "assets/") {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
}
|
||||||
|
http.ServeFileFS(w, r, s.ui, p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SPA fallback
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
http.ServeFileFS(w, r, s.ui, "index.html")
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// Package auth: argon2id password hashing, cookie sessions, login rate limiting.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
|
||||||
|
"hamlet/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
argonTime = 3
|
||||||
|
argonMemory = 64 * 1024
|
||||||
|
argonThreads = 2
|
||||||
|
argonKeyLen = 32
|
||||||
|
|
||||||
|
SessionTTL = 30 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
func HashPassword(pw string) (string, error) {
|
||||||
|
salt := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||||
|
enc := base64.RawStdEncoding.EncodeToString
|
||||||
|
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||||
|
argonMemory, argonTime, argonThreads, enc(salt), enc(key)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func VerifyPassword(hash, pw string) bool {
|
||||||
|
parts := strings.Split(hash, "$")
|
||||||
|
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var m, t uint32
|
||||||
|
var p uint8
|
||||||
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want)))
|
||||||
|
return subtle.ConstantTimeCompare(got, want) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sessions is a token store backed by the state file. Expiry is sliding.
|
||||||
|
type Sessions struct {
|
||||||
|
st *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessions(st *store.Store) *Sessions {
|
||||||
|
s := &Sessions{st: st}
|
||||||
|
_ = st.Update(func(state *store.State) { pruneExpired(state) })
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func pruneExpired(state *store.State) {
|
||||||
|
now := time.Now()
|
||||||
|
for tok, exp := range state.Sessions {
|
||||||
|
if exp.Before(now) {
|
||||||
|
delete(state.Sessions, tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sessions) Create() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tok := hex.EncodeToString(b)
|
||||||
|
err := s.st.Update(func(state *store.State) {
|
||||||
|
pruneExpired(state)
|
||||||
|
state.Sessions[tok] = time.Now().Add(SessionTTL)
|
||||||
|
})
|
||||||
|
return tok, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid reports whether tok is live, sliding its expiry (persisted at most hourly).
|
||||||
|
func (s *Sessions) Valid(tok string) bool {
|
||||||
|
if tok == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ok := false
|
||||||
|
extend := false
|
||||||
|
s.st.View(func(state *store.State) {
|
||||||
|
exp, found := state.Sessions[tok]
|
||||||
|
if found && exp.After(time.Now()) {
|
||||||
|
ok = true
|
||||||
|
extend = time.Until(exp) < SessionTTL-time.Hour
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if ok && extend {
|
||||||
|
_ = s.st.Update(func(state *store.State) {
|
||||||
|
if _, found := state.Sessions[tok]; found {
|
||||||
|
state.Sessions[tok] = time.Now().Add(SessionTTL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sessions) Delete(tok string) {
|
||||||
|
_ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limiter is a single global backoff: after 5 failures, each further failure
|
||||||
|
// doubles a lockout starting at 30s, capped at 10 minutes. Single-user box.
|
||||||
|
type Limiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
failures int
|
||||||
|
lockUntil time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrLocked = errors.New("too many failed logins")
|
||||||
|
|
||||||
|
func (l *Limiter) Allow() error {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if time.Now().Before(l.lockUntil) {
|
||||||
|
return ErrLocked
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Limiter) Fail() {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.failures++
|
||||||
|
if l.failures >= 5 {
|
||||||
|
d := 30 * time.Second << uint(l.failures-5)
|
||||||
|
if d > 10*time.Minute {
|
||||||
|
d = 10 * time.Minute
|
||||||
|
}
|
||||||
|
l.lockUntil = time.Now().Add(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Limiter) Reset() {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.failures = 0
|
||||||
|
l.lockUntil = time.Time{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Package config loads and saves hamlet's JSON config file.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Listen string `json:"listen"`
|
||||||
|
PasswordHash string `json:"password_hash"`
|
||||||
|
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Default(path string) *Config {
|
||||||
|
return &Config{Listen: "127.0.0.1:8100", path: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads the config at path. A missing file yields the defaults.
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
c := Default(path)
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, c); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Path() string { return c.path }
|
||||||
|
func (c *Config) DataDir() string { return filepath.Dir(c.path) }
|
||||||
|
|
||||||
|
func (c *Config) Save() error {
|
||||||
|
if err := os.MkdirAll(c.DataDir(), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(c, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := c.path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, c.path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Package store persists small runtime state (terminal names, login sessions)
|
||||||
|
// to a JSON file with atomic writes.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Terminal struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type State struct {
|
||||||
|
Terminals []Terminal `json:"terminals"`
|
||||||
|
Sessions map[string]time.Time `json:"sessions"` // token -> expiry
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
path string
|
||||||
|
state State
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
|
s := &Store{path: path}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
if err := json.Unmarshal(b, &s.state); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.state.Sessions == nil {
|
||||||
|
s.state.Sessions = map[string]time.Time{}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update applies fn under the lock and writes the result to disk.
|
||||||
|
func (s *Store) Update(fn func(*State)) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
fn(&s.state)
|
||||||
|
return s.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// View reads state under the lock.
|
||||||
|
func (s *Store) View(fn func(*State)) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
fn(&s.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) save() error {
|
||||||
|
b, err := json.MarshalIndent(&s.state, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := s.path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, s.path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
// Package term owns PTY-backed shell sessions that outlive any one browser tab.
|
||||||
|
package term
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ringSize = 256 * 1024
|
||||||
|
clientBuf = 512
|
||||||
|
killGrace = 3 * time.Second
|
||||||
|
readChunk = 32 * 1024
|
||||||
|
defaultCol = 120
|
||||||
|
defaultRow = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("terminal not found")
|
||||||
|
|
||||||
|
// Msg is one unit of output to a client: raw PTY bytes, or a control event.
|
||||||
|
type Msg struct {
|
||||||
|
Data []byte // non-nil for PTY output
|
||||||
|
Event string // "exit" when the shell has ended
|
||||||
|
Code int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
Ch chan Msg
|
||||||
|
session *Session
|
||||||
|
dropped bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Info struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
ExitCode int `json:"exit_code"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Clients int `json:"clients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Created time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
ptmx *os.File
|
||||||
|
cmd *exec.Cmd
|
||||||
|
ring *Ring
|
||||||
|
clients map[*Client]struct{}
|
||||||
|
running bool
|
||||||
|
exitCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[string]*Session
|
||||||
|
shell string
|
||||||
|
home string
|
||||||
|
OnChange func([]Info) // called (without locks held) when the list changes
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(shell, home string) *Manager {
|
||||||
|
return &Manager{sessions: map[string]*Session{}, shell: shell, home: home}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newID() string {
|
||||||
|
b := make([]byte, 4)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create starts a new login shell. id may be empty.
|
||||||
|
func (m *Manager) Create(id, name string) (*Session, error) {
|
||||||
|
if id == "" {
|
||||||
|
id = newID()
|
||||||
|
}
|
||||||
|
cmd := exec.Command(m.shell, "-l")
|
||||||
|
cmd.Dir = m.home
|
||||||
|
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "HAMLET=1")
|
||||||
|
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCol, Rows: defaultRow})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := &Session{
|
||||||
|
ID: id, Name: name, Created: time.Now(),
|
||||||
|
ptmx: ptmx, cmd: cmd, ring: NewRing(ringSize),
|
||||||
|
clients: map[*Client]struct{}{}, running: true,
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.sessions[id] = s
|
||||||
|
m.mu.Unlock()
|
||||||
|
go m.pump(s)
|
||||||
|
m.changed()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) pump(s *Session) {
|
||||||
|
buf := make([]byte, readChunk)
|
||||||
|
for {
|
||||||
|
n, err := s.ptmx.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
chunk := make([]byte, n)
|
||||||
|
copy(chunk, buf[:n])
|
||||||
|
s.mu.Lock()
|
||||||
|
s.ring.Write(chunk)
|
||||||
|
s.broadcast(Msg{Data: chunk})
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break // EIO when the slave side closes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = s.cmd.Wait()
|
||||||
|
code := s.cmd.ProcessState.ExitCode()
|
||||||
|
s.mu.Lock()
|
||||||
|
s.running = false
|
||||||
|
s.exitCode = code
|
||||||
|
s.broadcast(Msg{Event: "exit", Code: code})
|
||||||
|
s.mu.Unlock()
|
||||||
|
_ = s.ptmx.Close()
|
||||||
|
m.changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcast requires s.mu. Slow clients are dropped rather than blocking the PTY.
|
||||||
|
func (s *Session) broadcast(msg Msg) {
|
||||||
|
for c := range s.clients {
|
||||||
|
select {
|
||||||
|
case c.Ch <- msg:
|
||||||
|
default:
|
||||||
|
if !c.dropped {
|
||||||
|
c.dropped = true
|
||||||
|
close(c.Ch)
|
||||||
|
}
|
||||||
|
delete(s.clients, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) get(id string) (*Session, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
s, ok := m.sessions[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) List() []Info {
|
||||||
|
m.mu.RLock()
|
||||||
|
out := make([]Info, 0, len(m.sessions))
|
||||||
|
for _, s := range m.sessions {
|
||||||
|
s.mu.Lock()
|
||||||
|
out = append(out, Info{ID: s.ID, Name: s.Name, Running: s.running, ExitCode: s.exitCode,
|
||||||
|
Created: s.Created, Clients: len(s.clients)})
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Created.Before(out[j].Created) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Rename(id, name string) error {
|
||||||
|
s, err := m.get(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.Name = name
|
||||||
|
s.mu.Unlock()
|
||||||
|
m.changed()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach subscribes a client and returns the scrollback snapshot to replay first.
|
||||||
|
func (m *Manager) Attach(id string) (*Client, []byte, bool, error) {
|
||||||
|
s, err := m.get(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, false, err
|
||||||
|
}
|
||||||
|
c := &Client{Ch: make(chan Msg, clientBuf), session: s}
|
||||||
|
s.mu.Lock()
|
||||||
|
snap := s.ring.Bytes()
|
||||||
|
running := s.running
|
||||||
|
s.clients[c] = struct{}{}
|
||||||
|
s.mu.Unlock()
|
||||||
|
m.changed()
|
||||||
|
return c, snap, running, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Detach(c *Client) {
|
||||||
|
s := c.session
|
||||||
|
s.mu.Lock()
|
||||||
|
if _, ok := s.clients[c]; ok {
|
||||||
|
delete(s.clients, c)
|
||||||
|
if !c.dropped {
|
||||||
|
c.dropped = true
|
||||||
|
close(c.Ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
m.changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Write(id string, p []byte) error {
|
||||||
|
s, err := m.get(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
running := s.running
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !running {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err = s.ptmx.Write(p)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Resize(id string, cols, rows uint16) error {
|
||||||
|
s, err := m.get(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cols == 0 || rows == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return pty.Setsize(s.ptmx, &pty.Winsize{Cols: cols, Rows: rows})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close ends the shell (SIGHUP to its process group, SIGKILL after a grace
|
||||||
|
// period) and forgets the session.
|
||||||
|
func (m *Manager) Close(id string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
s, ok := m.sessions[id]
|
||||||
|
if ok {
|
||||||
|
delete(m.sessions, id)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
running := s.running
|
||||||
|
for c := range s.clients {
|
||||||
|
if !c.dropped {
|
||||||
|
c.dropped = true
|
||||||
|
close(c.Ch)
|
||||||
|
}
|
||||||
|
delete(s.clients, c)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
if running && s.cmd.Process != nil {
|
||||||
|
pid := s.cmd.Process.Pid
|
||||||
|
_ = syscall.Kill(-pid, syscall.SIGHUP)
|
||||||
|
go func() {
|
||||||
|
time.Sleep(killGrace)
|
||||||
|
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
m.changed()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) changed() {
|
||||||
|
if m.OnChange != nil {
|
||||||
|
m.OnChange(m.List())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown hangs up every shell. Called on daemon exit.
|
||||||
|
func (m *Manager) Shutdown() {
|
||||||
|
for _, s := range m.List() {
|
||||||
|
_ = m.Close(s.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package term
|
||||||
|
|
||||||
|
// Ring is a fixed-size byte ring buffer holding the most recent output.
|
||||||
|
type Ring struct {
|
||||||
|
buf []byte
|
||||||
|
start int
|
||||||
|
n int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRing(size int) *Ring { return &Ring{buf: make([]byte, size)} }
|
||||||
|
|
||||||
|
func (r *Ring) Write(p []byte) {
|
||||||
|
size := len(r.buf)
|
||||||
|
if len(p) >= size {
|
||||||
|
copy(r.buf, p[len(p)-size:])
|
||||||
|
r.start, r.n = 0, size
|
||||||
|
return
|
||||||
|
}
|
||||||
|
end := (r.start + r.n) % size
|
||||||
|
first := copy(r.buf[end:], p)
|
||||||
|
copy(r.buf, p[first:])
|
||||||
|
if r.n+len(p) > size {
|
||||||
|
r.start = (end + len(p)) % size
|
||||||
|
r.n = size
|
||||||
|
} else {
|
||||||
|
r.n += len(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Ring) Bytes() []byte {
|
||||||
|
out := make([]byte, r.n)
|
||||||
|
first := copy(out, r.buf[r.start:])
|
||||||
|
if first < r.n {
|
||||||
|
copy(out[first:], r.buf[:r.n-first])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Package web embeds the built frontend (web/dist, produced by `make web`).
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:dist
|
||||||
|
var dist embed.FS
|
||||||
|
|
||||||
|
func FS() fs.FS {
|
||||||
|
sub, err := fs.Sub(dist, "dist")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return sub
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
|
<meta name="theme-color" content="#111418" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Hamlet" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="icon" href="/icons/icon-192.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||||
|
<title>Hamlet</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1777
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "hamlet-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"icons": "node scripts/icons.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "Hamlet",
|
||||||
|
"short_name": "Hamlet",
|
||||||
|
"description": "Linux management console",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#111418",
|
||||||
|
"theme_color": "#111418",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||||
|
{ "src": "/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Hamlet service worker: cache hashed assets, never the API, network-first for the shell.
|
||||||
|
const CACHE = 'hamlet-v1';
|
||||||
|
self.addEventListener('install', () => self.skipWaiting());
|
||||||
|
self.addEventListener('activate', (e) => {
|
||||||
|
e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
|
||||||
|
});
|
||||||
|
self.addEventListener('fetch', (e) => {
|
||||||
|
const url = new URL(e.request.url);
|
||||||
|
if (e.request.method !== 'GET' || url.origin !== location.origin || url.pathname.startsWith('/api/')) return;
|
||||||
|
if (url.pathname.startsWith('/assets/') || url.pathname.startsWith('/icons/')) {
|
||||||
|
e.respondWith(caches.open(CACHE).then(async (c) => (await c.match(e.request)) || fetch(e.request).then((r) => { if (r.ok) c.put(e.request, r.clone()); return r; })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// navigations + everything else: network first, fall back to cached index
|
||||||
|
e.respondWith(fetch(e.request).then((r) => { if (r.ok && e.request.mode === 'navigate') caches.open(CACHE).then((c) => c.put('/', r.clone())); return r; })
|
||||||
|
.catch(() => caches.match(e.request.mode === 'navigate' ? '/' : e.request)));
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Renders the PWA icons with node-canvas (borrowed from ../../calorie-counter/node_modules).
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { writeFileSync } from 'node:fs'
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
const { createCanvas } = require('/home/core/projects/calorie-counter/node_modules/canvas')
|
||||||
|
|
||||||
|
function draw(size, { pad = 0, radius = 0 }) {
|
||||||
|
const c = createCanvas(size, size), g = c.getContext('2d')
|
||||||
|
g.fillStyle = '#111418'
|
||||||
|
if (radius) { g.beginPath(); g.roundRect(0, 0, size, size, radius); g.fill() } else g.fillRect(0, 0, size, size)
|
||||||
|
const inner = size - pad * 2
|
||||||
|
g.fillStyle = '#e3a33b'
|
||||||
|
g.font = `bold ${inner * 0.62}px "DejaVu Serif", serif`
|
||||||
|
g.textAlign = 'center'; g.textBaseline = 'middle'
|
||||||
|
g.fillText('H', size / 2, size / 2 + inner * 0.03)
|
||||||
|
// prompt caret
|
||||||
|
g.fillStyle = '#58a6ff'
|
||||||
|
g.font = `bold ${inner * 0.22}px "DejaVu Sans Mono", monospace`
|
||||||
|
g.fillText('>_', size / 2 + inner * 0.27, size / 2 + inner * 0.27)
|
||||||
|
return c.toBuffer('image/png')
|
||||||
|
}
|
||||||
|
writeFileSync('public/icons/icon-192.png', draw(192, {}))
|
||||||
|
writeFileSync('public/icons/icon-512.png', draw(512, {}))
|
||||||
|
writeFileSync('public/icons/icon-512-maskable.png', draw(512, { pad: 80 }))
|
||||||
|
writeFileSync('public/icons/apple-touch-icon.png', draw(180, {}))
|
||||||
|
console.log('icons written')
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { api, Me, Unauthenticated } from './api'
|
||||||
|
import Login from './Login'
|
||||||
|
import Shell from './Shell'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [me, setMe] = useState<Me | null | undefined>(undefined)
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try { setMe(await api.me()) } catch (e) { if (e instanceof Unauthenticated) setMe(null) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { refresh() }, [refresh])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!me) return
|
||||||
|
const t = setInterval(refresh, 30000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [me, refresh])
|
||||||
|
|
||||||
|
if (me === undefined) return <div className="center muted">…</div>
|
||||||
|
if (me === null) return <Login onLogin={refresh} />
|
||||||
|
return <Shell me={me} onLoggedOut={() => setMe(null)} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
const KEYS: [string, string][] = [
|
||||||
|
['Esc', '\x1b'], ['Tab', '\t'], ['↑', '\x1b[A'], ['↓', '\x1b[B'], ['←', '\x1b[D'], ['→', '\x1b[C'],
|
||||||
|
['Home', '\x1b[H'], ['End', '\x1b[F'], ['|', '|'], ['~', '~'], ['-', '-'], ['/', '/'], ['^C', '\x03'], ['^D', '\x04'],
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function KeyBar({ send, setCtrl }: { send: (s: string) => void; setCtrl: (v: boolean) => void }) {
|
||||||
|
const [ctrl, setCtrlState] = useState(false)
|
||||||
|
const toggle = () => { const v = !ctrl; setCtrlState(v); setCtrl(v) }
|
||||||
|
const press = (s: string) => { send(s); if (ctrl) { setCtrlState(false); setCtrl(false) } }
|
||||||
|
return (
|
||||||
|
<div className="keybar" onMouseDown={(e) => e.preventDefault()}>
|
||||||
|
<button className={ctrl ? 'on' : ''} onClick={toggle}>Ctrl</button>
|
||||||
|
{KEYS.map(([label, seq]) => <button key={label} onClick={() => press(seq)}>{label}</button>)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { FormEvent, useState } from 'react'
|
||||||
|
import { api } from './api'
|
||||||
|
|
||||||
|
export default function Login({ onLogin }: { onLogin: () => void }) {
|
||||||
|
const [pw, setPw] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function submit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setBusy(true); setErr('')
|
||||||
|
try { await api.login(pw); onLogin() }
|
||||||
|
catch (e) { setErr(e instanceof Error ? e.message : 'login failed') }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="center">
|
||||||
|
<form className="login" onSubmit={submit}>
|
||||||
|
<h1>Hamlet</h1>
|
||||||
|
<input type="password" autoFocus autoComplete="current-password" placeholder="password"
|
||||||
|
value={pw} onChange={(e) => setPw(e.target.value)} />
|
||||||
|
<button disabled={busy || !pw}>{busy ? '…' : 'Sign in'}</button>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Term } from './api'
|
||||||
|
|
||||||
|
export interface Group { id: string; label: string; icon: string; soon?: boolean }
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
groups: Group[]; terms: Term[]; active: string | null
|
||||||
|
onSelect: (id: string) => void; onNew: () => void
|
||||||
|
onRename: (t: Term) => void; onClose: (t: Term) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Rail({ groups, terms, active, onSelect, onNew, onRename, onClose }: Props) {
|
||||||
|
const [open, setOpen] = useState<string>('terminal')
|
||||||
|
return (
|
||||||
|
<nav className="rail">
|
||||||
|
{groups.map((g) => {
|
||||||
|
const expanded = open === g.id
|
||||||
|
return (
|
||||||
|
<div key={g.id} className={'group' + (expanded ? ' open' : '') + (g.soon ? ' soon' : '')}>
|
||||||
|
<button className="group-head" onClick={() => !g.soon && setOpen(expanded ? '' : g.id)} disabled={g.soon}>
|
||||||
|
<span className="chev">{expanded ? '▾' : '▸'}</span>
|
||||||
|
<span className="gicon">{g.icon}</span>
|
||||||
|
<span>{g.label}</span>
|
||||||
|
{g.soon && <span className="badge">soon</span>}
|
||||||
|
</button>
|
||||||
|
{expanded && g.id === 'terminal' && (
|
||||||
|
<div className="leaves">
|
||||||
|
{terms.map((t) => (
|
||||||
|
<div key={t.id} className={'leaf' + (t.id === active ? ' active' : '') + (t.running ? '' : ' exited')}
|
||||||
|
onClick={() => onSelect(t.id)}>
|
||||||
|
<span className={'sdot' + (t.running ? ' run' : '')} />
|
||||||
|
<span className="lname" title={t.name}>{t.name}</span>
|
||||||
|
{t.id === active && (
|
||||||
|
<span className="leaf-actions">
|
||||||
|
<button className="icon-btn" title="rename" onClick={(e) => { e.stopPropagation(); onRename(t) }}>✎</button>
|
||||||
|
<button className="icon-btn" title="close" onClick={(e) => { e.stopPropagation(); onClose(t) }}>✕</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button className="leaf new" onClick={onNew}>+ New Terminal</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { api, Me, Term } from './api'
|
||||||
|
import Rail, { Group } from './Rail'
|
||||||
|
import TerminalPane, { TermHandle } from './TerminalPane'
|
||||||
|
import KeyBar from './KeyBar'
|
||||||
|
|
||||||
|
const GROUPS: Group[] = [
|
||||||
|
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||||
|
{ id: 'services', label: 'Services', icon: '⚙', soon: true },
|
||||||
|
{ id: 'cron', label: 'Cron', icon: '⏱', soon: true },
|
||||||
|
{ id: 'zfs', label: 'ZFS', icon: '▤', soon: true },
|
||||||
|
{ id: 'dashboard', label: 'Dashboard', icon: '◔', soon: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
function fmtUptime(s: number): string {
|
||||||
|
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||||
|
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () => void }) {
|
||||||
|
const [terms, setTerms] = useState<Term[]>([])
|
||||||
|
const [active, setActive] = useState<string | null>(null)
|
||||||
|
const [drawer, setDrawer] = useState(false)
|
||||||
|
const [online, setOnline] = useState(true)
|
||||||
|
const handles = useRef<Record<string, TermHandle>>({})
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
const list = await api.terminals()
|
||||||
|
setTerms(list)
|
||||||
|
setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { reload() }, [reload])
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
const t = await api.createTerminal()
|
||||||
|
await reload()
|
||||||
|
setActive(t.id)
|
||||||
|
setDrawer(false)
|
||||||
|
}
|
||||||
|
async function rename(t: Term) {
|
||||||
|
const name = window.prompt('Rename terminal', t.name)
|
||||||
|
if (name && name.trim() && name !== t.name) { await api.renameTerminal(t.id, name.trim()); await reload() }
|
||||||
|
}
|
||||||
|
async function close(t: Term) {
|
||||||
|
if (t.running && !window.confirm(`Close "${t.name}"? The shell will be hung up.`)) return
|
||||||
|
await api.deleteTerminal(t.id)
|
||||||
|
delete handles.current[t.id]
|
||||||
|
await reload()
|
||||||
|
}
|
||||||
|
async function logout() { await api.logout(); onLoggedOut() }
|
||||||
|
|
||||||
|
const activeTerm = terms.find((t) => t.id === active)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'app' + (drawer ? ' drawer-open' : '')}>
|
||||||
|
<header className="header">
|
||||||
|
<button className="icon-btn menu" onClick={() => setDrawer(!drawer)} aria-label="menu">☰</button>
|
||||||
|
<span className="brand">Hamlet</span>
|
||||||
|
<span className="host">{me.user}@{me.host}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<span className="stat" title="load average">{me.load}</span>
|
||||||
|
<span className={'dot ' + (online ? 'ok' : 'bad')} title={online ? 'connected' : 'disconnected'} />
|
||||||
|
<button className="link" onClick={logout}>logout</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="body">
|
||||||
|
<Rail groups={GROUPS} terms={terms} active={active}
|
||||||
|
onSelect={(id) => { setActive(id); setDrawer(false) }}
|
||||||
|
onNew={create} onRename={rename} onClose={close} />
|
||||||
|
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||||
|
|
||||||
|
<main className="main">
|
||||||
|
{terms.length === 0 && (
|
||||||
|
<div className="center muted">
|
||||||
|
<div>No terminals.</div>
|
||||||
|
<button className="primary" onClick={create}>+ New Terminal</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{terms.map((t) => (
|
||||||
|
<TerminalPane key={t.id} id={t.id} active={t.id === active}
|
||||||
|
register={(h) => { if (h) handles.current[t.id] = h; else delete handles.current[t.id] }}
|
||||||
|
onStatus={(ok) => { if (t.id === active) setOnline(ok) }}
|
||||||
|
onExit={reload} />
|
||||||
|
))}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTerm && <KeyBar send={(s) => handles.current[activeTerm.id]?.input(s)}
|
||||||
|
setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />}
|
||||||
|
|
||||||
|
<footer className="footer">
|
||||||
|
<span>v{me.version}</span>
|
||||||
|
<span>up {fmtUptime(me.uptime)}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<span className="muted">{activeTerm ? activeTerm.name : ''}</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { Terminal } from '@xterm/xterm'
|
||||||
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
|
import { wsURL } from './api'
|
||||||
|
|
||||||
|
export interface TermHandle { input: (s: string) => void; armCtrl: (v: boolean) => void }
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string; active: boolean
|
||||||
|
register: (h: TermHandle | null) => void
|
||||||
|
onStatus: (ok: boolean) => void
|
||||||
|
onExit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEME = {
|
||||||
|
background: '#0d1117', foreground: '#d0d7de', cursor: '#d0d7de', selectionBackground: '#264f78',
|
||||||
|
black: '#0d1117', red: '#ff7b72', green: '#3fb950', yellow: '#d29922', blue: '#58a6ff',
|
||||||
|
magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4', brightBlack: '#6e7681', brightRed: '#ffa198',
|
||||||
|
brightGreen: '#56d364', brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff',
|
||||||
|
brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TerminalPane({ id, active, register, onStatus, onExit }: Props) {
|
||||||
|
const box = useRef<HTMLDivElement>(null)
|
||||||
|
const activeRef = useRef(active)
|
||||||
|
activeRef.current = active
|
||||||
|
const cbs = useRef({ onStatus, onExit })
|
||||||
|
cbs.current = { onStatus, onExit }
|
||||||
|
const fitRef = useRef<() => void>(() => {})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = box.current!
|
||||||
|
const term = new Terminal({
|
||||||
|
theme: THEME, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
||||||
|
fontSize: window.matchMedia('(pointer: coarse)').matches ? 13 : 14,
|
||||||
|
cursorBlink: true, scrollback: 5000, allowProposedApi: true,
|
||||||
|
})
|
||||||
|
const fit = new FitAddon()
|
||||||
|
term.loadAddon(fit)
|
||||||
|
term.loadAddon(new WebLinksAddon())
|
||||||
|
term.open(el)
|
||||||
|
|
||||||
|
let ws: WebSocket | null = null
|
||||||
|
let closed = false
|
||||||
|
let ctrlArmed = false
|
||||||
|
let retry = 0
|
||||||
|
let retryTimer: number | undefined
|
||||||
|
let lastSize = ''
|
||||||
|
const enc = new TextEncoder()
|
||||||
|
|
||||||
|
const send = (s: string) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(enc.encode(s)) }
|
||||||
|
const sendResize = () => {
|
||||||
|
const key = `${term.cols}x${term.rows}`
|
||||||
|
if (key === lastSize) return
|
||||||
|
lastSize = key
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
||||||
|
}
|
||||||
|
const doFit = () => {
|
||||||
|
if (!activeRef.current || el.clientWidth === 0 || el.clientHeight === 0) return
|
||||||
|
fit.fit()
|
||||||
|
sendResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (closed) return
|
||||||
|
const sock = new WebSocket(wsURL(id))
|
||||||
|
sock.binaryType = 'arraybuffer'
|
||||||
|
ws = sock
|
||||||
|
sock.onopen = () => { retry = 0; cbs.current.onStatus(true); lastSize = ''; doFit() }
|
||||||
|
sock.onmessage = (ev) => {
|
||||||
|
if (ev.data instanceof ArrayBuffer) { term.write(new Uint8Array(ev.data)); return }
|
||||||
|
try {
|
||||||
|
const m = JSON.parse(ev.data)
|
||||||
|
if (m.type === 'exit') { term.write('\r\n\x1b[90m[process exited' + (m.code !== undefined ? ` (${m.code})` : '') + ']\x1b[0m\r\n'); closed = true; cbs.current.onExit() }
|
||||||
|
if (m.type === 'closed') closed = true
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
sock.onclose = () => {
|
||||||
|
if (ws !== sock) return
|
||||||
|
cbs.current.onStatus(false)
|
||||||
|
if (closed) return
|
||||||
|
retryTimer = window.setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000))
|
||||||
|
}
|
||||||
|
sock.onerror = () => sock.close()
|
||||||
|
}
|
||||||
|
connect()
|
||||||
|
|
||||||
|
term.onData((d) => {
|
||||||
|
if (ctrlArmed && d.length === 1) {
|
||||||
|
ctrlArmed = false
|
||||||
|
const c = d.toUpperCase().charCodeAt(0)
|
||||||
|
if (c >= 64 && c <= 95) { send(String.fromCharCode(c & 0x1f)); return }
|
||||||
|
}
|
||||||
|
send(d)
|
||||||
|
})
|
||||||
|
term.onBinary((d) => send(d))
|
||||||
|
term.onResize(sendResize)
|
||||||
|
|
||||||
|
fitRef.current = () => { doFit(); term.focus() }
|
||||||
|
const ro = new ResizeObserver(() => doFit())
|
||||||
|
ro.observe(el)
|
||||||
|
const onVis = () => { if (document.visibilityState === 'visible' && ws && ws.readyState !== WebSocket.OPEN && !closed) { clearTimeout(retryTimer); connect() } }
|
||||||
|
document.addEventListener('visibilitychange', onVis)
|
||||||
|
|
||||||
|
register({ input: send, armCtrl: (v) => { ctrlArmed = v; term.focus() } })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
register(null)
|
||||||
|
closed = true
|
||||||
|
clearTimeout(retryTimer)
|
||||||
|
document.removeEventListener('visibilitychange', onVis)
|
||||||
|
ro.disconnect()
|
||||||
|
ws?.close()
|
||||||
|
term.dispose()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
// Refit + focus whenever this pane becomes the visible one (after the display change applies).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return
|
||||||
|
const t = window.setTimeout(() => fitRef.current(), 0)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [active])
|
||||||
|
|
||||||
|
return <div ref={box} className="term" style={{ display: active ? 'block' : 'none' }} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export class Unauthenticated extends Error {}
|
||||||
|
|
||||||
|
async function req<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const r = await fetch(path, {
|
||||||
|
method,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
if (r.status === 401) throw new Unauthenticated()
|
||||||
|
if (!r.ok) {
|
||||||
|
let msg = r.statusText
|
||||||
|
try { msg = (await r.json()).error ?? msg } catch { /* ignore */ }
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
if (r.status === 204) return undefined as T
|
||||||
|
return r.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Me { host: string; user: string; version: string; load: string; uptime: number }
|
||||||
|
export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number }
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
login: (password: string) => req<void>('POST', '/api/login', { password }),
|
||||||
|
logout: () => req<void>('POST', '/api/logout'),
|
||||||
|
me: () => req<Me>('GET', '/api/me'),
|
||||||
|
terminals: () => req<Term[]>('GET', '/api/terminals'),
|
||||||
|
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
|
||||||
|
renameTerminal: (id: string, name: string) => req<void>('PATCH', `/api/terminals/${id}`, { name }),
|
||||||
|
deleteTerminal: (id: string) => req<void>('DELETE', `/api/terminals/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wsURL(id: string): string {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
return `${proto}//${location.host}/api/terminals/${id}/attach`
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import '@xterm/xterm/css/xterm.css'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator && location.protocol === 'https:') {
|
||||||
|
window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').catch(() => {}) })
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #111418; --bg2: #171b21; --bg3: #1e242c; --border: #2a313b;
|
||||||
|
--fg: #d0d7de; --muted: #7d8590; --accent: #e3a33b; --accent2: #58a6ff;
|
||||||
|
--ok: #3fb950; --bad: #ff7b72;
|
||||||
|
--header-h: 44px; --footer-h: 28px; --rail-w: 220px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #root { height: 100%; margin: 0; }
|
||||||
|
body { background: var(--bg); color: var(--fg); font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; overscroll-behavior: none; }
|
||||||
|
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.center { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; }
|
||||||
|
|
||||||
|
/* login */
|
||||||
|
.login { display: flex; flex-direction: column; gap: 10px; width: min(320px, 90vw); }
|
||||||
|
.login h1 { margin: 0 0 8px; font-weight: 600; color: var(--accent); letter-spacing: .5px; }
|
||||||
|
.login input { padding: 10px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
|
||||||
|
.login button, .primary { padding: 10px 12px; background: var(--accent); color: #111; border-radius: 6px; font-weight: 600; }
|
||||||
|
.login button:disabled { opacity: .5; }
|
||||||
|
.error { color: var(--bad); font-size: 13px; }
|
||||||
|
|
||||||
|
/* layout */
|
||||||
|
.app { height: 100%; display: flex; flex-direction: column; padding-top: env(safe-area-inset-top); }
|
||||||
|
.header { height: var(--header-h); display: flex; align-items: center; gap: 12px; padding: 0 12px; background: var(--bg2); border-bottom: 1px solid var(--border); }
|
||||||
|
.brand { font-weight: 700; color: var(--accent); letter-spacing: .5px; }
|
||||||
|
.host { color: var(--muted); font-family: ui-monospace, monospace; font-size: 13px; }
|
||||||
|
.stat { font-family: ui-monospace, monospace; font-size: 12px; color: var(--muted); }
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
||||||
|
.dot.ok { background: var(--ok); } .dot.bad { background: var(--bad); }
|
||||||
|
.link { color: var(--muted); } .link:hover { color: var(--fg); }
|
||||||
|
.icon-btn { padding: 2px 6px; color: var(--muted); border-radius: 4px; } .icon-btn:hover { color: var(--fg); background: var(--bg3); }
|
||||||
|
.menu { display: none; font-size: 18px; }
|
||||||
|
.body { flex: 1; display: flex; min-height: 0; position: relative; }
|
||||||
|
.footer { height: var(--footer-h); display: flex; align-items: center; gap: 16px; padding: 0 12px; background: var(--bg2); border-top: 1px solid var(--border); font-size: 12px; color: var(--muted); padding-bottom: env(safe-area-inset-bottom); box-sizing: content-box; }
|
||||||
|
|
||||||
|
/* rail */
|
||||||
|
.rail { width: var(--rail-w); background: var(--bg2); border-right: 1px solid var(--border); overflow-y: auto; padding: 6px 0; flex-shrink: 0; }
|
||||||
|
.group-head { width: 100%; display: flex; align-items: center; gap: 8px; padding: 8px 12px; text-align: left; }
|
||||||
|
.group-head:hover { background: var(--bg3); }
|
||||||
|
.group.soon .group-head { color: var(--muted); cursor: default; }
|
||||||
|
.group.soon .group-head:hover { background: none; }
|
||||||
|
.chev { width: 10px; color: var(--muted); font-size: 11px; }
|
||||||
|
.gicon { width: 18px; text-align: center; color: var(--accent2); font-family: ui-monospace, monospace; font-size: 12px; }
|
||||||
|
.badge { margin-left: auto; font-size: 10px; text-transform: uppercase; border: 1px solid var(--border); border-radius: 3px; padding: 0 4px; }
|
||||||
|
.leaves { padding: 2px 0 6px; }
|
||||||
|
.leaf { display: flex; align-items: center; gap: 8px; padding: 7px 12px 7px 30px; cursor: pointer; width: 100%; text-align: left; }
|
||||||
|
.leaf:hover { background: var(--bg3); }
|
||||||
|
.leaf.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
|
.leaf.exited .lname { color: var(--muted); text-decoration: line-through; }
|
||||||
|
.leaf.new { color: var(--accent2); }
|
||||||
|
.lname { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.leaf-actions { display: flex; gap: 2px; }
|
||||||
|
.sdot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex-shrink: 0; }
|
||||||
|
.sdot.run { background: var(--ok); }
|
||||||
|
|
||||||
|
/* main + terminal */
|
||||||
|
.main { flex: 1; min-width: 0; position: relative; background: #0d1117; }
|
||||||
|
.term { position: absolute; inset: 0; padding: 6px 0 0 6px; }
|
||||||
|
.term .xterm { height: 100%; }
|
||||||
|
.xterm-viewport::-webkit-scrollbar { width: 8px; } .xterm-viewport::-webkit-scrollbar-thumb { background: var(--border); }
|
||||||
|
|
||||||
|
/* key bar (touch devices / narrow screens) */
|
||||||
|
.keybar { display: none; gap: 4px; padding: 4px 6px; background: var(--bg2); border-top: 1px solid var(--border); overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; }
|
||||||
|
.keybar button { flex: 0 0 auto; padding: 8px 10px; min-width: 40px; background: var(--bg3); border: 1px solid var(--border); border-radius: 5px; font-family: ui-monospace, monospace; font-size: 13px; }
|
||||||
|
.keybar button.on { background: var(--accent); color: #111; border-color: var(--accent); }
|
||||||
|
@media (pointer: coarse), (max-width: 800px) { .keybar { display: flex; } }
|
||||||
|
|
||||||
|
/* mobile: rail becomes a drawer */
|
||||||
|
.backdrop { display: none; }
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.menu { display: inline-block; }
|
||||||
|
.host, .stat { display: none; }
|
||||||
|
.rail { position: absolute; top: 0; bottom: 0; left: 0; z-index: 10; transform: translateX(-100%); transition: transform .15s ease; box-shadow: 4px 0 16px rgba(0,0,0,.5); }
|
||||||
|
.drawer-open .rail { transform: none; }
|
||||||
|
.drawer-open .backdrop { display: block; position: absolute; inset: 0; z-index: 9; background: rgba(0,0,0,.5); }
|
||||||
|
.footer { font-size: 11px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: { outDir: '../internal/web/dist', emptyOutDir: true },
|
||||||
|
server: {
|
||||||
|
proxy: { '/api': { target: 'http://127.0.0.1:8100', ws: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user