summaryrefslogtreecommitdiffstats
path: root/internal/auth
diff options
context:
space:
mode:
Diffstat (limited to 'internal/auth')
-rw-r--r--internal/auth/auth.go1410
1 files changed, 1410 insertions, 0 deletions
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 <r@roseh.moe>
+//
+// 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
+// <https://www.gnu.org/licenses/>.
+
+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, "<meta name=\"go-import\" content=\"roseh.moe/cmd/%s %s\">\n",
+ r.PathValue("cmd"),
+ serviceConfig.CommandRedirects[sourceIdx].URL)
+ if source := serviceConfig.CommandRedirects[sourceIdx].Source; source != "" {
+ fmt.Fprintf(w, "<meta name=\"go-source\" content=\"roseh.moe/cmd/%s %s\">\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, "<meta name=\"go-import\" content=\"roseh.moe/pkg/%s %s\">\n", r.PathValue("pkg"), serviceConfig.PackageRedirects[sourceIdx].URL)
+ if source := serviceConfig.PackageRedirects[sourceIdx].Source; source != "" {
+ fmt.Fprintf(w, "<meta name=\"go-source\" content=\"roseh.moe/pkg/%s %s\">\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())
+ }
+}