package main import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "embed" "encoding/base64" "encoding/binary" "encoding/hex" "flag" "fmt" "html/template" "io" "log" "net/http" "os" "strings" "sync" "time" "roseh.moe/pkg/roseh.moe/internal/ini" "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") secretsFile = flag.String("secrets", "secrets.ini", "secrets file") configFile = flag.String("config", "config.ini", "configuration file (no spec or examples, sorry)") serverStartTime = time.Now() ) var notepadPassword, secretKey []byte func loadSecrets() error { f, err := os.Open(*secretsFile) if err != nil { return err } defer f.Close() p := new(ini.Parser) for keyVal, err := range p.Parse(f) { if err != nil { return fmt.Errorf("%s:%d %s", *secretsFile, p.LineNumber, err) } switch keyVal.Key { case "notepad-password": notepadPassword, err = hex.AppendDecode(nil, []byte(keyVal.Val)) if err != nil { return fmt.Errorf("%s:%d %s", *secretsFile, p.LineNumber, err) } case "secret-key": secretKey, err = hex.AppendDecode(nil, []byte(keyVal.Val)) if err != nil { return fmt.Errorf("%s:%d %s", *secretsFile, p.LineNumber, err) } default: return fmt.Errorf("%s:%d unknown key", *secretsFile, p.LineNumber) } } if len(notepadPassword) == 0 { return fmt.Errorf("%s: missing notepad-password", *secretsFile) } if len(secretKey) == 0 { return fmt.Errorf("%s: missing secret-key", *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...") f, err := os.Open(*configFile) if err != nil { return nil, err } defer f.Close() config := &serviceConfiguration{ packageRedirects: make(map[string]string), commandRedirects: make(map[string]string), } p := new(ini.Parser) for keyVal, err := range p.Parse(f) { if err != nil { log.Printf("Warning: %s:%d %s", *configFile, p.LineNumber, err) continue } switch p.Section { case "package-redirects": config.packageRedirects[keyVal.Key] = keyVal.Val case "command-redirects": config.commandRedirects[keyVal.Key] = keyVal.Val default: log.Printf("Warning: %s:%d unknown section", *configFile, p.LineNumber) } } 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") } 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 { Self string Hole 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, "-") } func wormhole(w http.ResponseWriter, r *http.Request) { if err := wormholeTemplate.Execute(w, wormholeTemplateArgs{Self: *selfURL, Hole: makeHole()}); err != nil { log.Printf("Warning: wormhole: %s", err) } } type wormholeConn struct { done chan struct{} w http.ResponseWriter } var wormholeConnsMu sync.Mutex var wormholeConns = make(map[string]*wormholeConn) var wormholeNotifyMu sync.Mutex var wormholeNotify = make(map[string]chan struct{}) func wormholeSend(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 } for { part, err := reader.NextPart() if err != nil { break } if part.FormName() != "file" { continue } hole := r.PathValue("hole") wormholeConnsMu.Lock() conn := wormholeConns[hole] delete(wormholeConns, hole) wormholeConnsMu.Unlock() if conn == nil { http.Error(w, "no such connection", http.StatusBadRequest) return } defer close(conn.done) conn.w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+part.FileName()) if _, err := io.Copy(conn.w, part); err != nil { http.Error(w, fmt.Sprintf("Error during copy: %s", err), http.StatusServiceUnavailable) return } fmt.Fprintf(w, "uploaded!") return } http.Error(w, "file not found", http.StatusBadRequest) } func wormholeRecv(w http.ResponseWriter, r *http.Request) { ctx := r.Context() hole := r.PathValue("hole") conn := &wormholeConn{ done: make(chan struct{}), w: w, } wormholeConnsMu.Lock() wormholeConns[hole] = conn wormholeConnsMu.Unlock() defer func() { wormholeConnsMu.Lock() delete(wormholeConns, hole) wormholeConnsMu.Unlock() }() wormholeNotifyMu.Lock() notify := wormholeNotify[hole] wormholeNotifyMu.Unlock() if notify == nil { http.Error(w, "no such connection", http.StatusBadRequest) return } select { case notify <- struct{}{}: default: http.Error(w, "connection not ready", http.StatusBadRequest) return } select { case <-ctx.Done(): case <-conn.done: } } func wormholeReady(w http.ResponseWriter, r *http.Request) { ctx := r.Context() hole := r.PathValue("hole") notify := make(chan struct{}) wormholeNotifyMu.Lock() wormholeNotify[hole] = notify wormholeNotifyMu.Unlock() defer func() { wormholeNotifyMu.Lock() delete(wormholeNotify, hole) wormholeNotifyMu.Unlock() }() w.Header().Set("Content-Type", "text/event-stream") select { case <-ctx.Done(): return case <-notify: } fmt.Fprintf(w, "event: ready\ndata:\n\n") } func mac(msg []byte) []byte { mac := hmac.New(sha256.New, secretKey) mac.Write(msg) return mac.Sum(nil) } func sign(msg []byte) []byte { return append(msg, mac(msg)...) } func verify(msg []byte) ([]byte, bool) { if len(msg) < sha256.Size { return nil, false } msg, messageMAC := msg[:len(msg)-sha256.Size], msg[len(msg)-sha256.Size:] expectedMAC := mac(msg) if !hmac.Equal(messageMAC, expectedMAC) { return nil, false } return msg, true } func makeToken() (string, error) { b, err := time.Now().MarshalBinary() if err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(sign(b)), nil } const cookieExpiration = 180 * 24 * time.Hour func attachCookie(w http.ResponseWriter) error { token, err := makeToken() if err != nil { return err } http.SetCookie(w, &http.Cookie{ Name: "auth", Value: token, Path: "/", Expires: time.Now().Add(cookieExpiration), Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, Partitioned: true, }) return nil } func cookieAuth(w http.ResponseWriter, r *http.Request) bool { cookie, err := r.Cookie("auth") if err != nil { return false } authCookie, err := base64.RawURLEncoding.DecodeString(cookie.Value) if err != nil { return false } msg, ok := verify(authCookie) if !ok { return false } var t time.Time if err := t.UnmarshalBinary(msg); err != nil { return false } cookieAge := time.Since(t) if cookieAge > cookieExpiration { return false } if cookieAge > 24*time.Hour { attachCookie(w) } return true } func checkPassword(password string) bool { expectedHash, salt := notepadPassword[:len(notepadPassword)-pwhash.SaltSize], notepadPassword[len(notepadPassword)-pwhash.SaltSize:] hash, err := pwhash.Hash(password, salt) return err == nil && subtle.ConstantTimeCompare(hash, expectedHash) != 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 executeLoginTemplate(w io.Writer) { if err := loginTemplate.Execute(w, loginTemplateArgs{}); err != nil { log.Printf("Warning: login: %s", err) } } func login(w http.ResponseWriter, r *http.Request) { if !checkPassword(r.FormValue("password")) { if err := loginTemplate.Execute(w, loginTemplateArgs{Error: true}); err != nil { log.Printf("Warning: login: %s", err) } return } attachCookie(w) http.Redirect(w, r, "/notepad", 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) { executeLoginTemplate(w) 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) } } func main() { flag.Parse() if err := loadSecrets(); err != nil { log.Fatal(err) } mux := http.NewServeMux() mux.HandleFunc("GET /pong", pong) mux.HandleFunc("GET /wormhole", wormhole) mux.HandleFunc("POST /wormhole/{hole}", wormholeSend) mux.HandleFunc("GET /wormhole/{hole}", wormholeRecv) mux.HandleFunc("GET /wormhole/{hole}/ready", wormholeReady) 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.HandleFunc("GET /static/", static) mux.HandleFunc("GET /favicon.ico", favicon) mux.HandleFunc("GET /{$}", index) mux.HandleFunc("GET /", notFound) addr := fmt.Sprintf(":%d", *port) log.Printf("Listening on %q", addr) log.Fatal(http.ListenAndServe(addr, http.NewCrossOriginProtection().Handler(mux))) }