package main import ( "archive/tar" "cmp" "compress/gzip" "crypto/hkdf" "crypto/hmac" "crypto/rand" "crypto/sha1" "crypto/sha256" "crypto/subtle" "embed" "encoding/base64" "encoding/binary" "encoding/xml" "errors" "flag" "fmt" "html/template" "io" "io/fs" "log" "mime/multipart" "net/http" "net/http/httputil" "net/url" "os" "strconv" "strings" "sync" "time" "github.com/skip2/go-qrcode" "roseh.moe/pkg/oae2" "roseh.moe/pkg/roseh.moe/internal/pwhash" "roseh.moe/pkg/wordlist" ) var ( port = flag.Int("port", 42069, "port to listen on") selfURL = flag.String("self-url", "http://localhost:42069", "base URL of the server") domain = flag.String("domain", "", "domain for auth cookie") 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") holeTempDir = flag.String("hole-temp-dir", "hole", "directory to store temporary files for the wormhole") codeDir = flag.String("code-dir", "code", "directory to serve git repos") serverStartTime = time.Now() ) var notepadPassword, secretKey, authenticatorKey []byte func loadSecrets() error { fileBytes, err := os.ReadFile(*secretsFile) if err != nil { return err } var secretsConfig struct { NotepadPassword string `xml:",attr"` SecretKey string `xml:",attr"` AuthKey string `xml:",attr"` } if err := xml.Unmarshal(fileBytes, &secretsConfig); err != nil { return fmt.Errorf("%s: %s", *secretsFile, err) } if secretsConfig.NotepadPassword == "" { return fmt.Errorf("%s: missing NotepadPassword", *secretsFile) } if secretsConfig.SecretKey == "" { return fmt.Errorf("%s: missing SecretKey", *secretsFile) } if secretsConfig.AuthKey == "" { return fmt.Errorf("%s: missing AuthKey", *secretsFile) } if notepadPassword, err = base64.StdEncoding.DecodeString(secretsConfig.NotepadPassword); err != nil { return fmt.Errorf("%s: bad NotepadPassword (try base64)", *secretsFile) } if secretKey, err = base64.StdEncoding.DecodeString(secretsConfig.SecretKey); err != nil { return fmt.Errorf("%s: bad SecretKey (try base64)", *secretsFile) } if authenticatorKey, err = base64.StdEncoding.DecodeString(secretsConfig.AuthKey); err != nil { return fmt.Errorf("%s: bad AuthKey (try base64)", *secretsFile) } return nil } type serviceConfiguration struct { packageRedirects map[string]string commandRedirects map[string]string } 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 } var fileConfig struct { Package []struct { Name string `xml:",attr"` URL string `xml:",attr"` } `xml:"Redirects>Package"` Command []struct { Name string `xml:",attr"` URL string `xml:",attr"` } `xml:"Redirects>Command"` } if err := xml.Unmarshal(fileBytes, &fileConfig); err != nil { return nil, err } config := &serviceConfiguration{ packageRedirects: make(map[string]string), commandRedirects: make(map[string]string), } for _, pkg := range fileConfig.Package { config.packageRedirects[pkg.Name] = pkg.URL } for _, cmd := range fileConfig.Command { config.commandRedirects[cmd.Name] = cmd.URL } serviceConfigMu.Lock() serviceConfig = config serviceConfigLastModified = newModTime serviceConfigMu.Unlock() return config, 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) } } var ( //go:embed templates/outline.html.template outlineString string outlineTemplate = template.Must(template.New("outline").Parse(outlineString)) //go:embed templates/index.html.template indexString string indexTemplate = template.Must(template.Must(outlineTemplate.Clone()).New("body").Parse(indexString)).Lookup("outline") ) func index(w http.ResponseWriter, _ *http.Request) { if err := indexTemplate.Execute(w, nil); err != nil { log.Printf("Warning: index: %s", err) } } var ( //go:embed templates/pong.html.template pongString string pongTemplate = template.Must(template.New("pong").Parse(pongString)) ) func pong(w http.ResponseWriter, _ *http.Request) { if err := pongTemplate.Execute(w, nil); err != nil { log.Printf("Warning: pong: %s", err) } } //go:embed static var staticFiles embed.FS func serveStaticFile(w http.ResponseWriter, r *http.Request, path string) { f, err := staticFiles.Open(path) if err != nil { notFound(w, r) return } defer f.Close() http.ServeContent(w, r, path, serverStartTime, f.(io.ReadSeeker)) } func static(w http.ResponseWriter, r *http.Request) { serveStaticFile(w, r, strings.TrimPrefix(r.URL.Path, "/")) } func favicon(w http.ResponseWriter, r *http.Request) { serveStaticFile(w, r, "static/favicon.ico") } func qrHandler(w http.ResponseWriter, r *http.Request) { sig, err := base64.RawURLEncoding.DecodeString(r.FormValue("s")) if err != nil { http.Error(w, fmt.Sprintf("Bad signature: %s", err), http.StatusBadRequest) return } text := r.FormValue("t") if !verify([]byte(text), sig, "qr") { http.Error(w, "Bad signature", http.StatusBadRequest) return } qr, err := qrcode.Encode(text, qrcode.Medium, 200) if err != nil { http.Error(w, fmt.Sprintf("failed to encode QR code: %s", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "image/png") w.Write(qr) } var ( //go:embed templates/wormhole.html.template wormholeTemplateString string wormholeTemplate = template.Must(template.Must(outlineTemplate.Clone()).New("body").Parse(wormholeTemplateString)).Lookup("outline") ) type wormholeTemplateArgs struct { Err string } func makeHole() string { const nWords = 10 buf := make([]byte, 2*nWords) rand.Read(buf) words := make([]string, nWords) for i := range words { words[i] = wordlist.Words[binary.NativeEndian.Uint16(buf[2*i:])&0x1fff] } return strings.Join(words, "-") } const blockSize = 4 * 1024 * 1024 type holeFileWriter struct { *oae2.Writer f *os.File } func (w *holeFileWriter) Close() error { return cmp.Or(w.Writer.Close(), w.f.Close()) } func writeHoleFile(filename string) (_ string, _ *holeFileWriter, err error) { hole := makeHole() primaryKey, err := hkdf.Key(sha256.New, []byte(hole), nil, "", 32) if err != nil { return "", nil, err } f, err := os.Create(fmt.Sprintf("%s/%x", *holeTempDir, primaryKey)) if err != nil { return "", nil, err } defer func() { if err != nil { f.Close() } }() w := oae2.NewWriter(f, []byte(hole), blockSize, nil) buf := make([]byte, 8) binary.BigEndian.PutUint64(buf, uint64(len(filename))) if _, err := w.Write(buf); err != nil { return "", nil, err } if _, err := io.WriteString(w, filename); err != nil { return "", nil, err } return hole, &holeFileWriter{w, f}, nil } type offsetReader struct { io.ReadSeeker offset int } func (r *offsetReader) Seek(offset int64, whence int) (int64, error) { if whence == io.SeekStart { offset += int64(r.offset) } n, err := r.ReadSeeker.Seek(offset, whence) return n - int64(r.offset), err } type holeFileReader struct { *offsetReader f *os.File } func (r *holeFileReader) Close() error { return r.f.Close() } var errNoHole = errors.New("no such hole") func openHoleFile(hole string) (_ string, _ *holeFileReader, err error) { primaryKey, err := hkdf.Key(sha256.New, []byte(hole), nil, "", 32) if err != nil { return "", nil, err } f, err := os.Open(fmt.Sprintf("%s/%x", *holeTempDir, primaryKey)) if err != nil { if errors.Is(err, fs.ErrNotExist) { return "", nil, errNoHole } return "", nil, err } defer func() { if err != nil { f.Close() } }() r := oae2.NewReader(f, []byte(hole), blockSize, nil) buf := make([]byte, 8) if _, err := io.ReadFull(r, buf); err != nil { return "", nil, err } fileNameLen := binary.BigEndian.Uint64(buf) fileName := make([]byte, fileNameLen) if _, err := io.ReadFull(r, fileName); err != nil { return "", nil, err } return string(fileName), &holeFileReader{&offsetReader{r, 8 + int(fileNameLen)}, f}, nil } func readHoleFile(w http.ResponseWriter, req *http.Request, hole string) error { fileName, r, err := openHoleFile(hole) 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) } } func writeHoleFileToTarFile(tarWriter *tar.Writer, hole string, now time.Time) error { fileName, f, err := openHoleFile(hole) if err != nil { return fmt.Errorf("read back hole file: %s", err) } defer f.Close() size, err := f.Seek(0, io.SeekEnd) if err != nil { return fmt.Errorf("read back hole file: %s", err) } if _, err := f.Seek(0, io.SeekStart); err != nil { return fmt.Errorf("read back hole file: %s", err) } if err := tarWriter.WriteHeader(&tar.Header{Name: "wormhole-files/" + fileName, Mode: 0644, Size: size, ModTime: now, Format: tar.FormatPAX}); err != nil { return err } if _, err := io.Copy(tarWriter, f); err != nil { return fmt.Errorf("write file to tar file: %s", err) } return nil } var errNoFile = errors.New("no file") func uploadWormhole(reader *multipart.Reader) (string, error) { var holes []string for { part, err := reader.NextPart() if err != nil { break } if part.FormName() != "file" || part.FileName() == "" { continue } hole, err := writeSingleHoleFile(part.FileName(), part) if err != nil { return "", err } holes = append(holes, hole) } if len(holes) == 0 { return "", errNoFile } if len(holes) == 1 { return holes[0], nil } tarFileHole, tarHoleWriter, err := writeHoleFile("wormhole-files.tar.gz") if err != nil { return "", fmt.Errorf("make tar file: %s", err) } defer tarHoleWriter.Close() gzipWriter := gzip.NewWriter(tarHoleWriter) tarWriter := tar.NewWriter(gzipWriter) now := time.Now() if err := tarWriter.WriteHeader(&tar.Header{Name: "wormhole-files/", Mode: 0755, ModTime: now, Format: tar.FormatPAX}); err != nil { return "", fmt.Errorf("create tar file main directory: %s", err) } for _, hole := range holes { if err := writeHoleFileToTarFile(tarWriter, hole, now); err != nil { return "", err } } if err := tarWriter.Close(); err != nil { return "", fmt.Errorf("write tar file: %s", err) } if err := gzipWriter.Close(); err != nil { return "", fmt.Errorf("gzip tar file: %s", err) } if err := tarHoleWriter.Close(); err != nil { return "", fmt.Errorf("write tar hole file: %s", err) } return tarFileHole, nil } var ( //go:embed templates/wormhole-success.html.template wormholeSuccessTemplateString string wormholeSuccessTemplate = template.Must(template.Must(outlineTemplate.Clone()).New("body").Parse(wormholeSuccessTemplateString)).Lookup("outline") ) 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 } hole, err := uploadWormhole(reader) if err != nil { if err == 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("Warning: failed to upload hole file: %s", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } downloadURL := *selfURL + "/wormhole/" + hole if err := wormholeSuccessTemplate.Execute(w, wormholeSuccessTemplateArgs{ URL: downloadURL, QR: "/qr.png?t=" + url.QueryEscape(downloadURL) + "&s=" + base64.RawURLEncoding.EncodeToString(mac([]byte(downloadURL), "qr")), }); err != nil { log.Printf("Warning: wormholeSend: %s", err) } } func writeSingleHoleFile(filename string, r io.Reader) (string, error) { hole, holeWriter, err := writeHoleFile(filename) if err != nil { return "", fmt.Errorf("write hole file: %s", err) } defer holeWriter.Close() if _, err := io.Copy(holeWriter, r); err != nil { return "", fmt.Errorf("write hole file: %s", err) } if err := holeWriter.Close(); err != nil { return "", fmt.Errorf("write hole file: %s", err) } return hole, nil } func wormholeAPIUpload(w http.ResponseWriter, r *http.Request) { hole, err := writeSingleHoleFile(r.PathValue("name"), r.Body) if err != nil { log.Printf("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 == errNoHole { notFound(w, r) return } log.Printf("Warning: read hole file: %s", err) return } } func cleanHole() error { log.Printf("Cleaning hole...") holeDir, err := os.Open(*holeTempDir) if err != nil { return err } defer holeDir.Close() deadline := time.Now().Add(-time.Hour) for { files, err := holeDir.ReadDir(1024) for _, file := range files { stat, err := file.Info() if err != nil { log.Printf("Warning: clean hole: %s", err) continue } if !stat.ModTime().Before(deadline) { continue } if err := os.Remove(*holeTempDir + "/" + file.Name()); err != nil { log.Printf("Warning: clean hole: %s", err) continue } } if err != nil { if err != io.EOF { return err } return nil } } } const macSize = sha256.Size 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 verify(msg, messageMAC []byte, purpose string) bool { expectedMAC := mac(msg, purpose) return hmac.Equal(messageMAC, expectedMAC) } func marshalInt(x int64) []byte { buf := make([]byte, 8) binary.BigEndian.PutUint64(buf, uint64(x)) for ; len(buf) > 0 && buf[0] == 0; buf = buf[1:] { } return buf } func unmarshalInt(b []byte) (int64, bool) { if len(b) > 8 { return 0, false } buf := make([]byte, 8) copy(buf[8-len(b):], b) return int64(binary.BigEndian.Uint64(buf)), true } const yearOffset = 2025 func marshalTime(t time.Time) []byte { year, month, day := t.UTC().Date() return marshalInt((int64(year)-yearOffset)<<9 | int64(month)<<5 | int64(day)) } func unmarshalTime(b []byte) (time.Time, bool) { n, ok := unmarshalInt(b) if !ok { return time.Time{}, false } return time.Date(int(n>>9+yearOffset), time.Month(n>>5&0xf), int(n&0x1f), 0, 0, 0, 0, time.UTC), true } func makeToken() string { t := marshalTime(time.Now()) return base64.RawURLEncoding.EncodeToString(append(t, mac(t, "auth")...)) } const cookieExpiration = 7 * 24 * time.Hour const authCookieName = "a" func attachCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: authCookieName, Value: makeToken(), Path: "/", Domain: *domain, Expires: time.Now().Add(cookieExpiration), Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, Partitioned: true, }) } func cookieAuth(w http.ResponseWriter, r *http.Request) bool { cookie, err := r.Cookie(authCookieName) if err != nil { return false } authCookie, err := base64.RawURLEncoding.DecodeString(cookie.Value) if err != nil { return false } if len(authCookie) < macSize { return false } msg, mac := authCookie[:len(authCookie)-macSize], authCookie[len(authCookie)-macSize:] if !verify(msg, mac, "auth") { return false } t, ok := unmarshalTime(msg) if !ok { return false } cookieAge := time.Since(t) if cookieAge > cookieExpiration { return false } if cookieAge > 24*time.Hour { attachCookie(w) } return true } func totp(t int64) []byte { ts := make([]byte, 8) binary.BigEndian.PutUint64(ts, uint64(t)) hash := hmac.New(sha1.New, authenticatorKey) hash.Write(ts) mac := hash.Sum(nil) offset := mac[len(mac)-1] & 0xf n := int32(mac[offset])&0x7f<<24 | int32(mac[offset+1])<<16 | int32(mac[offset+2])<<8 | int32(mac[offset+3]) return strconv.AppendInt(nil, int64(n%1_000_000), 10) } func checkOTP(auth string) int { now := time.Now().Unix() / 30 return subtle.ConstantTimeCompare([]byte(auth), totp(now)) | subtle.ConstantTimeCompare([]byte(auth), totp(now-1)) } func checkPassword(password, auth string) bool { otpMatches := checkOTP(auth) hash, err := pwhash.Hash(password, notepadPassword[:pwhash.SaltSize]) if err != nil { return false } return subtle.ConstantTimeCompare(hash, notepadPassword[pwhash.SaltSize:])&otpMatches != 0 } var ( //go:embed templates/login.html.template loginTemplateString string loginTemplate = template.Must(template.Must(outlineTemplate.Clone()).New("body").Parse(loginTemplateString)).Lookup("outline") ) type loginTemplateArgs struct { Error bool } func redirectLogin(w http.ResponseWriter, r *http.Request, redirect string) { redirect += base64.RawURLEncoding.EncodeToString(mac([]byte(redirect), "redirect")) http.Redirect(w, r, *selfURL+"/login?redirect="+url.QueryEscape(redirect), http.StatusFound) } func serveLogin(w http.ResponseWriter, r *http.Request) { if err := loginTemplate.Execute(w, loginTemplateArgs{}); err != nil { log.Printf("Warning: login: %s", err) } } func verifyRedirect(redirect string) (string, bool) { base64MacSize := base64.RawURLEncoding.EncodedLen(macSize) if len(redirect) < base64MacSize { return "", false } redirect, macBase64 := redirect[:len(redirect)-base64MacSize], redirect[len(redirect)-base64MacSize:] mac, err := base64.RawURLEncoding.DecodeString(macBase64) if err != nil { return "", false } if !verify([]byte(redirect), mac, "redirect") { return "", false } return redirect, true } func login(w http.ResponseWriter, r *http.Request) { if !checkPassword(r.FormValue("password"), r.FormValue("auth")) { if err := loginTemplate.Execute(w, loginTemplateArgs{Error: true}); err != nil { log.Printf("Warning: login: %s", err) } return } attachCookie(w) redirect, ok := verifyRedirect(r.FormValue("redirect")) if !ok { redirect = "/" } http.Redirect(w, r, redirect, http.StatusSeeOther) } var ( //go:embed templates/note.html.template notepadString string notepadTemplate = template.Must(template.Must(outlineTemplate.Clone()).New("body").Parse(notepadString)).Lookup("outline") notepadContentsMu sync.Mutex notepadContents string ) type notepadTemplateArgs struct { Content string } func notepad(w http.ResponseWriter, r *http.Request) { if !cookieAuth(w, r) { redirectLogin(w, r, "/notepad") return } notepadContentsMu.Lock() currentContent := notepadContents notepadContentsMu.Unlock() if err := notepadTemplate.Execute(w, notepadTemplateArgs{Content: currentContent}); err != nil { log.Printf("Warning: notepad: %s", err) } } func saveNote(w http.ResponseWriter, r *http.Request) error { if !cookieAuth(w, r) { return fmt.Errorf("not logged in") } newContent := r.FormValue("content") notepadContentsMu.Lock() notepadContents = newContent notepadContentsMu.Unlock() return nil } func autosave(w http.ResponseWriter, r *http.Request) { msg := "Saved ✓" if err := saveNote(w, r); err != nil { msg = fmt.Sprintf("Failed to save: %s", err) } if err := notepadTemplate.ExecuteTemplate(w, "saveIndicator", msg); err != nil { log.Printf("Warning: autosave: %s", err) } } func cmd(w http.ResponseWriter, r *http.Request) { serviceConfig, err := loadConfig() if err != nil { http.Error(w, fmt.Sprintf("load config: %s", err), http.StatusInternalServerError) return } source, ok := serviceConfig.commandRedirects[r.PathValue("cmd")] if !ok { notFound(w, r) return } if r.FormValue("go-get") == "1" { fmt.Fprintf(w, ``, r.PathValue("cmd"), source) } else { http.Redirect(w, r, "https://pkg.go.dev/roseh.moe/cmd/"+r.PathValue("cmd"), http.StatusFound) } } func pkg(w http.ResponseWriter, r *http.Request) { serviceConfig, err := loadConfig() if err != nil { http.Error(w, fmt.Sprintf("load config: %s", err), http.StatusInternalServerError) return } source, ok := serviceConfig.packageRedirects[r.PathValue("pkg")] if !ok { notFound(w, r) return } if r.FormValue("go-get") == "1" { fmt.Fprintf(w, ``, r.PathValue("pkg"), source) } else { http.Redirect(w, r, "https://pkg.go.dev/roseh.moe/pkg/"+r.PathValue("pkg"), http.StatusFound) } } type jellyfinReverseProxy struct { proxy httputil.ReverseProxy } func (p *jellyfinReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { if !cookieAuth(w, r) { redirectLogin(w, r, "https://cinema.roseh.moe") return } p.proxy.ServeHTTP(w, r) } func main() { flag.Parse() if err := loadSecrets(); err != nil { log.Fatal(err) } go func() { for range time.NewTicker(2 * time.Hour).C { if err := cleanHole(); err != nil { log.Printf("Warning: clean hole: %s", err) } } }() mux := http.NewServeMux() mux.HandleFunc("GET /{$}", index) mux.HandleFunc("GET /pong", pong) mux.HandleFunc("GET /qr.png", qrHandler) mux.HandleFunc("GET /wormhole", wormhole) mux.HandleFunc("POST /wormhole", wormholeUpload) mux.HandleFunc("GET /wormhole/{hole}", wormholeDownload) mux.HandleFunc("GET /login", serveLogin) mux.HandleFunc("POST /login", login) mux.HandleFunc("GET /notepad", notepad) mux.HandleFunc("POST /notepad", autosave) mux.HandleFunc("GET /cmd/{cmd}", cmd) mux.HandleFunc("GET /pkg/{pkg}", pkg) mux.Handle("GET /code/", http.StripPrefix("/code/", http.FileServer(http.Dir(*codeDir)))) mux.HandleFunc("GET /static/", static) mux.HandleFunc("GET /favicon.ico", favicon) apiMux := http.NewServeMux() apiMux.HandleFunc("PUT /api/wormhole/{name}", wormholeAPIUpload) mux.Handle("/api/", apiMux) mux.HandleFunc("/", notFound) http.Handle("/", http.NewCrossOriginProtection().Handler(mux)) jellyfinURL, err := url.Parse("http://127.0.0.1:8096") if err != nil { log.Fatal(err) } http.Handle("cinema.roseh.moe/", &jellyfinReverseProxy{ proxy: httputil.ReverseProxy{ Rewrite: func(r *httputil.ProxyRequest) { r.SetURL(jellyfinURL) r.SetXForwarded() }, }, }) addr := fmt.Sprintf(":%d", *port) log.Printf("Listening on %q", addr) if *https != "" { log.Fatal(http.ListenAndServeTLS(addr, *https+"/cert.pem", *https+"/key.pem", nil)) } else { log.Fatal(http.ListenAndServe(addr, nil)) } }