From 6d9306c4222012c0e466662bef6f92d7b3e0fd43 Mon Sep 17 00:00:00 2001 From: mleku Date: Tue, 19 Aug 2025 19:42:08 +0100 Subject: [PATCH] reverted to old leproxy code --- cmd/hello/main.go | 83 ++++++++ config/config.go | 225 ---------------------- go.mod | 6 +- go.sum | 12 +- main.go | 475 +++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 564 insertions(+), 237 deletions(-) create mode 100644 cmd/hello/main.go delete mode 100644 config/config.go diff --git a/cmd/hello/main.go b/cmd/hello/main.go new file mode 100644 index 0000000..8aa49a7 --- /dev/null +++ b/cmd/hello/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "log" + "net" + "net/http" + "net/url" + "os" + "strings" +) + +// normalizeAddr converts various address formats into a value acceptable by http.ListenAndServe. +// Examples: +// - "http://127.0.0.1:8080" -> "127.0.0.1:8080" +// - "http://127.0.0.1" -> "127.0.0.1:80" +// - "127.0.0.1:8080" -> "127.0.0.1:8080" +// - "127.0.0.1" -> "127.0.0.1:80" +// - ":8080" -> ":8080" +func normalizeAddr(arg string) (string, error) { + s := strings.TrimSpace(arg) + if s == "" { + return "", fmt.Errorf("empty address") + } + // If it looks like a URL with scheme + if strings.Contains(s, "://") { + u, err := url.Parse(s) + if err != nil { + return "", err + } + if u.Scheme != "http" && u.Scheme != "" { + return "", fmt.Errorf("unsupported scheme: %s", u.Scheme) + } + host := u.Host + if host == "" { + // Some inputs might be like http://:8080 where Host is empty and Path holds the value + host = strings.TrimPrefix(u.Path, "/") + } + if host == "" { + return "", fmt.Errorf("missing host") + } + if _, _, err := net.SplitHostPort(host); err != nil { + // No port: default to 80 for http + // Handle possible IPv6 without brackets by joining properly + // net.JoinHostPort expects a bare host without brackets + if strings.HasPrefix(host, "[") && strings.Contains(host, "]") { + host = strings.Trim(host, "[]") + } + host = net.JoinHostPort(host, "80") + } + return host, nil + } + // Bare address cases + if _, _, err := net.SplitHostPort(s); err == nil { + return s, nil + } + // If it's only :port or host without port + if strings.HasPrefix(s, ":") { + return s, nil + } + return net.JoinHostPort(s, "80"), nil +} + +func main() { + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "Usage: %s [address or URL]\nExample: %s http://127.0.0.1:8080\n", os.Args[0], os.Args[0]) + os.Exit(2) + } + addr, err := normalizeAddr(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, "Invalid address:", err) + os.Exit(2) + } + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("hello world!")) + }) + log.Printf("Listening on %s", addr) + if err := http.ListenAndServe(addr, nil); err != nil { + log.Fatal(err) + } +} diff --git a/config/config.go b/config/config.go deleted file mode 100644 index f363d48..0000000 --- a/config/config.go +++ /dev/null @@ -1,225 +0,0 @@ -package config - -import ( - "fmt" - "io" - "os" - "reflect" - "sort" - "strings" - "time" - - "github.com/mleku/lol/chk" - "go-simpler.org/env" -) - -type C struct { - LogLevel string `env:"REVERSE_LOG_LEVEL" default:"info" usage:"Log level: fatal error warn info debug trace"` - Listen string `env:"REVERSE_LISTEN" usage:"Listen address for reverse proxy" default:":443"` - Mapping string `env:"REVERSE_MAPPING" usage:"file containing domain/target mappings for reverse proxy" default:"~/.config/reverse/mapping.conf"` - Cache string `env:"REVERSE_CERTCACHE" usage:"directory where certificates from letsencrypt are stored" default:"~/.cache/reverse"` - HSTS bool `env:"REVERSE_HSTS" usage:"add Strict-TransportSecurity header" default:"false"` - Email string `env:"REVERSE_EMAIL" usage:"email address presented to letsencrypt CA"` - HTTP string `env:"REVERSE_HTTP" usage:"http address for HTTP->HTTPS upgrades" default:":80"` - Idle time.Duration `env:"REVERSE_IDLE" usage:"how long idle connection is kept before closing" default:"1m"` -} - -func New() (cfg *C, err error) { - cfg = new(C) - if err = env.Load(cfg, &env.Options{SliceSep: ","}); chk.T(err) { - return - } - if GetEnv() { - PrintEnv(cfg, os.Stdout) - os.Exit(0) - } - if HelpRequested() { - PrintHelp(cfg, os.Stderr) - os.Exit(0) - } - return -} - -// HelpRequested determines if the command line arguments indicate a request for help -// -// # Return Values -// -// - help: A boolean value indicating true if a help flag was detected in the -// command line arguments, false otherwise -// -// # Expected Behaviour -// -// The function checks the first command line argument for common help flags and -// returns true if any of them are present. Returns false if no help flag is found -func HelpRequested() (help bool) { - if len(os.Args) > 1 { - switch strings.ToLower(os.Args[1]) { - case "help", "-h", "--h", "-help", "--help", "?": - help = true - } - } - return -} - -// GetEnv checks if the first command line argument is "env" and returns -// whether the environment configuration should be printed. -// -// # Return Values -// -// - requested: A boolean indicating true if the 'env' argument was -// provided, false otherwise. -// -// # Expected Behaviour -// -// The function returns true when the first command line argument is "env" -// (case-insensitive), signalling that the environment configuration should be -// printed. Otherwise, it returns false. -func GetEnv() (requested bool) { - if len(os.Args) > 1 { - switch strings.ToLower(os.Args[1]) { - case "env": - requested = true - } - } - return -} - -// KV is a key/value pair. -type KV struct{ Key, Value string } - -// KVSlice is a sortable slice of key/value pairs, designed for managing -// configuration data and enabling operations like merging and sorting based on -// keys. -type KVSlice []KV - -func (kv KVSlice) Len() int { return len(kv) } -func (kv KVSlice) Less(i, j int) bool { return kv[i].Key < kv[j].Key } -func (kv KVSlice) Swap(i, j int) { kv[i], kv[j] = kv[j], kv[i] } - -// Compose merges two KVSlice instances into a new slice where key-value pairs -// from the second slice override any duplicate keys from the first slice. -// -// # Parameters -// -// - kv2: The second KVSlice whose entries will be merged with the receiver. -// -// # Return Values -// -// - out: A new KVSlice containing all entries from both slices, with keys -// from kv2 taking precedence over keys from the receiver. -// -// # Expected Behaviour -// -// The method returns a new KVSlice that combines the contents of the receiver -// and kv2. If any key exists in both slices, the value from kv2 is used. The -// resulting slice remains sorted by keys as per the KVSlice implementation. -func (kv KVSlice) Compose(kv2 KVSlice) (out KVSlice) { - // duplicate the initial KVSlice - for _, p := range kv { - out = append(out, p) - } -out: - for i, p := range kv2 { - for j, q := range out { - // if the key is repeated, replace the value - if p.Key == q.Key { - out[j].Value = kv2[i].Value - continue out - } - } - out = append(out, p) - } - return -} - -// EnvKV generates key/value pairs from a configuration object's struct tags -// -// # Parameters -// -// - cfg: A configuration object whose struct fields are processed for env tags -// -// # Return Values -// -// - m: A KVSlice containing key/value pairs derived from the config's env tags -// -// # Expected Behaviour -// -// Processes each field of the config object, extracting values tagged with -// "env" and converting them to strings. Skips fields without an "env" tag. -// Handles various value types including strings, integers, booleans, durations, -// and string slices by joining elements with commas. -func EnvKV(cfg any) (m KVSlice) { - t := reflect.TypeOf(cfg) - for i := 0; i < t.NumField(); i++ { - k := t.Field(i).Tag.Get("env") - v := reflect.ValueOf(cfg).Field(i).Interface() - var val string - switch v.(type) { - case string: - val = v.(string) - case int, bool, time.Duration: - val = fmt.Sprint(v) - case []string: - arr := v.([]string) - if len(arr) > 0 { - val = strings.Join(arr, ",") - } - } - // this can happen with embedded structs - if k == "" { - continue - } - m = append(m, KV{k, val}) - } - return -} - -// PrintEnv outputs sorted environment key/value pairs from a configuration object -// to the provided writer -// -// # Parameters -// -// - cfg: Pointer to the configuration object containing env tags -// -// - printer: Destination for the output, typically an io.Writer implementation -// -// # Expected Behaviour -// -// Outputs each environment variable derived from the config's struct tags in -// sorted order, formatted as "key=value\n" to the specified writer -func PrintEnv(cfg *C, printer io.Writer) { - kvs := EnvKV(*cfg) - sort.Sort(kvs) - for _, v := range kvs { - _, _ = fmt.Fprintf(printer, "%s=%s\n", v.Key, v.Value) - } -} - -// PrintHelp prints help information including application version, environment -// variable configuration, and details about .env file handling to the provided -// writer -// -// # Parameters -// -// - cfg: Configuration object containing app name and config directory path -// -// - printer: Output destination for the help text -// -// # Expected Behaviour -// -// Prints application name and version followed by environment variable -// configuration details, explains .env file behaviour including automatic -// loading and custom path options, and displays current configuration values -// using PrintEnv. Outputs all information to the specified writer -func PrintHelp(cfg *C, printer io.Writer) { - _, _ = fmt.Fprintf(printer, "reverse usage:\n\n") - env.Usage(cfg, printer, &env.Options{SliceSep: ","}) - _, _ = fmt.Fprintf( - printer, - "\nCLI parameter 'help' also prints this information\n"+ - "use the parameter 'env' to print out the current configuration to the terminal\n\n", - ) - _, _ = fmt.Fprintf(printer, "current configuration:\n\n") - PrintEnv(cfg, printer) - return -} diff --git a/go.mod b/go.mod index 2c4c1e6..90dc150 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module github.com/mleku/reverse go 1.25.0 require ( + github.com/artyom/autoflags v1.1.1 github.com/mleku/lol v1.0.1 - go-simpler.org/env v0.12.0 + golang.org/x/crypto v0.41.0 + golang.org/x/sync v0.16.0 ) require ( @@ -12,5 +14,7 @@ require ( github.com/fatih/color v1.18.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/net v0.42.0 // indirect golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 8ab869b..cde864e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/artyom/autoflags v1.1.1 h1:8flRmpb7xpjLHFVcM+HN+cEEKLw+H5a2hABDbRvfG9A= +github.com/artyom/autoflags v1.1.1/go.mod h1:Th9KgAVvFcYp7t8b//Pu21xHjExLpzr4SXCbwVbHL7Y= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -8,8 +10,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mleku/lol v1.0.1 h1:CMGpeMTh2iE7Xc4/dtJxs0vZqwRmPiGxsqpWxdmOZKc= github.com/mleku/lol v1.0.1/go.mod h1:daW3rL0XP4ZKscvWn990AJCrJs2Lsu+sdrI9cWbacWE= -go-simpler.org/env v0.12.0 h1:kt/lBts0J1kjWJAnB740goNdvwNxt5emhYngL0Fzufs= -go-simpler.org/env v0.12.0/go.mod h1:cc/5Md9JCUM7LVLtN0HYjPTDcI3Q8TDaPlNTAlDU+WI= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= diff --git a/main.go b/main.go index 18a6c4c..8a8a666 100644 --- a/main.go +++ b/main.go @@ -1,21 +1,478 @@ +// Command leproxy implements https reverse proxy with automatic Letsencrypt usage for multiple +// hostnames/backends package main import ( + "bufio" + "context" + "encoding/json" "fmt" + "io" + log2 "log" + "net" + "net/http" + "net/http/httputil" + "net/url" "os" + "os/signal" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + "github.com/artyom/autoflags" "github.com/mleku/lol/chk" - "github.com/mleku/reverse/config" + "github.com/mleku/lol/log" + "golang.org/x/crypto/acme/autocert" + "golang.org/x/sync/errgroup" ) func main() { - var err error - var cfg *config.C - if cfg, err = config.New(); chk.T(err) { - if err != nil { - fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", err) - } - config.PrintHelp(cfg, os.Stderr) - os.Exit(0) + args := runArgs{ + Addr: ":https", + HTTP: ":http", + Conf: os.Getenv("HOME") + "/.config/reverse/mapping.conf", + Cache: os.Getenv("HOME") + "/.cache/reverse", + RTo: time.Minute, + WTo: 5 * time.Minute, + } + autoflags.Parse(&args) + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + if err := run(ctx, args); err != nil { + log2.Fatal(err) } } + +type runArgs struct { + Addr string `flag:"addr,address to listen at"` + Conf string `flag:"map,file with host/backend mapping"` + Cache string `flag:"cacheDir,path to directory to cache key and certificates"` + HSTS bool `flag:"hsts,add Strict-Transport-Security header"` + Email string `flag:"email,contact email address presented to letsencrypt CA"` + HTTP string `flag:"http,optional address to serve http-to-https redirects and ACME http-01 challenge responses"` + + RTo time.Duration `flag:"rto,maximum duration before timing out read of the request"` + WTo time.Duration `flag:"wto,maximum duration before timing out write of the response"` + Idle time.Duration `flag:"idle,how long idle connection is kept before closing (set rto, wto to 0 to use this)"` +} + +func run(ctx context.Context, args runArgs) error { + if args.Cache == "" { + return fmt.Errorf("no cache specified") + } + srv, httpHandler, err := setupServer( + args.Addr, args.Conf, args.Cache, args.Email, args.HSTS, + ) + if err != nil { + return err + } + srv.ReadHeaderTimeout = 5 * time.Second + if args.RTo > 0 { + srv.ReadTimeout = args.RTo + } + if args.WTo > 0 { + srv.WriteTimeout = args.WTo + } + group, ctx := errgroup.WithContext(ctx) + if args.HTTP != "" { + httpServer := http.Server{ + Addr: args.HTTP, + Handler: httpHandler, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + group.Go(func() error { return httpServer.ListenAndServe() }) + group.Go( + func() error { + <-ctx.Done() + ctx, cancel := context.WithTimeout( + context.Background(), time.Second, + ) + defer cancel() + return httpServer.Shutdown(ctx) + }, + ) + } + if srv.ReadTimeout != 0 || srv.WriteTimeout != 0 || args.Idle == 0 { + group.Go(func() error { return srv.ListenAndServeTLS("", "") }) + } else { + group.Go( + func() error { + ln, err := net.Listen("tcp", srv.Addr) + if err != nil { + return err + } + defer ln.Close() + ln = tcpKeepAliveListener{ + d: args.Idle, + TCPListener: ln.(*net.TCPListener), + } + return srv.ServeTLS(ln, "", "") + }, + ) + } + group.Go( + func() error { + <-ctx.Done() + ctx, cancel := context.WithTimeout( + context.Background(), time.Second, + ) + defer cancel() + return srv.Shutdown(ctx) + }, + ) + return group.Wait() +} + +func setupServer( + addr, mapfile, cacheDir, email string, hsts bool, +) (*http.Server, http.Handler, error) { + mapping, err := readMapping(mapfile) + if err != nil { + return nil, nil, err + } + proxy, err := setProxy(mapping) + if err != nil { + return nil, nil, err + } + if hsts { + proxy = &hstsProxy{proxy} + } + if err := os.MkdirAll(cacheDir, 0700); err != nil { + return nil, nil, fmt.Errorf( + "cannot create cache directory %q: %v", cacheDir, err, + ) + } + m := autocert.Manager{ + Prompt: autocert.AcceptTOS, + Cache: autocert.DirCache(cacheDir), + HostPolicy: autocert.HostWhitelist(keys(mapping)...), + Email: email, + } + srv := &http.Server{ + Handler: proxy, + Addr: addr, + TLSConfig: m.TLSConfig(), + } + return srv, m.HTTPHandler(nil), nil +} + +func setProxy(mapping map[string]string) (http.Handler, error) { + if len(mapping) == 0 { + return nil, fmt.Errorf("empty mapping") + } + mux := http.NewServeMux() + for hostname, backendAddr := range mapping { + hn, ba := hostname, backendAddr // intentional shadowing + if strings.ContainsRune(hn, os.PathSeparator) { + return nil, fmt.Errorf("invalid hostname: %q", hn) + } + network := "tcp" + if ba != "" && ba[0] == '@' && runtime.GOOS == "linux" { + // append \0 to address so addrlen for connect(2) is + // calculated in a way compatible with some other + // implementations (i.e. uwsgi) + network, ba = "unix", ba+string(byte(0)) + } else if strings.HasPrefix(ba, "git+") { + GoVanity(hn, ba, mux) + continue + } else if filepath.IsAbs(ba) { + network = "unix" + switch { + case strings.HasSuffix(ba, string(os.PathSeparator)): + // path specified as directory with explicit trailing slash; add + // this path as static site + fs := http.FileServer(http.Dir(ba)) + mux.Handle(hn+"/", fs) + continue + case strings.HasSuffix(ba, "nostr.json"): + if err := NostrDNS(hn, ba, mux); err != nil { + continue + } + continue + } + } else if u, err := url.Parse(ba); err == nil { + switch u.Scheme { + case "http", "https": + rp := newSingleHostReverseProxy(u) + rp.ErrorLog = log2.New(io.Discard, "", 0) + rp.BufferPool = bufPool{} + mux.Handle(hn+"/", rp) + continue + } + } + rp := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = "http" + req.URL.Host = req.Host + req.Header.Set("X-Forwarded-Proto", "https") + }, + Transport: &http.Transport{ + Dial: func(netw, addr string) (net.Conn, error) { + return net.DialTimeout(network, ba, 5*time.Second) + }, + }, + ErrorLog: log2.New(io.Discard, "", 0), + BufferPool: bufPool{}, + } + mux.Handle(hn+"/", rp) + } + return mux, nil +} + +func readMapping(file string) (map[string]string, error) { + f, err := os.Open(file) + if err != nil { + return nil, err + } + defer f.Close() + m := make(map[string]string) + sc := bufio.NewScanner(f) + for sc.Scan() { + if b := sc.Bytes(); len(b) == 0 || b[0] == '#' { + continue + } + s := strings.SplitN(sc.Text(), ":", 2) + if len(s) != 2 { + return nil, fmt.Errorf("invalid line: %q", sc.Text()) + } + m[strings.TrimSpace(s[0])] = strings.TrimSpace(s[1]) + } + return m, sc.Err() +} + +func keys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +type hstsProxy struct { + http.Handler +} + +func (p *hstsProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains; preload", + ) + p.Handler.ServeHTTP(w, r) +} + +type bufPool struct{} + +func (bp bufPool) Get() []byte { return *(bufferPool.Get().(*[]byte)) } +func (bp bufPool) Put(b []byte) { bufferPool.Put(&b) } + +var bufferPool = &sync.Pool{ + New: func() interface{} { + buf := make([]byte, 32*1024) + return &buf + }, +} + +// newSingleHostReverseProxy is a copy of httputil.NewSingleHostReverseProxy +// with addition of "X-Forwarded-Proto" header. +func newSingleHostReverseProxy(target *url.URL) *httputil.ReverseProxy { + targetQuery := target.RawQuery + director := func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path) + if targetQuery == "" || req.URL.RawQuery == "" { + req.URL.RawQuery = targetQuery + req.URL.RawQuery + } else { + req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery + } + if _, ok := req.Header["User-Agent"]; !ok { + req.Header.Set("User-Agent", "") + } + req.Header.Set("X-Forwarded-Proto", "https") + } + return &httputil.ReverseProxy{Director: director} +} + +func singleJoiningSlash(a, b string) string { + aslash := strings.HasSuffix(a, "/") + bslash := strings.HasPrefix(b, "/") + switch { + case aslash && bslash: + return a + b[1:] + case !aslash && !bslash: + return a + "/" + b + } + return a + b +} + +// tcpKeepAliveListener sets TCP keep-alive timeouts on accepted +// connections. It's used by ListenAndServe and ListenAndServeTLS so +// dead TCP connections (e.g. closing laptop mid-download) eventually +// go away. +type tcpKeepAliveListener struct { + d time.Duration + *net.TCPListener +} + +func (ln tcpKeepAliveListener) Accept() (net.Conn, error) { + tc, err := ln.AcceptTCP() + if err != nil { + return nil, err + } + tc.SetKeepAlive(true) + tc.SetKeepAlivePeriod(3 * time.Minute) + if ln.d == 0 { + return tc, nil + } + return timeoutConn{d: ln.d, TCPConn: tc}, nil +} + +// timeoutConn extends deadline after successful read or write operations +type timeoutConn struct { + d time.Duration + *net.TCPConn +} + +func (c timeoutConn) Read(b []byte) (int, error) { + n, err := c.TCPConn.Read(b) + if err == nil { + _ = c.TCPConn.SetDeadline(time.Now().Add(c.d)) + } + return n, err +} + +func (c timeoutConn) Write(b []byte) (int, error) { + n, err := c.TCPConn.Write(b) + if err == nil { + _ = c.TCPConn.SetDeadline(time.Now().Add(c.d)) + } + return n, err +} + +// GoVanity configures an HTTP handler for redirecting requests to vanity URLs +// based on the provided hostname and backend address. +// +// # Parameters +// +// - hn (string): The hostname associated with the vanity URL. +// +// - ba (string): The backend address, expected to be in the format +// "git+". +// +// - mux (*http.ServeMux): The HTTP serve multiplexer where the handler will be +// registered. +// +// # Expected behaviour +// +// - Splits the backend address to extract the repository path from the "git+" prefix. +// +// - If the split fails, logs an error and returns without registering a handler. +// +// - Generates an HTML redirect page containing metadata for Go import and +// redirects to the extracted repository path. +// +// - Registers a handler on the provided ServeMux that serves this redirect page +// when requests are made to the specified hostname. +func GoVanity(hn, ba string, mux *http.ServeMux) { + split := strings.Split(ba, "git+") + if len(split) != 2 { + log.E.Ln("invalid go vanity redirect: %s: %s", hn, ba) + return + } + redirector := fmt.Sprintf( + `redirecting to %s`, + hn, split[1], split[1], split[1], split[1], + ) + mux.HandleFunc( + hn+"/", + func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set( + "Access-Control-Allow-Methods", + "GET,HEAD,PUT,PATCH,POST,DELETE", + ) + writer.Header().Set("Access-Control-Allow-Origin", "*") + writer.Header().Set("Content-Type", "text/html") + writer.Header().Set( + "Content-Length", fmt.Sprint(len(redirector)), + ) + writer.Header().Set( + "strict-transport-security", + "max-age=0; includeSubDomains", + ) + fmt.Fprint(writer, redirector) + }, + ) +} + +type NostrJSON struct { + Names map[string]string `json:"names"` + Relays map[string][]string `json:"relays"` +} + +// NostrDNS handles the configuration and registration of a Nostr DNS endpoint +// for a given hostname and backend address. +// +// # Parameters +// +// - hn (string): The hostname for which the Nostr DNS entry is being configured. +// +// - ba (string): The path to the JSON file containing the Nostr DNS data. +// +// - mux (*http.ServeMux): The HTTP serve multiplexer to which the Nostr DNS +// handler will be registered. +// +// # Return Values +// +// - err (error): An error if any step fails during the configuration or +// registration process. +// +// # Expected behaviour +// +// - Reads the JSON file specified by `ba` and parses its contents into a +// NostrJSON struct. +// +// - Registers a new HTTP handler on the provided `mux` for the +// `.well-known/nostr.json` endpoint under the specified hostname. +// +// - The handler serves the parsed Nostr DNS data with appropriate HTTP headers +// set for CORS and content type. +func NostrDNS(hn, ba string, mux *http.ServeMux) (err error) { + log.T.Ln(hn, ba) + var fb []byte + if fb, err = os.ReadFile(ba); chk.E(err) { + return + } + var v NostrJSON + if err = json.Unmarshal(fb, &v); chk.E(err) { + return + } + var jb []byte + if jb, err = json.Marshal(v); chk.E(err) { + return + } + nostrJSON := string(jb) + mux.HandleFunc( + hn+"/.well-known/nostr.json", + func(writer http.ResponseWriter, request *http.Request) { + log.T.Ln("serving nostr json to", hn) + writer.Header().Set( + "Access-Control-Allow-Methods", + "GET,HEAD,PUT,PATCH,POST,DELETE", + ) + writer.Header().Set("Access-Control-Allow-Origin", "*") + writer.Header().Set("Content-Type", "application/json") + writer.Header().Set( + "Content-Length", fmt.Sprint(len(nostrJSON)), + ) + writer.Header().Set( + "strict-transport-security", + "max-age=0; includeSubDomains", + ) + fmt.Fprint(writer, nostrJSON) + }, + ) + return +}