412 lines
11 KiB
Go
412 lines
11 KiB
Go
package internals
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/creack/pty"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
//go:embed web/shell.html
|
|
var shellPageHTML string
|
|
|
|
//go:embed web/login.html
|
|
var loginPageHTML string
|
|
|
|
//go:embed web/favicon.svg
|
|
var faviconSVG string
|
|
|
|
//go:embed web/*.css web/*.js
|
|
var staticAssets embed.FS
|
|
|
|
var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
|
|
|
// handleFavicon serves a terminal-themed SVG favicon.
|
|
func handleFavicon(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "image/svg+xml")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
w.Write([]byte(faviconSVG))
|
|
}
|
|
|
|
// handleStaticCSS serves the embedded app.css stylesheet.
|
|
func handleStaticCSS(w http.ResponseWriter, r *http.Request) {
|
|
data, err := staticAssets.ReadFile("web/app.css")
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
w.Write(data)
|
|
}
|
|
|
|
// handleStaticJS serves the embedded app.js script.
|
|
func handleStaticJS(w http.ResponseWriter, r *http.Request) {
|
|
data, err := staticAssets.ReadFile("web/app.js")
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
w.Write(data)
|
|
}
|
|
|
|
// handleLogin serves the standalone login page (GET) or redirects authed users.
|
|
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/login" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if isAuthed(r) {
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
return
|
|
}
|
|
next := r.URL.Query().Get("next")
|
|
if !isValidNext(next) {
|
|
next = "/"
|
|
}
|
|
tok := setCSRFCookie(w)
|
|
html := strings.NewReplacer(
|
|
"[[CSRF_TOKEN]]", tok,
|
|
"[[NEXT]]", next,
|
|
).Replace(loginPageHTML)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Write([]byte(html)) //nolint:errcheck
|
|
}
|
|
|
|
// isValidNext rejects open-redirect targets; only "/" and "/s/<hex>" allowed.
|
|
func isValidNext(next string) bool {
|
|
if next == "" || next == "/" {
|
|
return true
|
|
}
|
|
if strings.HasPrefix(next, "/s/") {
|
|
return validID(strings.TrimPrefix(next, "/s/"))
|
|
}
|
|
return false
|
|
}
|
|
|
|
// handleIndex: creates a fresh workspace and redirects to its stable URL.
|
|
// Unauthenticated requests are sent to the login page first.
|
|
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if !isAuthed(r) {
|
|
http.Redirect(w, r, "/login", http.StatusFound)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/s/"+randHex(16), http.StatusFound)
|
|
}
|
|
|
|
// handleShell: serves the terminal page for a workspace ID.
|
|
// Unauthenticated requests are redirected to /login?next=...
|
|
func handleShell(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimPrefix(r.URL.Path, "/s/")
|
|
if !validID(id) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if !isAuthed(r) {
|
|
http.Redirect(w, r, "/login?next=/s/"+id, http.StatusFound)
|
|
return
|
|
}
|
|
serveTerminalPage(w, id, true)
|
|
}
|
|
|
|
func serveTerminalPage(w http.ResponseWriter, workspaceID string, authed bool) {
|
|
authedStr := "false"
|
|
if authed {
|
|
authedStr = "true"
|
|
}
|
|
html := strings.NewReplacer(
|
|
"[[WORKSPACE_ID]]", workspaceID,
|
|
"[[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"}`)) //nolint:errcheck
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte(`{"error":"bad form"}`)) //nolint:errcheck
|
|
return
|
|
}
|
|
|
|
// CSRF validation (skipped in -nopw mode which never shows the login page)
|
|
if !nopwMode && !checkCSRF(r) {
|
|
logAuthAttempt(r, "", false, "csrf_invalid")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
w.Write([]byte(`{"error":"invalid request"}`)) //nolint:errcheck
|
|
return
|
|
}
|
|
|
|
username := strings.TrimSpace(r.FormValue("username"))
|
|
password := r.FormValue("password")
|
|
totpCode := strings.TrimSpace(r.FormValue("totp_code"))
|
|
|
|
// Input bounds — reject obviously bad values before touching the hasher
|
|
if len(username) == 0 || len(username) > 64 || len(password) == 0 || len(password) > 1024 {
|
|
time.Sleep(500 * time.Millisecond)
|
|
logAuthAttempt(r, username, false, "invalid_input")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte(`{"error":"invalid input"}`)) //nolint:errcheck
|
|
return
|
|
}
|
|
|
|
// Step 1: verify password
|
|
if !checkCreds(username, password) {
|
|
time.Sleep(500 * time.Millisecond)
|
|
logAuthAttempt(r, username, false, "invalid_credentials")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
w.Write([]byte(`{"error":"Invalid username or password"}`)) //nolint:errcheck
|
|
return
|
|
}
|
|
|
|
// Step 2: MFA (if enabled)
|
|
if appCreds.MFAEnabled && appCreds.MFASecret != "" {
|
|
if totpCode == "" {
|
|
// Signal to frontend: show TOTP input
|
|
w.Write([]byte(`{"mfa_required":true}`)) //nolint:errcheck
|
|
return
|
|
}
|
|
if !validateTOTP(appCreds.MFASecret, totpCode) {
|
|
time.Sleep(500 * time.Millisecond)
|
|
logAuthAttempt(r, username, false, "invalid_totp")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
w.Write([]byte(`{"error":"Invalid authenticator code"}`)) //nolint:errcheck
|
|
return
|
|
}
|
|
}
|
|
|
|
// All checks passed — issue auth cookie
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: authCookieName,
|
|
Value: makeAuthToken(),
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: int(authTokenTTL.Seconds()),
|
|
})
|
|
logAuthAttempt(r, username, true, "login_success")
|
|
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
|
|
}
|
|
|
|
// handleLogout clears the auth cookie without destroying the workspace.
|
|
// The terminal URL remains valid — user re-authenticates to resume.
|
|
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: authCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: -1,
|
|
})
|
|
next := r.FormValue("next")
|
|
if !isValidNext(next) {
|
|
next = "/"
|
|
}
|
|
http.Redirect(w, r, "/login?next="+next, http.StatusSeeOther)
|
|
}
|
|
|
|
// handleRefresh extends the auth cookie TTL for an active authenticated session.
|
|
// Called by the frontend heartbeat every 5 minutes.
|
|
func handleRefresh(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"}`)) //nolint:errcheck
|
|
return
|
|
}
|
|
if !isAuthed(r) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
w.Write([]byte(`{"error":"unauthorized"}`)) //nolint:errcheck
|
|
return
|
|
}
|
|
refreshAuthCookie(w, r)
|
|
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
|
|
}
|
|
|
|
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)
|
|
}
|