From 08d61e70031c844902cc4bb3690ceaf9b1006edd Mon Sep 17 00:00:00 2001 From: Rose Hogenson Date: Tue, 20 Oct 2026 22:17:09 -0700 Subject: Branch roseh.moe.go to a new package --- internal/auth/auth.go | 1410 +++++++++++++++++++++++++++++++++++++++++++++++++ roseh.moe.go | 1410 ------------------------------------------------- 2 files changed, 1410 insertions(+), 1410 deletions(-) create mode 100644 internal/auth/auth.go delete mode 100644 roseh.moe.go diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..e8fb802 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,1410 @@ +// Copyright 2026 Rose Hogenson +// +// This file is part of roseh.moe. +// +// roseh.moe is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// roseh.moe is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty +// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public +// License along with roseh.moe. If not, see +// . + +package main + +import ( + "bytes" + "compress/gzip" + "context" + "crypto" + "crypto/ecdsa" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "embed" + "encoding/base64" + "encoding/binary" + "encoding/json/v2" + "encoding/xml" + "flag" + "fmt" + "html/template" + "io" + "io/fs" + "log" + "net/http" + "net/http/cgi" + "net/http/httputil" + "net/url" + "os" + "path" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "time" + "unicode" + + "github.com/skip2/go-qrcode" + "roseh.moe/pkg/roseh.moe/internal/hole" + "roseh.moe/pkg/wordlist" +) + +var ( + selfURL = urlFlag("url", "http://localhost:42069", "base URL of the server") + secretsFile = flag.String("secrets", "secrets.xml", "secrets file") + configFile = flag.String("config", "config.xml", "configuration file (XML, baby!!)") + https = flag.String("https", "", "directory containing cert.pem and key.pem, or empty string to use unencrypted http") + httpRedirect = flag.String("http-redirect", "", "port to serve HTTP redirect") + nezuko = flag.String("nezuko", "/mnt/nezuko", "nezuko partition") + cgit = flag.String("cgit", "", "directory containing cgit.cgi") + cgitConfig = flag.String("cgit-config", "", "cgit config file") + v = flag.Bool("v", true, "verbose") +) + +func init() { + flag.StringVar(&hole.HoleTempDir, "hole-temp-dir", "/tmp/hole", "directory to store temporary files for the wormhole") +} + +type urlValue struct { + URL *url.URL +} + +func (v urlValue) String() string { + if v.URL != nil { + return v.URL.String() + } + return "" +} + +func (v urlValue) Set(s string) error { + u, err := url.Parse(s) + if err != nil { + return err + } + *v.URL = *u + return nil +} + +func urlFlag(name, def, doc string) *url.URL { + u, err := url.Parse(def) + if err != nil { + panic(err) + } + flag.Var(urlValue{u}, name, doc) + return u +} + +func sigVerifier(keyBytes []byte) (func(msg, sig []byte) bool, error) { + key, err := x509.ParsePKIXPublicKey(keyBytes) + if err != nil { + return nil, err + } + switch key := key.(type) { + case *ecdsa.PublicKey: + return func(msg, sig []byte) bool { + hash := sha256.Sum256(msg) + return ecdsa.VerifyASN1(key, hash[:], sig) + }, nil + case *rsa.PublicKey: + return func(msg, sig []byte) bool { + hash := sha256.Sum256(msg) + return rsa.VerifyPKCS1v15(key, crypto.SHA256, hash[:], sig) == nil + }, nil + default: + return nil, fmt.Errorf("unsupported key type") + } +} + +func verifyPubKey(pubKey, msg, sig []byte) error { + verify, err := sigVerifier(pubKey) + if err != nil { + return err + } + if !verify(msg, sig) { + return fmt.Errorf("message failed validation") + } + return nil +} + +var secretKey []byte + +func loadSecrets() error { + fileBytes, err := os.ReadFile(*secretsFile) + if err != nil { + return err + } + var secretsConfig struct { + SecretKey string `xml:",attr"` + } + if err := xml.Unmarshal(fileBytes, &secretsConfig); err != nil { + return fmt.Errorf("%s: %s", *secretsFile, err) + } + if secretsConfig.SecretKey == "" { + return fmt.Errorf("%s: missing SecretKey", *secretsFile) + } + if secretKey, err = base64.StdEncoding.DecodeString(secretsConfig.SecretKey); err != nil { + return fmt.Errorf("%s: bad SecretKey (try base64)", *secretsFile) + } + return nil +} + +type packageConfig struct { + Name string `xml:",attr"` + URL string `xml:",attr"` + Source string `xml:",attr"` + Description string `xml:",attr"` +} + +type commandConfig struct { + Name string `xml:",attr"` + URL string `xml:",attr"` + Source string `xml:",attr"` + Description string `xml:",attr"` + Multi bool `xml:",attr"` +} + +type passkey struct { + ID string `xml:",attr"` + Key string `xml:",attr"` +} + +type serviceConfiguration struct { + PackageRedirects []packageConfig `xml:"Redirects>Package"` + CommandRedirects []commandConfig `xml:"Redirects>Command"` + Passkey []passkey + passkeys map[string][]byte +} + +var ( + serviceConfigMu sync.Mutex + serviceConfig *serviceConfiguration + serviceConfigLastModified time.Time +) + +func loadConfig() (*serviceConfiguration, error) { + stat, err := os.Stat(*configFile) + if err != nil { + return nil, err + } + serviceConfigMu.Lock() + oldConfig := serviceConfig + configLastModified := serviceConfigLastModified + serviceConfigMu.Unlock() + newModTime := stat.ModTime() + if !newModTime.After(configLastModified) { + return oldConfig, nil + } + log.Printf("Reloading config file...") + fileBytes, err := os.ReadFile(*configFile) + if err != nil { + return nil, err + } + config := new(serviceConfiguration) + if err := xml.Unmarshal(fileBytes, config); err != nil { + return nil, err + } + names := make(map[string]bool) + config.passkeys = make(map[string][]byte) + namesByID := make(map[string]string) + for _, passkey := range config.Passkey { + if names[passkey.ID] { + return nil, fmt.Errorf("%s: duplicate name %q", *configFile, passkey.ID) + } + names[passkey.ID] = true + keyBytes, err := base64.RawURLEncoding.DecodeString(strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return -1 + } + return r + }, passkey.Key)) + if err != nil { + return nil, fmt.Errorf("%s: invalid key for passkey %q: %s", *configFile, passkey.ID, err) + } + if len(keyBytes) < 16 { + return nil, fmt.Errorf("%s: invalid key for passkey %q: bad user ID: too short", *configFile, passkey.ID) + } + id, key := keyBytes[:16], keyBytes[16:] + if _, err := sigVerifier(key); err != nil { + return nil, fmt.Errorf("%s: invalid key for passkey %q: %s", *configFile, passkey.ID, err) + } + if _, ok := config.passkeys[string(id)]; ok { + return nil, fmt.Errorf("%s: passkeys %q and %q have duplicate ID %d", *configFile, namesByID[string(id)], passkey.ID, id) + } + config.passkeys[string(id)] = key + namesByID[string(id)] = passkey.ID + } + slices.SortFunc(config.PackageRedirects, func(x, y packageConfig) int { return strings.Compare(x.Name, y.Name) }) + slices.SortFunc(config.CommandRedirects, func(x, y commandConfig) int { return strings.Compare(x.Name, y.Name) }) + serviceConfigMu.Lock() + serviceConfig = config + serviceConfigLastModified = newModTime + serviceConfigMu.Unlock() + return config, nil +} + +func makeETag(content io.Reader) (string, error) { + h := sha256.New() + if _, err := io.Copy(h, content); err != nil { + return "", err + } + return `W/"` + base64.RawURLEncoding.EncodeToString(h.Sum(nil)[:8]) + `"`, nil +} + +var ( + //go:embed templates/outline.html.template + outlineString string + outlineTemplate = template.Must(template.New("outline").Funcs(template.FuncMap{ + "host": func() string { + return selfURL.Host + }, + "xmlEscape": func(s string) string { + buf := new(strings.Builder) + xml.EscapeText(buf, []byte(s)) + return buf.String() + }, + }).Parse(outlineString)) +) + +func outlinedTemplate(s string) (*template.Template, error) { + outline, err := outlineTemplate.Clone() + if err != nil { + return nil, err + } + t, err := outline.New("body").Parse(s) + if err != nil { + return nil, err + } + return t.Lookup("outline"), nil +} + +type staticHandler struct { + content, eTag string +} + +func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("ETag", h.eTag) + http.ServeContent(w, r, "", time.Time{}, strings.NewReader(h.content)) +} + +func staticTemplate(tmpl *template.Template) (*staticHandler, error) { + buf := new(strings.Builder) + if err := tmpl.Execute(buf, nil); err != nil { + return nil, err + } + content := buf.String() + eTag, err := makeETag(strings.NewReader(content)) + if err != nil { + return nil, err + } + return &staticHandler{ + content: buf.String(), + eTag: eTag, + }, nil +} + +var ( + //go:embed templates/404.html.template + notFoundString string + notFoundTemplate = template.Must(template.New("notFound").Parse(notFoundString)) +) + +func notFound(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + type args struct { + Path string + } + if err := notFoundTemplate.Execute(w, args{Path: r.URL.Path}); err != nil { + log.Printf("Warning: notFound: %s", err) + } +} + +//go:embed templates/index.html.template +var indexString string + +//go:embed templates/games/index.html.template +var gamesIndexString string + +//go:embed templates/games/pong.html.template +var pongString string + +//go:embed templates/games/rms.html.template +var stallmanShooterString string + +//go:embed static +var staticFiles embed.FS + +var staticFileHashes = buildStaticFileHashes() + +func buildStaticFileHashes() map[string]string { + hashMap := make(map[string]string) + if err := fs.WalkDir(staticFiles, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + f, err := staticFiles.Open(path) + if err != nil { + return err + } + defer f.Close() + if hashMap[path], err = makeETag(f); err != nil { + return err + } + return nil + }); err != nil { + panic(fmt.Sprintf("Build static file hash map: %s", err)) + } + return hashMap +} + +func serveStaticFile(w http.ResponseWriter, r *http.Request, path string) { + if eTag, ok := staticFileHashes[path]; ok { + w.Header().Set("ETag", eTag) + } + http.ServeFileFS(w, r, staticFiles, path) +} + +func static(w http.ResponseWriter, r *http.Request) { + serveStaticFile(w, r, path.Clean(strings.TrimPrefix(r.URL.Path, "/"))) +} + +func favicon(w http.ResponseWriter, r *http.Request) { + serveStaticFile(w, r, "static/favicon.ico") +} + +func sharedFileHandler(w http.ResponseWriter, r *http.Request) { + fp, err := filepath.Localize(path.Clean(strings.TrimPrefix(r.URL.Path, "/"))) + if err != nil { + notFound(w, r) + return + } + f, err := os.OpenInRoot(filepath.Join(*nezuko, "www"), fp) + if err != nil { + notFound(w, r) + return + } + defer f.Close() + stat, err := f.Stat() + if err != nil { + log.Printf("Warning: sharedFileHandler: %s", err) + notFound(w, r) + return + } + if stat.IsDir() { + notFound(w, r) + return + } + http.ServeContent(w, r, fp, stat.ModTime(), f) +} + +func qrHandler(w http.ResponseWriter, r *http.Request) { + text, ok := verifyHMAC(r.FormValue("t"), "qr") + if !ok { + http.Error(w, "Bad signature", http.StatusBadRequest) + return + } + qr, err := qrcode.Encode(text, qrcode.Medium, 200) + if err != nil { + log.Printf("Error: failed to encode QR code: %s", err) + http.Error(w, "Failed to encode QR code", http.StatusInternalServerError) + return + } + http.ServeContent(w, r, "qr.png", time.Time{}, bytes.NewReader(qr)) +} + +var ( + //go:embed templates/wormhole.html.template + wormholeTemplateString string + wormholeTemplate = template.Must(outlinedTemplate(wormholeTemplateString)) +) + +type wormholeTemplateArgs struct { + Err string +} + +func readHoleFile(w http.ResponseWriter, req *http.Request, holeName string) error { + fileName, r, err := hole.Open(holeName) + if err != nil { + return err + } + defer r.Close() + w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+fileName) + var modTime time.Time + if stat, err := r.F.Stat(); err == nil { + modTime = stat.ModTime() + } + http.ServeContent(w, req, fileName, modTime, r) + return nil +} + +func wormhole(w http.ResponseWriter, r *http.Request) { + if err := wormholeTemplate.Execute(w, wormholeTemplateArgs{}); err != nil { + log.Printf("Warning: wormhole: %s", err) + } +} + +var ( + //go:embed templates/wormhole-success.html.template + wormholeSuccessTemplateString string + wormholeSuccessTemplate = template.Must(outlinedTemplate(wormholeSuccessTemplateString)) +) + +type wormholeSuccessTemplateArgs struct { + URL string + QR string +} + +func wormholeUpload(w http.ResponseWriter, r *http.Request) { + reader, err := r.MultipartReader() + if err != nil { + http.Error(w, fmt.Sprintf("Not a multipart/form-data request: %s", err), http.StatusBadRequest) + return + } + holeName, err := hole.Upload(reader) + if err != nil { + if err == hole.ErrNoFile { + w.WriteHeader(http.StatusBadRequest) + if err := wormholeTemplate.Execute(w, wormholeTemplateArgs{Err: "don't forget to pick a file"}); err != nil { + log.Printf("Warning: wormholeTemplate: %s", err) + } + return + } + log.Printf("Error: failed to upload hole file: %s", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + downloadURL := selfURL.String() + "/wormhole/" + holeName + if err := wormholeSuccessTemplate.Execute(w, wormholeSuccessTemplateArgs{ + URL: downloadURL, + QR: "/qr.png?t=" + url.QueryEscape(signMsg(downloadURL, "qr")), + }); err != nil { + log.Printf("Warning: wormholeSend: %s", err) + } +} + +func wormholeAPIUpload(w http.ResponseWriter, r *http.Request) { + hole, err := hole.WriteSingleFile(r.PathValue("name"), r.Body) + if err != nil { + log.Printf("Error: failed to write hole file: %s", err) + http.Error(w, "Failed to write hole file", http.StatusInternalServerError) + return + } + resultURL := fmt.Sprintf("%s/wormhole/%s", selfURL, hole) + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("Location", resultURL) + w.WriteHeader(http.StatusCreated) + fmt.Fprintln(w, resultURL) +} + +func wormholeDownload(w http.ResponseWriter, r *http.Request) { + if err := readHoleFile(w, r, r.PathValue("hole")); err != nil { + if err == hole.ErrNoHole { + notFound(w, r) + return + } + log.Printf("Error: failed to read hole file: %s", err) + http.Error(w, "Failed to read hole file", http.StatusInternalServerError) + return + } +} + +func mac(msg []byte, purpose string) []byte { + mac := hmac.New(sha256.New, secretKey) + io.WriteString(mac, purpose) + io.WriteString(mac, ":") + mac.Write(msg) + return mac.Sum(nil) +} + +func signMsg(msg, purpose string) string { + return msg + base64.RawURLEncoding.EncodeToString(mac([]byte(msg), purpose)) +} + +func verifyHMAC(signedMsg, purpose string) (string, bool) { + macSize := base64.RawURLEncoding.EncodedLen(sha256.Size) + if len(signedMsg) < macSize { + return "", false + } + msg, msgMAC := signedMsg[:len(signedMsg)-macSize], signedMsg[len(signedMsg)-macSize:] + expectedMAC := base64.RawURLEncoding.EncodeToString(mac([]byte(msg), purpose)) + if !hmac.Equal([]byte(msgMAC), []byte(expectedMAC)) { + return "", false + } + return msg, true +} + +const ( + authVersion = "authv3" + authCookieName = "__Secure-a" +) + +func makeHTTPCookie(token string, expiry time.Time) *http.Cookie { + return &http.Cookie{ + Name: authCookieName, + Value: token, + Path: "/", + Domain: selfURL.Hostname(), + Expires: expiry, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Partitioned: true, + } +} + +type authToken struct { + expiry time.Time + userID []byte +} + +func attachCookie(w http.ResponseWriter, tok *authToken) { + buf := binary.AppendVarint(nil, tok.expiry.Unix()) + buf = append(buf, tok.userID...) + token := signMsg(base64.RawURLEncoding.EncodeToString(buf), authVersion) + http.SetCookie(w, makeHTTPCookie(token, tok.expiry)) +} + +func cookieAuth(r *http.Request) bool { + cookie, err := r.Cookie(authCookieName) + if err != nil { + return false + } + msg, ok := verifyHMAC(cookie.Value, authVersion) + if !ok { + return false + } + msgBytes, err := base64.RawURLEncoding.DecodeString(msg) + if err != nil { + return false + } + expiryUnix, n := binary.Varint(msgBytes) + if n <= 0 { + return false + } + msgBytes = msgBytes[n:] + expiry := time.Unix(expiryUnix, 0) + if time.Now().After(expiry) { + return false + } + if len(msgBytes) > 0 { + if len(msgBytes) < 16 { + return false + } + userID := msgBytes + config, err := loadConfig() + if err != nil { + log.Printf("Error: failed to load config: %s", err) + return false + } + if _, ok := config.passkeys[string(userID)]; !ok { + return false + } + } + return true +} + +func verifyRedirect(redirect string) bool { + u, err := url.Parse(redirect) + if err != nil { + return false + } + switch u.Host { + case + "cinema." + selfURL.Host, + "downloads." + selfURL.Host, + selfURL.Host: + + return true + } + return false +} + +const challengeExpiry = 10 * time.Minute + +var ( + challengeMu sync.Mutex + challenges = make(map[string]time.Time) +) + +var ( + //go:embed templates/login.html.template + loginTemplateString string + loginTemplate = template.Must(template.New("login").Parse(loginTemplateString)) +) + +type loginTemplateArgs struct { + Challenge string +} + +func serveLogin(w http.ResponseWriter, r *http.Request) { + if cookieAuth(r) { + redirect := r.FormValue("redirect") + if !verifyRedirect(redirect) { + redirect = "/" + } + http.Redirect(w, r, redirect, http.StatusSeeOther) + return + } + buf := make([]byte, 32) + rand.Read(buf) + now := time.Now() + challengeMu.Lock() + if len(challenges) >= 100 { + challengeMu.Unlock() + http.Error(w, "Too many active requests", http.StatusTooManyRequests) + return + } + challenges[string(buf)] = now + challengeMu.Unlock() + if err := loginTemplate.Execute(w, loginTemplateArgs{ + Challenge: base64.StdEncoding.EncodeToString(buf), + }); err != nil { + log.Printf("Warning: login: %s", err) + } +} + +func login(w http.ResponseWriter, r *http.Request) { + user, err := base64.StdEncoding.DecodeString(r.FormValue("user")) + if err != nil { + http.Error(w, "Bad user", http.StatusBadRequest) + return + } + authData, err := base64.StdEncoding.DecodeString(r.FormValue("auth-data")) + if err != nil { + http.Error(w, "Bad auth data", http.StatusBadRequest) + return + } + clientData, err := base64.StdEncoding.DecodeString(r.FormValue("client-data")) + if err != nil { + http.Error(w, "Bad client data: invalid base64", http.StatusBadRequest) + return + } + sig, err := base64.StdEncoding.DecodeString(r.FormValue("sig")) + if err != nil { + http.Error(w, "Bad sig", http.StatusBadRequest) + return + } + config, err := loadConfig() + if err != nil { + log.Printf("Error: failed to load config: %s", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + pubKey, ok := config.passkeys[string(user)] + if !ok { + http.Error(w, "No user with the given ID", http.StatusBadRequest) + return + } + clientDataHash := sha256.Sum256(clientData) + if err := verifyPubKey(pubKey, slices.Concat(authData, clientDataHash[:]), sig); err != nil { + http.Error(w, "Challenge failed", http.StatusBadRequest) + return + } + var cd struct { + Type string `json:"type"` + Challenge string `json:"challenge"` + Origin string `json:"origin"` + CrossOrigin bool `json:"crossOrigin"` + } + if err := json.Unmarshal(clientData, &cd); err != nil { + http.Error(w, "Bad client data: invalid JSON", http.StatusBadRequest) + return + } + challenge, err := base64.RawURLEncoding.DecodeString(cd.Challenge) + if err != nil { + http.Error(w, "Bad client data: bad challenge", http.StatusBadRequest) + return + } + challengeMu.Lock() + initiated, ok := challenges[string(challenge)] + delete(challenges, string(challenge)) + challengeMu.Unlock() + if !ok { + http.Error(w, "Request already consumed", http.StatusConflict) + return + } + if time.Since(initiated) > challengeExpiry { + http.Error(w, "Challenge is too old. Please try again", http.StatusConflict) + return + } + if cd.Type != "webauthn.get" || cd.Origin != selfURL.String() || cd.CrossOrigin { + http.Error(w, "Bad client data", http.StatusBadRequest) + return + } + expectedRPHash := sha256.Sum256([]byte(selfURL.Hostname())) + const flagUserPresence, flagUserVerification = 0x01, 0x04 + if len(authData) < 37 || !bytes.Equal(authData[:32], expectedRPHash[:]) || authData[32]&(flagUserPresence|flagUserVerification) != (flagUserPresence|flagUserVerification) { + http.Error(w, "Bad authenticator data", http.StatusBadRequest) + return + } + attachCookie(w, &authToken{ + expiry: time.Now().Add(30 * 24 * time.Hour), + userID: user, + }) + redirect := r.FormValue("redirect") + if !verifyRedirect(redirect) { + redirect = "/" + } + http.Redirect(w, r, redirect, http.StatusSeeOther) +} + +var ( + //go:embed templates/register/register.html.template + registerTemplateString string + registerTemplate = template.Must(outlinedTemplate(registerTemplateString)) +) + +type registerTemplateArgs struct { + ID string + Challenge string +} + +func serveRegister(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, 32+16) + rand.Read(buf) + challenge, userID := buf[:32], buf[32:] + if err := registerTemplate.Execute(w, registerTemplateArgs{ + ID: base64.StdEncoding.EncodeToString(userID), + Challenge: base64.StdEncoding.EncodeToString(challenge), + }); err != nil { + log.Printf("Warning: serveRegister: %s", err) + } +} + +func wrap(s string) string { + out := new(strings.Builder) + for s != "" { + if out.Len() > 0 { + out.WriteString("\n\t ") + } + n := min(50, len(s)) + out.WriteString(s[:n]) + s = s[n:] + } + return out.String() +} + +var ( + //go:embed templates/register/register-success.html.template + registerSuccessTemplateString string + registerSuccessTemplate = template.Must(outlinedTemplate(registerSuccessTemplateString)) +) + +type registerSuccessTemplateArgs struct { + ID string + Key string +} + +func register(w http.ResponseWriter, r *http.Request) { + username := r.FormValue("name") + if username == "" { + http.Error(w, "Passkey name is required", http.StatusBadRequest) + return + } + userID, err := base64.StdEncoding.DecodeString(r.FormValue("id")) + if err != nil { + http.Error(w, "Invalid user ID", http.StatusBadRequest) + return + } + key, err := base64.StdEncoding.DecodeString(r.FormValue("key")) + if err != nil { + http.Error(w, "Invalid key", http.StatusBadRequest) + return + } + if err := registerSuccessTemplate.Execute(w, registerSuccessTemplateArgs{ + ID: r.FormValue("name"), + Key: wrap(base64.RawURLEncoding.EncodeToString(slices.Concat(userID, key))), + }); err != nil { + log.Printf("Warning: register: %s", err) + } +} + +var ( + otpMu sync.Mutex + otpLink string + otpGenerated time.Time +) + +var ( + //go:embed templates/otp.html.template + otpTemplateString string + otpTemplate = template.Must(outlinedTemplate(otpTemplateString)) +) + +type otpTemplateArgs struct { + URL string + QR string +} + +func serveOTP(w http.ResponseWriter, r *http.Request) { + const nWords = 5 + buf := make([]byte, 2*nWords) + rand.Read(buf) + words := make([]string, nWords) + for i := range len(buf) / 2 { + words[i] = wordlist.Words[binary.NativeEndian.Uint16(buf[2*i:])&0x1fff] + } + l := strings.Join(words, "-") + otpMu.Lock() + otpLink = l + otpGenerated = time.Now() + otpMu.Unlock() + otpURL := selfURL.String() + "/otp/" + l + if err := otpTemplate.Execute(w, otpTemplateArgs{ + URL: otpURL, + QR: "/qr.png?t=" + url.QueryEscape(signMsg(otpURL, "qr")), + }); err != nil { + log.Printf("Warning: serveOTP: %s", err) + } +} + +func otpLogin(w http.ResponseWriter, r *http.Request) { + otpMu.Lock() + myOTP := otpLink + otpLink = "" + generated := otpGenerated + otpMu.Unlock() + reqOTP := r.PathValue("otp") + if reqOTP == "" { + notFound(w, r) + return + } + myOTPHash := sha256.Sum256([]byte(myOTP)) + reqOTPHash := sha256.Sum256([]byte(reqOTP)) + if !hmac.Equal(myOTPHash[:], reqOTPHash[:]) { + notFound(w, r) + return + } + if time.Since(generated) > 10*time.Minute { + http.Error(w, "OTP is too old. Please generate a new link", http.StatusConflict) + return + } + attachCookie(w, &authToken{expiry: time.Now().Add(24 * time.Hour)}) + fmt.Fprintf(w, "Temporary 24 hour token issued. You are now logged in.\n") +} + +func logout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, makeHTTPCookie("!", time.Now())) + fmt.Fprintf(w, "You are now logged out.\n") +} + +var ( + //go:embed templates/note.html.template + notepadString string + notepadTemplate = template.Must(outlinedTemplate(notepadString)) + + notepadContentsMu sync.Mutex + notepadContents string +) + +type notepadTemplateArgs struct { + Content string +} + +func notepad(w http.ResponseWriter, r *http.Request) { + notepadContentsMu.Lock() + currentContent := notepadContents + notepadContentsMu.Unlock() + if err := notepadTemplate.Execute(w, notepadTemplateArgs{Content: currentContent}); err != nil { + log.Printf("Warning: notepad: %s", err) + } +} + +func autosave(w http.ResponseWriter, r *http.Request) { + newContent := r.FormValue("content") + notepadContentsMu.Lock() + notepadContents = newContent + notepadContentsMu.Unlock() + if err := notepadTemplate.ExecuteTemplate(w, "saveIndicator", "Saved ✓"); err != nil { + log.Printf("Warning: autosave: %s", err) + } +} + +func cmd(w http.ResponseWriter, r *http.Request) { + serviceConfig, err := loadConfig() + if err != nil { + log.Printf("Error: failed to load config: %s", err) + http.Error(w, "Failed to load config", http.StatusInternalServerError) + return + } + sourceIdx, ok := slices.BinarySearchFunc(serviceConfig.CommandRedirects, r.PathValue("cmd"), func(cmd commandConfig, name string) int { return strings.Compare(cmd.Name, name) }) + if !ok { + notFound(w, r) + return + } + if r.FormValue("go-get") == "1" { + fmt.Fprintf(w, "\n", + r.PathValue("cmd"), + serviceConfig.CommandRedirects[sourceIdx].URL) + if source := serviceConfig.CommandRedirects[sourceIdx].Source; source != "" { + fmt.Fprintf(w, "\n", + r.PathValue("cmd"), + source) + } + } else { + http.Redirect(w, r, "https://pkg.go.dev/roseh.moe/cmd/"+r.PathValue("cmd"), http.StatusMovedPermanently) + } +} + +var ( + //go:embed templates/pkg-index.html.template + pkgIndexString string + pkgIndexTemplate = template.Must(outlinedTemplate(pkgIndexString)) +) + +type pkgIndexTemplateArgs struct { + Packages []packageConfig +} + +func pkgIndex(w http.ResponseWriter, r *http.Request) { + serviceConfig, err := loadConfig() + if err != nil { + log.Printf("Error: failed to load config: %s", err) + http.Error(w, "Failed to load config", http.StatusInternalServerError) + return + } + if err := pkgIndexTemplate.Execute(w, pkgIndexTemplateArgs{Packages: serviceConfig.PackageRedirects}); err != nil { + log.Printf("Warning: pkgIndex: %s", err) + } +} + +var ( + //go:embed templates/cmd-index.html.template + cmdIndexString string + cmdIndexTemplate = template.Must(outlinedTemplate(cmdIndexString)) +) + +type cmdIndexTemplateArgs struct { + Commands []commandConfig +} + +func cmdIndex(w http.ResponseWriter, r *http.Request) { + serviceConfig, err := loadConfig() + if err != nil { + log.Printf("Error: failed to load config: %s", err) + http.Error(w, "Failed to load config", http.StatusInternalServerError) + return + } + if err := cmdIndexTemplate.Execute(w, cmdIndexTemplateArgs{Commands: serviceConfig.CommandRedirects}); err != nil { + log.Printf("Warning: pkgIndex: %s", err) + } +} + +func pkg(w http.ResponseWriter, r *http.Request) { + serviceConfig, err := loadConfig() + if err != nil { + log.Printf("Error: failed to load config: %s", err) + http.Error(w, "Failed to load config", http.StatusInternalServerError) + return + } + sourceIdx, ok := slices.BinarySearchFunc(serviceConfig.PackageRedirects, r.PathValue("pkg"), func(pkg packageConfig, name string) int { return strings.Compare(pkg.Name, name) }) + if !ok { + notFound(w, r) + return + } + if r.FormValue("go-get") == "1" { + fmt.Fprintf(w, "\n", r.PathValue("pkg"), serviceConfig.PackageRedirects[sourceIdx].URL) + if source := serviceConfig.PackageRedirects[sourceIdx].Source; source != "" { + fmt.Fprintf(w, "\n", + r.PathValue("pkg"), + source) + } + } else { + http.Redirect(w, r, "https://pkg.go.dev/roseh.moe/pkg/"+r.PathValue("pkg"), http.StatusMovedPermanently) + } +} + +type nopCloser struct { + io.Writer +} + +func (nopCloser) Close() error { return nil } + +func (w nopCloser) ReadFrom(r io.Reader) (int64, error) { + return io.Copy(w.Writer, r) +} + +type gzipResponseWriter struct { + http.ResponseWriter + + w io.WriteCloser + buf []byte + code int +} + +func (w *gzipResponseWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + +func shouldGzip(w http.ResponseWriter) bool { + disposition, _, _ := strings.Cut(w.Header().Get("Content-Disposition"), ";") + length, lengthErr := strconv.Atoi(w.Header().Get("Content-Length")) + return strings.HasPrefix(w.Header().Get("Content-Type"), "text/") && + !(disposition == "attachment" && w.Header().Get("Accept-Ranges") != "" || + w.Header().Get("Content-Encoding") != "" || + lengthErr == nil && length < 512) +} + +func (w *gzipResponseWriter) ReadFrom(r io.Reader) (int64, error) { + if w.w != nil { + return io.Copy(w.w, r) + } + var nn int64 + if len(w.Header().Values("Content-Type")) == 0 { + if w.buf == nil { + w.buf = make([]byte, 0, 512) + } + n, err := io.ReadFull(r, w.buf[len(w.buf):cap(w.buf)]) + w.buf = w.buf[:len(w.buf)+n] + nn += int64(n) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return nn, err + } + if len(w.buf) < 512 { + return nn, nil + } + contentType := http.DetectContentType(w.buf) + w.Header().Set("Content-Type", contentType) + } + if shouldGzip(w.ResponseWriter) { + var err error + w.w, err = gzip.NewWriterLevel(w.ResponseWriter, gzip.BestSpeed) + if err != nil { + return nn, err + } + w.Header().Set("Content-Encoding", "gzip") + w.Header().Del("Content-Length") + w.Header().Del("Accept-Ranges") + } else { + w.w = nopCloser{w.ResponseWriter} + } + if w.code != 0 { + w.ResponseWriter.WriteHeader(w.code) + } + if len(w.buf) > 0 { + if n, err := w.w.Write(w.buf); err != nil { + return max(int64(n)-(512-nn), 0), err + } + } + n, err := io.Copy(w.w, r) + nn += n + return nn, err +} + +func (w *gzipResponseWriter) Write(p []byte) (int, error) { + n, err := w.ReadFrom(bytes.NewReader(p)) + return int(n), err +} + +func (w *gzipResponseWriter) WriteHeader(statusCode int) { + w.code = statusCode +} + +func (w *gzipResponseWriter) close() { + if w.w == nil { + if w.code != 0 { + w.ResponseWriter.WriteHeader(w.code) + } + w.ResponseWriter.Write(w.buf) + } else { + w.w.Close() + } +} + +type gzipHandler struct { + h http.Handler +} + +func (h *gzipHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if len(r.Header.Values("Range")) == 0 { + for _, hdr := range r.Header.Values("Accept-Encoding") { + for encoding := range strings.FieldsFuncSeq(hdr, func(r rune) bool { return r == ',' || unicode.IsSpace(r) }) { + encoding, _, _ := strings.Cut(encoding, ";") + if encoding == "gzip" { + gzipWriter := &gzipResponseWriter{ResponseWriter: w} + h.h.ServeHTTP(gzipWriter, r) + gzipWriter.close() + return + } + } + } + } + h.h.ServeHTTP(w, r) +} + +type authHandler struct { + h http.Handler + noRedirect bool +} + +func (h *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !cookieAuth(r) { + if h.noRedirect { + http.Error(w, "not logged in", http.StatusUnauthorized) + } else { + u := *r.URL + u.Host = r.Host + u.Scheme = selfURL.Scheme + http.Redirect(w, r, "//"+selfURL.Host+"/login?redirect="+url.QueryEscape(u.String()), http.StatusSeeOther) + } + return + } + h.h.ServeHTTP(w, r) +} + +type timeoutHandler struct { + h http.Handler + timeout time.Duration +} + +func (h *timeoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + rc := http.NewResponseController(w) + deadline := time.Now().Add(h.timeout) + if err := rc.SetReadDeadline(deadline); err != nil { + log.Printf("Error: set read deadline: %s", err) + } + // Give some extra time to write the error + writeDeadline := deadline.Add(time.Minute) + if err := rc.SetWriteDeadline(writeDeadline); err != nil { + log.Printf("Error: set write deadline: %s", err) + } + ctx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + h.h.ServeHTTP(w, r.WithContext(ctx)) +} + +type loggingHandler struct { + h http.Handler +} + +func (h *loggingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if *v { + log.Printf("%q from %q", r.Method+" "+r.Host+r.URL.String(), r.RemoteAddr) + } + h.h.ServeHTTP(w, r) +} + +type removeAuthCookieHandler struct { + h http.Handler +} + +type hstsHandler struct { + h http.Handler +} + +func (h *hstsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if *https != "" { + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } + h.h.ServeHTTP(w, r) +} + +func (h *removeAuthCookieHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var cookies []*http.Cookie + for _, c := range r.Cookies() { + if c.Name != authCookieName { + cookies = append(cookies, c) + } + } + r.Header.Del("Cookie") + for _, c := range cookies { + r.AddCookie(c) + } + h.h.ServeHTTP(w, r) +} + +func main() { + flag.Parse() + + if err := loadSecrets(); err != nil { + log.Fatal(err) + } + + if err := hole.Init(); err != nil { + log.Fatal(err) + } + + go func() { + for range time.NewTicker(2 * time.Hour).C { + if err := hole.Clean(); err != nil { + log.Printf("Error: clean hole: %s", err) + } + } + }() + + go func() { + for now := range time.NewTicker(time.Hour).C { + log.Printf("Cleaning expired challenges") + challengeMu.Lock() + for challenge, initiated := range challenges { + if now.Sub(initiated) > challengeExpiry { + delete(challenges, challenge) + } + } + challengeMu.Unlock() + } + }() + + mux := http.NewServeMux() + indexTemplate, err := outlinedTemplate(indexString) + if err != nil { + log.Fatal(err) + } + indexHandler, err := staticTemplate(indexTemplate) + if err != nil { + log.Fatal(err) + } + mux.Handle("GET /{$}", &timeoutHandler{h: indexHandler, timeout: 5 * time.Minute}) + gamesIndexTemplate, err := outlinedTemplate(gamesIndexString) + if err != nil { + log.Fatal(err) + } + gamesIndexHandler, err := staticTemplate(gamesIndexTemplate) + if err != nil { + log.Fatal(err) + } + mux.Handle("GET /games/{$}", &timeoutHandler{h: gamesIndexHandler, timeout: 5 * time.Minute}) + pongTemplate, err := template.New("pong").Parse(pongString) + if err != nil { + log.Fatal(err) + } + pongHandler, err := staticTemplate(pongTemplate) + if err != nil { + log.Fatal(err) + } + mux.Handle("GET /games/pong", &timeoutHandler{h: pongHandler, timeout: 5 * time.Minute}) + stallmanShooterTemplate, err := outlinedTemplate(stallmanShooterString) + if err != nil { + log.Fatal(err) + } + stallmanShooterHandler, err := staticTemplate(stallmanShooterTemplate) + if err != nil { + log.Fatal(err) + } + mux.Handle("GET /games/rms", &timeoutHandler{h: stallmanShooterHandler, timeout: 5 * time.Minute}) + mux.Handle("GET /qr.png", &timeoutHandler{h: http.HandlerFunc(qrHandler), timeout: 5 * time.Minute}) + mux.Handle("GET /wormhole", &timeoutHandler{h: http.HandlerFunc(wormhole), timeout: 5 * time.Minute}) + mux.HandleFunc("POST /wormhole", wormholeUpload) + mux.HandleFunc("GET /wormhole/{hole}", wormholeDownload) + mux.Handle("GET /login", &timeoutHandler{h: http.HandlerFunc(serveLogin), timeout: 5 * time.Minute}) + mux.Handle("POST /login", &timeoutHandler{h: http.HandlerFunc(login), timeout: 5 * time.Minute}) + mux.Handle("GET /logout", &timeoutHandler{h: http.HandlerFunc(logout), timeout: 5 * time.Minute}) + mux.Handle("GET /register", &timeoutHandler{h: http.HandlerFunc(serveRegister), timeout: 5 * time.Minute}) + mux.Handle("POST /register", &timeoutHandler{h: http.HandlerFunc(register), timeout: 5 * time.Minute}) + mux.Handle("GET /otp", &timeoutHandler{h: &authHandler{h: http.HandlerFunc(serveOTP)}, timeout: 5 * time.Minute}) + mux.Handle("GET /otp/{otp}", &timeoutHandler{h: http.HandlerFunc(otpLogin), timeout: 5 * time.Minute}) + mux.Handle("GET /notepad", &timeoutHandler{h: &authHandler{h: http.HandlerFunc(notepad)}, timeout: 5 * time.Minute}) + mux.Handle("POST /notepad", &timeoutHandler{h: &authHandler{h: http.HandlerFunc(autosave), noRedirect: true}, timeout: 5 * time.Minute}) + mux.Handle("GET /cmd/{$}", &timeoutHandler{h: http.HandlerFunc(cmdIndex), timeout: 5 * time.Minute}) + mux.Handle("GET /cmd/{cmd}", &timeoutHandler{h: http.HandlerFunc(cmd), timeout: 5 * time.Minute}) + mux.Handle("GET /pkg/{$}", &timeoutHandler{h: http.HandlerFunc(pkgIndex), timeout: 5 * time.Minute}) + mux.Handle("GET /pkg/{pkg}", &timeoutHandler{h: http.HandlerFunc(pkg), timeout: 5 * time.Minute}) + mux.Handle("GET /nezuko/", &timeoutHandler{h: &authHandler{h: http.StripPrefix("/nezuko/", http.FileServer(http.Dir(*nezuko)))}, timeout: 10 * time.Minute}) + mux.Handle("GET /static/", &timeoutHandler{h: http.HandlerFunc(static), timeout: 5 * time.Minute}) + mux.Handle("GET /favicon.ico", &timeoutHandler{h: http.HandlerFunc(favicon), timeout: 5 * time.Minute}) + apiMux := http.NewServeMux() + apiMux.HandleFunc("PUT /api/wormhole/{name}", wormholeAPIUpload) + mux.Handle("/api/", apiMux) + mux.Handle("/", &timeoutHandler{h: http.HandlerFunc(sharedFileHandler), timeout: 5 * time.Minute}) + serverMux := http.NewServeMux() + serverMux.Handle(selfURL.Hostname()+"/", &gzipHandler{mux}) + jellyfinURL, err := url.Parse("http://127.0.0.1:8096") + if err != nil { + log.Fatal(err) + } + serverMux.Handle("cinema."+selfURL.Hostname()+"/", &authHandler{h: &removeAuthCookieHandler{&httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + r.SetURL(jellyfinURL) + r.SetXForwarded() + }, + }}}) + codeMux := http.NewServeMux() + cgitStaticFiles := http.FileServer(http.Dir(*cgit)) + codeMux.Handle("/cgit.css", &timeoutHandler{h: cgitStaticFiles, timeout: 5 * time.Minute}) + codeMux.Handle("/cgit.js", &timeoutHandler{h: cgitStaticFiles, timeout: 5 * time.Minute}) + codeMux.Handle("/robots.txt", &timeoutHandler{h: cgitStaticFiles, timeout: 5 * time.Minute}) + codeMux.Handle("/favicon.ico", &timeoutHandler{h: http.HandlerFunc(favicon), timeout: 5 * time.Minute}) + cgitConfigAbsPath, err := filepath.Abs(*cgitConfig) + if err != nil { + log.Fatal(err) + } + cgitCGI := &cgi.Handler{ + Path: filepath.Join(*cgit, "cgit.cgi"), + Env: []string{"CGIT_CONFIG=" + cgitConfigAbsPath}, + } + cgitCGI.PathLocationHandler = cgitCGI + codeMux.Handle("/", &timeoutHandler{h: &removeAuthCookieHandler{cgitCGI}, timeout: 5 * time.Minute}) + serverMux.Handle("code."+selfURL.Hostname()+"/", &gzipHandler{codeMux}) + transmissionURL, err := url.Parse("http://127.0.0.1:9091") + if err != nil { + log.Fatal(err) + } + serverMux.Handle("downloads."+selfURL.Hostname()+"/", &authHandler{h: &removeAuthCookieHandler{&httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + r.SetURL(transmissionURL) + r.SetXForwarded() + }, + }}}) + server := &http.Server{ + Handler: &loggingHandler{http.NewCrossOriginProtection().Handler(&hstsHandler{serverMux})}, + + ReadHeaderTimeout: 5 * time.Minute, + IdleTimeout: 10 * time.Minute, + } + + if *httpRedirect != "" { + go func() { + mux := http.NewServeMux() + mux.Handle("GET /.well-known/acme-challenge/", &timeoutHandler{ + h: http.FileServer(http.Dir("/var/lib/acme/acme-challenge")), + timeout: 5 * time.Minute, + }) + mux.Handle("/", &timeoutHandler{ + h: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u := *r.URL + u.Scheme = selfURL.Scheme + u.Host, _, _ = strings.Cut(r.Host, ":") + if port := selfURL.Port(); port != "" { + u.Host += ":" + port + } + http.Redirect(w, r, u.String(), http.StatusMovedPermanently) + }), + timeout: time.Minute, + }) + httpSrv := &http.Server{ + Addr: ":" + *httpRedirect, + Handler: &loggingHandler{mux}, + ReadHeaderTimeout: 5 * time.Minute, + IdleTimeout: 10 * time.Minute, + } + log.Printf("Serving HTTP redirect on %q", httpSrv.Addr) + err := httpSrv.ListenAndServe() + log.Printf("Error: HTTP redirect failed: %s", err) + }() + } + + port := selfURL.Port() + if port == "" { + port = "80" + if *https != "" { + port = "443" + } + } + server.Addr = ":" + port + log.Printf("Listening on %q", server.Addr) + if *https != "" { + log.Fatal(server.ListenAndServeTLS(*https+"/cert.pem", *https+"/key.pem")) + } else { + log.Fatal(server.ListenAndServe()) + } +} diff --git a/roseh.moe.go b/roseh.moe.go deleted file mode 100644 index e8fb802..0000000 --- a/roseh.moe.go +++ /dev/null @@ -1,1410 +0,0 @@ -// Copyright 2026 Rose Hogenson -// -// This file is part of roseh.moe. -// -// roseh.moe is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// roseh.moe is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty -// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public -// License along with roseh.moe. If not, see -// . - -package main - -import ( - "bytes" - "compress/gzip" - "context" - "crypto" - "crypto/ecdsa" - "crypto/hmac" - "crypto/rand" - "crypto/rsa" - "crypto/sha256" - "crypto/x509" - "embed" - "encoding/base64" - "encoding/binary" - "encoding/json/v2" - "encoding/xml" - "flag" - "fmt" - "html/template" - "io" - "io/fs" - "log" - "net/http" - "net/http/cgi" - "net/http/httputil" - "net/url" - "os" - "path" - "path/filepath" - "slices" - "strconv" - "strings" - "sync" - "time" - "unicode" - - "github.com/skip2/go-qrcode" - "roseh.moe/pkg/roseh.moe/internal/hole" - "roseh.moe/pkg/wordlist" -) - -var ( - selfURL = urlFlag("url", "http://localhost:42069", "base URL of the server") - secretsFile = flag.String("secrets", "secrets.xml", "secrets file") - configFile = flag.String("config", "config.xml", "configuration file (XML, baby!!)") - https = flag.String("https", "", "directory containing cert.pem and key.pem, or empty string to use unencrypted http") - httpRedirect = flag.String("http-redirect", "", "port to serve HTTP redirect") - nezuko = flag.String("nezuko", "/mnt/nezuko", "nezuko partition") - cgit = flag.String("cgit", "", "directory containing cgit.cgi") - cgitConfig = flag.String("cgit-config", "", "cgit config file") - v = flag.Bool("v", true, "verbose") -) - -func init() { - flag.StringVar(&hole.HoleTempDir, "hole-temp-dir", "/tmp/hole", "directory to store temporary files for the wormhole") -} - -type urlValue struct { - URL *url.URL -} - -func (v urlValue) String() string { - if v.URL != nil { - return v.URL.String() - } - return "" -} - -func (v urlValue) Set(s string) error { - u, err := url.Parse(s) - if err != nil { - return err - } - *v.URL = *u - return nil -} - -func urlFlag(name, def, doc string) *url.URL { - u, err := url.Parse(def) - if err != nil { - panic(err) - } - flag.Var(urlValue{u}, name, doc) - return u -} - -func sigVerifier(keyBytes []byte) (func(msg, sig []byte) bool, error) { - key, err := x509.ParsePKIXPublicKey(keyBytes) - if err != nil { - return nil, err - } - switch key := key.(type) { - case *ecdsa.PublicKey: - return func(msg, sig []byte) bool { - hash := sha256.Sum256(msg) - return ecdsa.VerifyASN1(key, hash[:], sig) - }, nil - case *rsa.PublicKey: - return func(msg, sig []byte) bool { - hash := sha256.Sum256(msg) - return rsa.VerifyPKCS1v15(key, crypto.SHA256, hash[:], sig) == nil - }, nil - default: - return nil, fmt.Errorf("unsupported key type") - } -} - -func verifyPubKey(pubKey, msg, sig []byte) error { - verify, err := sigVerifier(pubKey) - if err != nil { - return err - } - if !verify(msg, sig) { - return fmt.Errorf("message failed validation") - } - return nil -} - -var secretKey []byte - -func loadSecrets() error { - fileBytes, err := os.ReadFile(*secretsFile) - if err != nil { - return err - } - var secretsConfig struct { - SecretKey string `xml:",attr"` - } - if err := xml.Unmarshal(fileBytes, &secretsConfig); err != nil { - return fmt.Errorf("%s: %s", *secretsFile, err) - } - if secretsConfig.SecretKey == "" { - return fmt.Errorf("%s: missing SecretKey", *secretsFile) - } - if secretKey, err = base64.StdEncoding.DecodeString(secretsConfig.SecretKey); err != nil { - return fmt.Errorf("%s: bad SecretKey (try base64)", *secretsFile) - } - return nil -} - -type packageConfig struct { - Name string `xml:",attr"` - URL string `xml:",attr"` - Source string `xml:",attr"` - Description string `xml:",attr"` -} - -type commandConfig struct { - Name string `xml:",attr"` - URL string `xml:",attr"` - Source string `xml:",attr"` - Description string `xml:",attr"` - Multi bool `xml:",attr"` -} - -type passkey struct { - ID string `xml:",attr"` - Key string `xml:",attr"` -} - -type serviceConfiguration struct { - PackageRedirects []packageConfig `xml:"Redirects>Package"` - CommandRedirects []commandConfig `xml:"Redirects>Command"` - Passkey []passkey - passkeys map[string][]byte -} - -var ( - serviceConfigMu sync.Mutex - serviceConfig *serviceConfiguration - serviceConfigLastModified time.Time -) - -func loadConfig() (*serviceConfiguration, error) { - stat, err := os.Stat(*configFile) - if err != nil { - return nil, err - } - serviceConfigMu.Lock() - oldConfig := serviceConfig - configLastModified := serviceConfigLastModified - serviceConfigMu.Unlock() - newModTime := stat.ModTime() - if !newModTime.After(configLastModified) { - return oldConfig, nil - } - log.Printf("Reloading config file...") - fileBytes, err := os.ReadFile(*configFile) - if err != nil { - return nil, err - } - config := new(serviceConfiguration) - if err := xml.Unmarshal(fileBytes, config); err != nil { - return nil, err - } - names := make(map[string]bool) - config.passkeys = make(map[string][]byte) - namesByID := make(map[string]string) - for _, passkey := range config.Passkey { - if names[passkey.ID] { - return nil, fmt.Errorf("%s: duplicate name %q", *configFile, passkey.ID) - } - names[passkey.ID] = true - keyBytes, err := base64.RawURLEncoding.DecodeString(strings.Map(func(r rune) rune { - if unicode.IsSpace(r) { - return -1 - } - return r - }, passkey.Key)) - if err != nil { - return nil, fmt.Errorf("%s: invalid key for passkey %q: %s", *configFile, passkey.ID, err) - } - if len(keyBytes) < 16 { - return nil, fmt.Errorf("%s: invalid key for passkey %q: bad user ID: too short", *configFile, passkey.ID) - } - id, key := keyBytes[:16], keyBytes[16:] - if _, err := sigVerifier(key); err != nil { - return nil, fmt.Errorf("%s: invalid key for passkey %q: %s", *configFile, passkey.ID, err) - } - if _, ok := config.passkeys[string(id)]; ok { - return nil, fmt.Errorf("%s: passkeys %q and %q have duplicate ID %d", *configFile, namesByID[string(id)], passkey.ID, id) - } - config.passkeys[string(id)] = key - namesByID[string(id)] = passkey.ID - } - slices.SortFunc(config.PackageRedirects, func(x, y packageConfig) int { return strings.Compare(x.Name, y.Name) }) - slices.SortFunc(config.CommandRedirects, func(x, y commandConfig) int { return strings.Compare(x.Name, y.Name) }) - serviceConfigMu.Lock() - serviceConfig = config - serviceConfigLastModified = newModTime - serviceConfigMu.Unlock() - return config, nil -} - -func makeETag(content io.Reader) (string, error) { - h := sha256.New() - if _, err := io.Copy(h, content); err != nil { - return "", err - } - return `W/"` + base64.RawURLEncoding.EncodeToString(h.Sum(nil)[:8]) + `"`, nil -} - -var ( - //go:embed templates/outline.html.template - outlineString string - outlineTemplate = template.Must(template.New("outline").Funcs(template.FuncMap{ - "host": func() string { - return selfURL.Host - }, - "xmlEscape": func(s string) string { - buf := new(strings.Builder) - xml.EscapeText(buf, []byte(s)) - return buf.String() - }, - }).Parse(outlineString)) -) - -func outlinedTemplate(s string) (*template.Template, error) { - outline, err := outlineTemplate.Clone() - if err != nil { - return nil, err - } - t, err := outline.New("body").Parse(s) - if err != nil { - return nil, err - } - return t.Lookup("outline"), nil -} - -type staticHandler struct { - content, eTag string -} - -func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - w.Header().Set("ETag", h.eTag) - http.ServeContent(w, r, "", time.Time{}, strings.NewReader(h.content)) -} - -func staticTemplate(tmpl *template.Template) (*staticHandler, error) { - buf := new(strings.Builder) - if err := tmpl.Execute(buf, nil); err != nil { - return nil, err - } - content := buf.String() - eTag, err := makeETag(strings.NewReader(content)) - if err != nil { - return nil, err - } - return &staticHandler{ - content: buf.String(), - eTag: eTag, - }, nil -} - -var ( - //go:embed templates/404.html.template - notFoundString string - notFoundTemplate = template.Must(template.New("notFound").Parse(notFoundString)) -) - -func notFound(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - type args struct { - Path string - } - if err := notFoundTemplate.Execute(w, args{Path: r.URL.Path}); err != nil { - log.Printf("Warning: notFound: %s", err) - } -} - -//go:embed templates/index.html.template -var indexString string - -//go:embed templates/games/index.html.template -var gamesIndexString string - -//go:embed templates/games/pong.html.template -var pongString string - -//go:embed templates/games/rms.html.template -var stallmanShooterString string - -//go:embed static -var staticFiles embed.FS - -var staticFileHashes = buildStaticFileHashes() - -func buildStaticFileHashes() map[string]string { - hashMap := make(map[string]string) - if err := fs.WalkDir(staticFiles, ".", func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } - f, err := staticFiles.Open(path) - if err != nil { - return err - } - defer f.Close() - if hashMap[path], err = makeETag(f); err != nil { - return err - } - return nil - }); err != nil { - panic(fmt.Sprintf("Build static file hash map: %s", err)) - } - return hashMap -} - -func serveStaticFile(w http.ResponseWriter, r *http.Request, path string) { - if eTag, ok := staticFileHashes[path]; ok { - w.Header().Set("ETag", eTag) - } - http.ServeFileFS(w, r, staticFiles, path) -} - -func static(w http.ResponseWriter, r *http.Request) { - serveStaticFile(w, r, path.Clean(strings.TrimPrefix(r.URL.Path, "/"))) -} - -func favicon(w http.ResponseWriter, r *http.Request) { - serveStaticFile(w, r, "static/favicon.ico") -} - -func sharedFileHandler(w http.ResponseWriter, r *http.Request) { - fp, err := filepath.Localize(path.Clean(strings.TrimPrefix(r.URL.Path, "/"))) - if err != nil { - notFound(w, r) - return - } - f, err := os.OpenInRoot(filepath.Join(*nezuko, "www"), fp) - if err != nil { - notFound(w, r) - return - } - defer f.Close() - stat, err := f.Stat() - if err != nil { - log.Printf("Warning: sharedFileHandler: %s", err) - notFound(w, r) - return - } - if stat.IsDir() { - notFound(w, r) - return - } - http.ServeContent(w, r, fp, stat.ModTime(), f) -} - -func qrHandler(w http.ResponseWriter, r *http.Request) { - text, ok := verifyHMAC(r.FormValue("t"), "qr") - if !ok { - http.Error(w, "Bad signature", http.StatusBadRequest) - return - } - qr, err := qrcode.Encode(text, qrcode.Medium, 200) - if err != nil { - log.Printf("Error: failed to encode QR code: %s", err) - http.Error(w, "Failed to encode QR code", http.StatusInternalServerError) - return - } - http.ServeContent(w, r, "qr.png", time.Time{}, bytes.NewReader(qr)) -} - -var ( - //go:embed templates/wormhole.html.template - wormholeTemplateString string - wormholeTemplate = template.Must(outlinedTemplate(wormholeTemplateString)) -) - -type wormholeTemplateArgs struct { - Err string -} - -func readHoleFile(w http.ResponseWriter, req *http.Request, holeName string) error { - fileName, r, err := hole.Open(holeName) - if err != nil { - return err - } - defer r.Close() - w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+fileName) - var modTime time.Time - if stat, err := r.F.Stat(); err == nil { - modTime = stat.ModTime() - } - http.ServeContent(w, req, fileName, modTime, r) - return nil -} - -func wormhole(w http.ResponseWriter, r *http.Request) { - if err := wormholeTemplate.Execute(w, wormholeTemplateArgs{}); err != nil { - log.Printf("Warning: wormhole: %s", err) - } -} - -var ( - //go:embed templates/wormhole-success.html.template - wormholeSuccessTemplateString string - wormholeSuccessTemplate = template.Must(outlinedTemplate(wormholeSuccessTemplateString)) -) - -type wormholeSuccessTemplateArgs struct { - URL string - QR string -} - -func wormholeUpload(w http.ResponseWriter, r *http.Request) { - reader, err := r.MultipartReader() - if err != nil { - http.Error(w, fmt.Sprintf("Not a multipart/form-data request: %s", err), http.StatusBadRequest) - return - } - holeName, err := hole.Upload(reader) - if err != nil { - if err == hole.ErrNoFile { - w.WriteHeader(http.StatusBadRequest) - if err := wormholeTemplate.Execute(w, wormholeTemplateArgs{Err: "don't forget to pick a file"}); err != nil { - log.Printf("Warning: wormholeTemplate: %s", err) - } - return - } - log.Printf("Error: failed to upload hole file: %s", err) - http.Error(w, "Internal error", http.StatusInternalServerError) - return - } - downloadURL := selfURL.String() + "/wormhole/" + holeName - if err := wormholeSuccessTemplate.Execute(w, wormholeSuccessTemplateArgs{ - URL: downloadURL, - QR: "/qr.png?t=" + url.QueryEscape(signMsg(downloadURL, "qr")), - }); err != nil { - log.Printf("Warning: wormholeSend: %s", err) - } -} - -func wormholeAPIUpload(w http.ResponseWriter, r *http.Request) { - hole, err := hole.WriteSingleFile(r.PathValue("name"), r.Body) - if err != nil { - log.Printf("Error: failed to write hole file: %s", err) - http.Error(w, "Failed to write hole file", http.StatusInternalServerError) - return - } - resultURL := fmt.Sprintf("%s/wormhole/%s", selfURL, hole) - w.Header().Set("Content-Type", "text/plain") - w.Header().Set("Location", resultURL) - w.WriteHeader(http.StatusCreated) - fmt.Fprintln(w, resultURL) -} - -func wormholeDownload(w http.ResponseWriter, r *http.Request) { - if err := readHoleFile(w, r, r.PathValue("hole")); err != nil { - if err == hole.ErrNoHole { - notFound(w, r) - return - } - log.Printf("Error: failed to read hole file: %s", err) - http.Error(w, "Failed to read hole file", http.StatusInternalServerError) - return - } -} - -func mac(msg []byte, purpose string) []byte { - mac := hmac.New(sha256.New, secretKey) - io.WriteString(mac, purpose) - io.WriteString(mac, ":") - mac.Write(msg) - return mac.Sum(nil) -} - -func signMsg(msg, purpose string) string { - return msg + base64.RawURLEncoding.EncodeToString(mac([]byte(msg), purpose)) -} - -func verifyHMAC(signedMsg, purpose string) (string, bool) { - macSize := base64.RawURLEncoding.EncodedLen(sha256.Size) - if len(signedMsg) < macSize { - return "", false - } - msg, msgMAC := signedMsg[:len(signedMsg)-macSize], signedMsg[len(signedMsg)-macSize:] - expectedMAC := base64.RawURLEncoding.EncodeToString(mac([]byte(msg), purpose)) - if !hmac.Equal([]byte(msgMAC), []byte(expectedMAC)) { - return "", false - } - return msg, true -} - -const ( - authVersion = "authv3" - authCookieName = "__Secure-a" -) - -func makeHTTPCookie(token string, expiry time.Time) *http.Cookie { - return &http.Cookie{ - Name: authCookieName, - Value: token, - Path: "/", - Domain: selfURL.Hostname(), - Expires: expiry, - Secure: true, - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - Partitioned: true, - } -} - -type authToken struct { - expiry time.Time - userID []byte -} - -func attachCookie(w http.ResponseWriter, tok *authToken) { - buf := binary.AppendVarint(nil, tok.expiry.Unix()) - buf = append(buf, tok.userID...) - token := signMsg(base64.RawURLEncoding.EncodeToString(buf), authVersion) - http.SetCookie(w, makeHTTPCookie(token, tok.expiry)) -} - -func cookieAuth(r *http.Request) bool { - cookie, err := r.Cookie(authCookieName) - if err != nil { - return false - } - msg, ok := verifyHMAC(cookie.Value, authVersion) - if !ok { - return false - } - msgBytes, err := base64.RawURLEncoding.DecodeString(msg) - if err != nil { - return false - } - expiryUnix, n := binary.Varint(msgBytes) - if n <= 0 { - return false - } - msgBytes = msgBytes[n:] - expiry := time.Unix(expiryUnix, 0) - if time.Now().After(expiry) { - return false - } - if len(msgBytes) > 0 { - if len(msgBytes) < 16 { - return false - } - userID := msgBytes - config, err := loadConfig() - if err != nil { - log.Printf("Error: failed to load config: %s", err) - return false - } - if _, ok := config.passkeys[string(userID)]; !ok { - return false - } - } - return true -} - -func verifyRedirect(redirect string) bool { - u, err := url.Parse(redirect) - if err != nil { - return false - } - switch u.Host { - case - "cinema." + selfURL.Host, - "downloads." + selfURL.Host, - selfURL.Host: - - return true - } - return false -} - -const challengeExpiry = 10 * time.Minute - -var ( - challengeMu sync.Mutex - challenges = make(map[string]time.Time) -) - -var ( - //go:embed templates/login.html.template - loginTemplateString string - loginTemplate = template.Must(template.New("login").Parse(loginTemplateString)) -) - -type loginTemplateArgs struct { - Challenge string -} - -func serveLogin(w http.ResponseWriter, r *http.Request) { - if cookieAuth(r) { - redirect := r.FormValue("redirect") - if !verifyRedirect(redirect) { - redirect = "/" - } - http.Redirect(w, r, redirect, http.StatusSeeOther) - return - } - buf := make([]byte, 32) - rand.Read(buf) - now := time.Now() - challengeMu.Lock() - if len(challenges) >= 100 { - challengeMu.Unlock() - http.Error(w, "Too many active requests", http.StatusTooManyRequests) - return - } - challenges[string(buf)] = now - challengeMu.Unlock() - if err := loginTemplate.Execute(w, loginTemplateArgs{ - Challenge: base64.StdEncoding.EncodeToString(buf), - }); err != nil { - log.Printf("Warning: login: %s", err) - } -} - -func login(w http.ResponseWriter, r *http.Request) { - user, err := base64.StdEncoding.DecodeString(r.FormValue("user")) - if err != nil { - http.Error(w, "Bad user", http.StatusBadRequest) - return - } - authData, err := base64.StdEncoding.DecodeString(r.FormValue("auth-data")) - if err != nil { - http.Error(w, "Bad auth data", http.StatusBadRequest) - return - } - clientData, err := base64.StdEncoding.DecodeString(r.FormValue("client-data")) - if err != nil { - http.Error(w, "Bad client data: invalid base64", http.StatusBadRequest) - return - } - sig, err := base64.StdEncoding.DecodeString(r.FormValue("sig")) - if err != nil { - http.Error(w, "Bad sig", http.StatusBadRequest) - return - } - config, err := loadConfig() - if err != nil { - log.Printf("Error: failed to load config: %s", err) - http.Error(w, "Internal error", http.StatusInternalServerError) - return - } - pubKey, ok := config.passkeys[string(user)] - if !ok { - http.Error(w, "No user with the given ID", http.StatusBadRequest) - return - } - clientDataHash := sha256.Sum256(clientData) - if err := verifyPubKey(pubKey, slices.Concat(authData, clientDataHash[:]), sig); err != nil { - http.Error(w, "Challenge failed", http.StatusBadRequest) - return - } - var cd struct { - Type string `json:"type"` - Challenge string `json:"challenge"` - Origin string `json:"origin"` - CrossOrigin bool `json:"crossOrigin"` - } - if err := json.Unmarshal(clientData, &cd); err != nil { - http.Error(w, "Bad client data: invalid JSON", http.StatusBadRequest) - return - } - challenge, err := base64.RawURLEncoding.DecodeString(cd.Challenge) - if err != nil { - http.Error(w, "Bad client data: bad challenge", http.StatusBadRequest) - return - } - challengeMu.Lock() - initiated, ok := challenges[string(challenge)] - delete(challenges, string(challenge)) - challengeMu.Unlock() - if !ok { - http.Error(w, "Request already consumed", http.StatusConflict) - return - } - if time.Since(initiated) > challengeExpiry { - http.Error(w, "Challenge is too old. Please try again", http.StatusConflict) - return - } - if cd.Type != "webauthn.get" || cd.Origin != selfURL.String() || cd.CrossOrigin { - http.Error(w, "Bad client data", http.StatusBadRequest) - return - } - expectedRPHash := sha256.Sum256([]byte(selfURL.Hostname())) - const flagUserPresence, flagUserVerification = 0x01, 0x04 - if len(authData) < 37 || !bytes.Equal(authData[:32], expectedRPHash[:]) || authData[32]&(flagUserPresence|flagUserVerification) != (flagUserPresence|flagUserVerification) { - http.Error(w, "Bad authenticator data", http.StatusBadRequest) - return - } - attachCookie(w, &authToken{ - expiry: time.Now().Add(30 * 24 * time.Hour), - userID: user, - }) - redirect := r.FormValue("redirect") - if !verifyRedirect(redirect) { - redirect = "/" - } - http.Redirect(w, r, redirect, http.StatusSeeOther) -} - -var ( - //go:embed templates/register/register.html.template - registerTemplateString string - registerTemplate = template.Must(outlinedTemplate(registerTemplateString)) -) - -type registerTemplateArgs struct { - ID string - Challenge string -} - -func serveRegister(w http.ResponseWriter, r *http.Request) { - buf := make([]byte, 32+16) - rand.Read(buf) - challenge, userID := buf[:32], buf[32:] - if err := registerTemplate.Execute(w, registerTemplateArgs{ - ID: base64.StdEncoding.EncodeToString(userID), - Challenge: base64.StdEncoding.EncodeToString(challenge), - }); err != nil { - log.Printf("Warning: serveRegister: %s", err) - } -} - -func wrap(s string) string { - out := new(strings.Builder) - for s != "" { - if out.Len() > 0 { - out.WriteString("\n\t ") - } - n := min(50, len(s)) - out.WriteString(s[:n]) - s = s[n:] - } - return out.String() -} - -var ( - //go:embed templates/register/register-success.html.template - registerSuccessTemplateString string - registerSuccessTemplate = template.Must(outlinedTemplate(registerSuccessTemplateString)) -) - -type registerSuccessTemplateArgs struct { - ID string - Key string -} - -func register(w http.ResponseWriter, r *http.Request) { - username := r.FormValue("name") - if username == "" { - http.Error(w, "Passkey name is required", http.StatusBadRequest) - return - } - userID, err := base64.StdEncoding.DecodeString(r.FormValue("id")) - if err != nil { - http.Error(w, "Invalid user ID", http.StatusBadRequest) - return - } - key, err := base64.StdEncoding.DecodeString(r.FormValue("key")) - if err != nil { - http.Error(w, "Invalid key", http.StatusBadRequest) - return - } - if err := registerSuccessTemplate.Execute(w, registerSuccessTemplateArgs{ - ID: r.FormValue("name"), - Key: wrap(base64.RawURLEncoding.EncodeToString(slices.Concat(userID, key))), - }); err != nil { - log.Printf("Warning: register: %s", err) - } -} - -var ( - otpMu sync.Mutex - otpLink string - otpGenerated time.Time -) - -var ( - //go:embed templates/otp.html.template - otpTemplateString string - otpTemplate = template.Must(outlinedTemplate(otpTemplateString)) -) - -type otpTemplateArgs struct { - URL string - QR string -} - -func serveOTP(w http.ResponseWriter, r *http.Request) { - const nWords = 5 - buf := make([]byte, 2*nWords) - rand.Read(buf) - words := make([]string, nWords) - for i := range len(buf) / 2 { - words[i] = wordlist.Words[binary.NativeEndian.Uint16(buf[2*i:])&0x1fff] - } - l := strings.Join(words, "-") - otpMu.Lock() - otpLink = l - otpGenerated = time.Now() - otpMu.Unlock() - otpURL := selfURL.String() + "/otp/" + l - if err := otpTemplate.Execute(w, otpTemplateArgs{ - URL: otpURL, - QR: "/qr.png?t=" + url.QueryEscape(signMsg(otpURL, "qr")), - }); err != nil { - log.Printf("Warning: serveOTP: %s", err) - } -} - -func otpLogin(w http.ResponseWriter, r *http.Request) { - otpMu.Lock() - myOTP := otpLink - otpLink = "" - generated := otpGenerated - otpMu.Unlock() - reqOTP := r.PathValue("otp") - if reqOTP == "" { - notFound(w, r) - return - } - myOTPHash := sha256.Sum256([]byte(myOTP)) - reqOTPHash := sha256.Sum256([]byte(reqOTP)) - if !hmac.Equal(myOTPHash[:], reqOTPHash[:]) { - notFound(w, r) - return - } - if time.Since(generated) > 10*time.Minute { - http.Error(w, "OTP is too old. Please generate a new link", http.StatusConflict) - return - } - attachCookie(w, &authToken{expiry: time.Now().Add(24 * time.Hour)}) - fmt.Fprintf(w, "Temporary 24 hour token issued. You are now logged in.\n") -} - -func logout(w http.ResponseWriter, r *http.Request) { - http.SetCookie(w, makeHTTPCookie("!", time.Now())) - fmt.Fprintf(w, "You are now logged out.\n") -} - -var ( - //go:embed templates/note.html.template - notepadString string - notepadTemplate = template.Must(outlinedTemplate(notepadString)) - - notepadContentsMu sync.Mutex - notepadContents string -) - -type notepadTemplateArgs struct { - Content string -} - -func notepad(w http.ResponseWriter, r *http.Request) { - notepadContentsMu.Lock() - currentContent := notepadContents - notepadContentsMu.Unlock() - if err := notepadTemplate.Execute(w, notepadTemplateArgs{Content: currentContent}); err != nil { - log.Printf("Warning: notepad: %s", err) - } -} - -func autosave(w http.ResponseWriter, r *http.Request) { - newContent := r.FormValue("content") - notepadContentsMu.Lock() - notepadContents = newContent - notepadContentsMu.Unlock() - if err := notepadTemplate.ExecuteTemplate(w, "saveIndicator", "Saved ✓"); err != nil { - log.Printf("Warning: autosave: %s", err) - } -} - -func cmd(w http.ResponseWriter, r *http.Request) { - serviceConfig, err := loadConfig() - if err != nil { - log.Printf("Error: failed to load config: %s", err) - http.Error(w, "Failed to load config", http.StatusInternalServerError) - return - } - sourceIdx, ok := slices.BinarySearchFunc(serviceConfig.CommandRedirects, r.PathValue("cmd"), func(cmd commandConfig, name string) int { return strings.Compare(cmd.Name, name) }) - if !ok { - notFound(w, r) - return - } - if r.FormValue("go-get") == "1" { - fmt.Fprintf(w, "\n", - r.PathValue("cmd"), - serviceConfig.CommandRedirects[sourceIdx].URL) - if source := serviceConfig.CommandRedirects[sourceIdx].Source; source != "" { - fmt.Fprintf(w, "\n", - r.PathValue("cmd"), - source) - } - } else { - http.Redirect(w, r, "https://pkg.go.dev/roseh.moe/cmd/"+r.PathValue("cmd"), http.StatusMovedPermanently) - } -} - -var ( - //go:embed templates/pkg-index.html.template - pkgIndexString string - pkgIndexTemplate = template.Must(outlinedTemplate(pkgIndexString)) -) - -type pkgIndexTemplateArgs struct { - Packages []packageConfig -} - -func pkgIndex(w http.ResponseWriter, r *http.Request) { - serviceConfig, err := loadConfig() - if err != nil { - log.Printf("Error: failed to load config: %s", err) - http.Error(w, "Failed to load config", http.StatusInternalServerError) - return - } - if err := pkgIndexTemplate.Execute(w, pkgIndexTemplateArgs{Packages: serviceConfig.PackageRedirects}); err != nil { - log.Printf("Warning: pkgIndex: %s", err) - } -} - -var ( - //go:embed templates/cmd-index.html.template - cmdIndexString string - cmdIndexTemplate = template.Must(outlinedTemplate(cmdIndexString)) -) - -type cmdIndexTemplateArgs struct { - Commands []commandConfig -} - -func cmdIndex(w http.ResponseWriter, r *http.Request) { - serviceConfig, err := loadConfig() - if err != nil { - log.Printf("Error: failed to load config: %s", err) - http.Error(w, "Failed to load config", http.StatusInternalServerError) - return - } - if err := cmdIndexTemplate.Execute(w, cmdIndexTemplateArgs{Commands: serviceConfig.CommandRedirects}); err != nil { - log.Printf("Warning: pkgIndex: %s", err) - } -} - -func pkg(w http.ResponseWriter, r *http.Request) { - serviceConfig, err := loadConfig() - if err != nil { - log.Printf("Error: failed to load config: %s", err) - http.Error(w, "Failed to load config", http.StatusInternalServerError) - return - } - sourceIdx, ok := slices.BinarySearchFunc(serviceConfig.PackageRedirects, r.PathValue("pkg"), func(pkg packageConfig, name string) int { return strings.Compare(pkg.Name, name) }) - if !ok { - notFound(w, r) - return - } - if r.FormValue("go-get") == "1" { - fmt.Fprintf(w, "\n", r.PathValue("pkg"), serviceConfig.PackageRedirects[sourceIdx].URL) - if source := serviceConfig.PackageRedirects[sourceIdx].Source; source != "" { - fmt.Fprintf(w, "\n", - r.PathValue("pkg"), - source) - } - } else { - http.Redirect(w, r, "https://pkg.go.dev/roseh.moe/pkg/"+r.PathValue("pkg"), http.StatusMovedPermanently) - } -} - -type nopCloser struct { - io.Writer -} - -func (nopCloser) Close() error { return nil } - -func (w nopCloser) ReadFrom(r io.Reader) (int64, error) { - return io.Copy(w.Writer, r) -} - -type gzipResponseWriter struct { - http.ResponseWriter - - w io.WriteCloser - buf []byte - code int -} - -func (w *gzipResponseWriter) Unwrap() http.ResponseWriter { - return w.ResponseWriter -} - -func shouldGzip(w http.ResponseWriter) bool { - disposition, _, _ := strings.Cut(w.Header().Get("Content-Disposition"), ";") - length, lengthErr := strconv.Atoi(w.Header().Get("Content-Length")) - return strings.HasPrefix(w.Header().Get("Content-Type"), "text/") && - !(disposition == "attachment" && w.Header().Get("Accept-Ranges") != "" || - w.Header().Get("Content-Encoding") != "" || - lengthErr == nil && length < 512) -} - -func (w *gzipResponseWriter) ReadFrom(r io.Reader) (int64, error) { - if w.w != nil { - return io.Copy(w.w, r) - } - var nn int64 - if len(w.Header().Values("Content-Type")) == 0 { - if w.buf == nil { - w.buf = make([]byte, 0, 512) - } - n, err := io.ReadFull(r, w.buf[len(w.buf):cap(w.buf)]) - w.buf = w.buf[:len(w.buf)+n] - nn += int64(n) - if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { - return nn, err - } - if len(w.buf) < 512 { - return nn, nil - } - contentType := http.DetectContentType(w.buf) - w.Header().Set("Content-Type", contentType) - } - if shouldGzip(w.ResponseWriter) { - var err error - w.w, err = gzip.NewWriterLevel(w.ResponseWriter, gzip.BestSpeed) - if err != nil { - return nn, err - } - w.Header().Set("Content-Encoding", "gzip") - w.Header().Del("Content-Length") - w.Header().Del("Accept-Ranges") - } else { - w.w = nopCloser{w.ResponseWriter} - } - if w.code != 0 { - w.ResponseWriter.WriteHeader(w.code) - } - if len(w.buf) > 0 { - if n, err := w.w.Write(w.buf); err != nil { - return max(int64(n)-(512-nn), 0), err - } - } - n, err := io.Copy(w.w, r) - nn += n - return nn, err -} - -func (w *gzipResponseWriter) Write(p []byte) (int, error) { - n, err := w.ReadFrom(bytes.NewReader(p)) - return int(n), err -} - -func (w *gzipResponseWriter) WriteHeader(statusCode int) { - w.code = statusCode -} - -func (w *gzipResponseWriter) close() { - if w.w == nil { - if w.code != 0 { - w.ResponseWriter.WriteHeader(w.code) - } - w.ResponseWriter.Write(w.buf) - } else { - w.w.Close() - } -} - -type gzipHandler struct { - h http.Handler -} - -func (h *gzipHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if len(r.Header.Values("Range")) == 0 { - for _, hdr := range r.Header.Values("Accept-Encoding") { - for encoding := range strings.FieldsFuncSeq(hdr, func(r rune) bool { return r == ',' || unicode.IsSpace(r) }) { - encoding, _, _ := strings.Cut(encoding, ";") - if encoding == "gzip" { - gzipWriter := &gzipResponseWriter{ResponseWriter: w} - h.h.ServeHTTP(gzipWriter, r) - gzipWriter.close() - return - } - } - } - } - h.h.ServeHTTP(w, r) -} - -type authHandler struct { - h http.Handler - noRedirect bool -} - -func (h *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if !cookieAuth(r) { - if h.noRedirect { - http.Error(w, "not logged in", http.StatusUnauthorized) - } else { - u := *r.URL - u.Host = r.Host - u.Scheme = selfURL.Scheme - http.Redirect(w, r, "//"+selfURL.Host+"/login?redirect="+url.QueryEscape(u.String()), http.StatusSeeOther) - } - return - } - h.h.ServeHTTP(w, r) -} - -type timeoutHandler struct { - h http.Handler - timeout time.Duration -} - -func (h *timeoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - rc := http.NewResponseController(w) - deadline := time.Now().Add(h.timeout) - if err := rc.SetReadDeadline(deadline); err != nil { - log.Printf("Error: set read deadline: %s", err) - } - // Give some extra time to write the error - writeDeadline := deadline.Add(time.Minute) - if err := rc.SetWriteDeadline(writeDeadline); err != nil { - log.Printf("Error: set write deadline: %s", err) - } - ctx, cancel := context.WithDeadline(ctx, deadline) - defer cancel() - h.h.ServeHTTP(w, r.WithContext(ctx)) -} - -type loggingHandler struct { - h http.Handler -} - -func (h *loggingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if *v { - log.Printf("%q from %q", r.Method+" "+r.Host+r.URL.String(), r.RemoteAddr) - } - h.h.ServeHTTP(w, r) -} - -type removeAuthCookieHandler struct { - h http.Handler -} - -type hstsHandler struct { - h http.Handler -} - -func (h *hstsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if *https != "" { - w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") - } - h.h.ServeHTTP(w, r) -} - -func (h *removeAuthCookieHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - var cookies []*http.Cookie - for _, c := range r.Cookies() { - if c.Name != authCookieName { - cookies = append(cookies, c) - } - } - r.Header.Del("Cookie") - for _, c := range cookies { - r.AddCookie(c) - } - h.h.ServeHTTP(w, r) -} - -func main() { - flag.Parse() - - if err := loadSecrets(); err != nil { - log.Fatal(err) - } - - if err := hole.Init(); err != nil { - log.Fatal(err) - } - - go func() { - for range time.NewTicker(2 * time.Hour).C { - if err := hole.Clean(); err != nil { - log.Printf("Error: clean hole: %s", err) - } - } - }() - - go func() { - for now := range time.NewTicker(time.Hour).C { - log.Printf("Cleaning expired challenges") - challengeMu.Lock() - for challenge, initiated := range challenges { - if now.Sub(initiated) > challengeExpiry { - delete(challenges, challenge) - } - } - challengeMu.Unlock() - } - }() - - mux := http.NewServeMux() - indexTemplate, err := outlinedTemplate(indexString) - if err != nil { - log.Fatal(err) - } - indexHandler, err := staticTemplate(indexTemplate) - if err != nil { - log.Fatal(err) - } - mux.Handle("GET /{$}", &timeoutHandler{h: indexHandler, timeout: 5 * time.Minute}) - gamesIndexTemplate, err := outlinedTemplate(gamesIndexString) - if err != nil { - log.Fatal(err) - } - gamesIndexHandler, err := staticTemplate(gamesIndexTemplate) - if err != nil { - log.Fatal(err) - } - mux.Handle("GET /games/{$}", &timeoutHandler{h: gamesIndexHandler, timeout: 5 * time.Minute}) - pongTemplate, err := template.New("pong").Parse(pongString) - if err != nil { - log.Fatal(err) - } - pongHandler, err := staticTemplate(pongTemplate) - if err != nil { - log.Fatal(err) - } - mux.Handle("GET /games/pong", &timeoutHandler{h: pongHandler, timeout: 5 * time.Minute}) - stallmanShooterTemplate, err := outlinedTemplate(stallmanShooterString) - if err != nil { - log.Fatal(err) - } - stallmanShooterHandler, err := staticTemplate(stallmanShooterTemplate) - if err != nil { - log.Fatal(err) - } - mux.Handle("GET /games/rms", &timeoutHandler{h: stallmanShooterHandler, timeout: 5 * time.Minute}) - mux.Handle("GET /qr.png", &timeoutHandler{h: http.HandlerFunc(qrHandler), timeout: 5 * time.Minute}) - mux.Handle("GET /wormhole", &timeoutHandler{h: http.HandlerFunc(wormhole), timeout: 5 * time.Minute}) - mux.HandleFunc("POST /wormhole", wormholeUpload) - mux.HandleFunc("GET /wormhole/{hole}", wormholeDownload) - mux.Handle("GET /login", &timeoutHandler{h: http.HandlerFunc(serveLogin), timeout: 5 * time.Minute}) - mux.Handle("POST /login", &timeoutHandler{h: http.HandlerFunc(login), timeout: 5 * time.Minute}) - mux.Handle("GET /logout", &timeoutHandler{h: http.HandlerFunc(logout), timeout: 5 * time.Minute}) - mux.Handle("GET /register", &timeoutHandler{h: http.HandlerFunc(serveRegister), timeout: 5 * time.Minute}) - mux.Handle("POST /register", &timeoutHandler{h: http.HandlerFunc(register), timeout: 5 * time.Minute}) - mux.Handle("GET /otp", &timeoutHandler{h: &authHandler{h: http.HandlerFunc(serveOTP)}, timeout: 5 * time.Minute}) - mux.Handle("GET /otp/{otp}", &timeoutHandler{h: http.HandlerFunc(otpLogin), timeout: 5 * time.Minute}) - mux.Handle("GET /notepad", &timeoutHandler{h: &authHandler{h: http.HandlerFunc(notepad)}, timeout: 5 * time.Minute}) - mux.Handle("POST /notepad", &timeoutHandler{h: &authHandler{h: http.HandlerFunc(autosave), noRedirect: true}, timeout: 5 * time.Minute}) - mux.Handle("GET /cmd/{$}", &timeoutHandler{h: http.HandlerFunc(cmdIndex), timeout: 5 * time.Minute}) - mux.Handle("GET /cmd/{cmd}", &timeoutHandler{h: http.HandlerFunc(cmd), timeout: 5 * time.Minute}) - mux.Handle("GET /pkg/{$}", &timeoutHandler{h: http.HandlerFunc(pkgIndex), timeout: 5 * time.Minute}) - mux.Handle("GET /pkg/{pkg}", &timeoutHandler{h: http.HandlerFunc(pkg), timeout: 5 * time.Minute}) - mux.Handle("GET /nezuko/", &timeoutHandler{h: &authHandler{h: http.StripPrefix("/nezuko/", http.FileServer(http.Dir(*nezuko)))}, timeout: 10 * time.Minute}) - mux.Handle("GET /static/", &timeoutHandler{h: http.HandlerFunc(static), timeout: 5 * time.Minute}) - mux.Handle("GET /favicon.ico", &timeoutHandler{h: http.HandlerFunc(favicon), timeout: 5 * time.Minute}) - apiMux := http.NewServeMux() - apiMux.HandleFunc("PUT /api/wormhole/{name}", wormholeAPIUpload) - mux.Handle("/api/", apiMux) - mux.Handle("/", &timeoutHandler{h: http.HandlerFunc(sharedFileHandler), timeout: 5 * time.Minute}) - serverMux := http.NewServeMux() - serverMux.Handle(selfURL.Hostname()+"/", &gzipHandler{mux}) - jellyfinURL, err := url.Parse("http://127.0.0.1:8096") - if err != nil { - log.Fatal(err) - } - serverMux.Handle("cinema."+selfURL.Hostname()+"/", &authHandler{h: &removeAuthCookieHandler{&httputil.ReverseProxy{ - Rewrite: func(r *httputil.ProxyRequest) { - r.SetURL(jellyfinURL) - r.SetXForwarded() - }, - }}}) - codeMux := http.NewServeMux() - cgitStaticFiles := http.FileServer(http.Dir(*cgit)) - codeMux.Handle("/cgit.css", &timeoutHandler{h: cgitStaticFiles, timeout: 5 * time.Minute}) - codeMux.Handle("/cgit.js", &timeoutHandler{h: cgitStaticFiles, timeout: 5 * time.Minute}) - codeMux.Handle("/robots.txt", &timeoutHandler{h: cgitStaticFiles, timeout: 5 * time.Minute}) - codeMux.Handle("/favicon.ico", &timeoutHandler{h: http.HandlerFunc(favicon), timeout: 5 * time.Minute}) - cgitConfigAbsPath, err := filepath.Abs(*cgitConfig) - if err != nil { - log.Fatal(err) - } - cgitCGI := &cgi.Handler{ - Path: filepath.Join(*cgit, "cgit.cgi"), - Env: []string{"CGIT_CONFIG=" + cgitConfigAbsPath}, - } - cgitCGI.PathLocationHandler = cgitCGI - codeMux.Handle("/", &timeoutHandler{h: &removeAuthCookieHandler{cgitCGI}, timeout: 5 * time.Minute}) - serverMux.Handle("code."+selfURL.Hostname()+"/", &gzipHandler{codeMux}) - transmissionURL, err := url.Parse("http://127.0.0.1:9091") - if err != nil { - log.Fatal(err) - } - serverMux.Handle("downloads."+selfURL.Hostname()+"/", &authHandler{h: &removeAuthCookieHandler{&httputil.ReverseProxy{ - Rewrite: func(r *httputil.ProxyRequest) { - r.SetURL(transmissionURL) - r.SetXForwarded() - }, - }}}) - server := &http.Server{ - Handler: &loggingHandler{http.NewCrossOriginProtection().Handler(&hstsHandler{serverMux})}, - - ReadHeaderTimeout: 5 * time.Minute, - IdleTimeout: 10 * time.Minute, - } - - if *httpRedirect != "" { - go func() { - mux := http.NewServeMux() - mux.Handle("GET /.well-known/acme-challenge/", &timeoutHandler{ - h: http.FileServer(http.Dir("/var/lib/acme/acme-challenge")), - timeout: 5 * time.Minute, - }) - mux.Handle("/", &timeoutHandler{ - h: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - u := *r.URL - u.Scheme = selfURL.Scheme - u.Host, _, _ = strings.Cut(r.Host, ":") - if port := selfURL.Port(); port != "" { - u.Host += ":" + port - } - http.Redirect(w, r, u.String(), http.StatusMovedPermanently) - }), - timeout: time.Minute, - }) - httpSrv := &http.Server{ - Addr: ":" + *httpRedirect, - Handler: &loggingHandler{mux}, - ReadHeaderTimeout: 5 * time.Minute, - IdleTimeout: 10 * time.Minute, - } - log.Printf("Serving HTTP redirect on %q", httpSrv.Addr) - err := httpSrv.ListenAndServe() - log.Printf("Error: HTTP redirect failed: %s", err) - }() - } - - port := selfURL.Port() - if port == "" { - port = "80" - if *https != "" { - port = "443" - } - } - server.Addr = ":" + port - log.Printf("Listening on %q", server.Addr) - if *https != "" { - log.Fatal(server.ListenAndServeTLS(*https+"/cert.pem", *https+"/key.pem")) - } else { - log.Fatal(server.ListenAndServe()) - } -} -- cgit v1.3.1