// Command lerproxy implements https reverse proxy with automatic LetsEncrypt // usage for multiple hostnames/backends,your own SSL certificates, nostr NIP-05 // DNS verification hosting and Go vanity redirects. package main import ( "bufio" "crypto/tls" _ "embed" "encoding/json" "fmt" "io" stdLog "log" "net" "net/http" "net/http/httputil" "net/url" "orly.dev/cmd/lerproxy/buf" "orly.dev/cmd/lerproxy/hsts" "orly.dev/cmd/lerproxy/reverse" "orly.dev/cmd/lerproxy/tcpkeepalive" "orly.dev/cmd/lerproxy/util" "orly.dev/pkg/utils/chk" "orly.dev/pkg/utils/context" "orly.dev/pkg/utils/log" "os" "os/signal" "path/filepath" "runtime" "strings" "sync" "time" "github.com/alexflint/go-arg" "golang.org/x/crypto/acme/autocert" "golang.org/x/sync/errgroup" ) //go:embed favicon.ico var defaultFavicon []byte type runArgs struct { Addr string `arg:"-l,--listen" default:":https" help:"address to listen at"` Conf string `arg:"-m,--map" default:"mapping.txt" help:"file with host/backend mapping"` Cache string `arg:"-c,--cachedir" default:"/var/cache/letsencrypt" help:"path to directory to cache key and certificates"` HSTS bool `arg:"-h,--hsts" help:"add Strict-Transport-Security header"` Email string `arg:"-e,--email" help:"contact email address presented to letsencrypt CA"` HTTP string `arg:"--http" default:":http" help:"optional address to serve http-to-https redirects and ACME http-01 challenge responses"` RTO time.Duration `arg:"-r,--rto" default:"1m" help:"maximum duration before timing out read of the request"` WTO time.Duration `arg:"-w,--wto" default:"5m" help:"maximum duration before timing out write of the response"` Idle time.Duration `arg:"-i,--idle" help:"how long idle connection is kept before closing (set rto, wto to 0 to use this)"` Certs []string `arg:"--cert,separate" help:"certificates and the domain they match: eg: orly.dev:/path/to/cert - this will indicate to load two, one with extension .key and one with .crt, each expected to be PEM encoded TLS private and public keys, respectively"` // Rewrites string `arg:"-r,--rewrites" default:"rewrites.txt"` } var args runArgs func main() { arg.MustParse(&args) ctx, cancel := signal.NotifyContext(context.Bg(), os.Interrupt) defer cancel() if err := run(ctx, args); chk.T(err) { log.F.Ln(err) } } func run(c context.T, args runArgs) (err error) { if args.Cache == "" { err = log.E.Err("no cache specified") return } var srv *http.Server var httpHandler http.Handler if srv, httpHandler, err = setupServer(args); chk.E(err) { return } 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(c) if args.HTTP != "" { httpServer := http.Server{ Addr: args.HTTP, Handler: httpHandler, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, } group.Go( func() (err error) { chk.E(httpServer.ListenAndServe()) return }, ) group.Go( func() error { <-ctx.Done() ctx, cancel := context.Timeout( context.Bg(), time.Second, ) defer cancel() return httpServer.Shutdown(ctx) }, ) } if srv.ReadTimeout != 0 || srv.WriteTimeout != 0 || args.Idle == 0 { group.Go( func() (err error) { chk.E(srv.ListenAndServeTLS("", "")) return }, ) } else { group.Go( func() (err error) { var ln net.Listener if ln, err = net.Listen("tcp", srv.Addr); chk.E(err) { return } defer ln.Close() ln = tcpkeepalive.Listener{ Duration: args.Idle, TCPListener: ln.(*net.TCPListener), } err = srv.ServeTLS(ln, "", "") chk.E(err) return }, ) } group.Go( func() error { <-ctx.Done() ctx, cancel := context.Timeout(context.Bg(), time.Second) defer cancel() return srv.Shutdown(ctx) }, ) return group.Wait() } // TLSConfig returns a TLSConfig that works with a LetsEncrypt automatic SSL cert issuer as well // as any provided .pem certificates from providers. // // The certs are provided in the form "example.com:/path/to/cert.pem" func TLSConfig(m *autocert.Manager, certs ...string) (tc *tls.Config) { certMap := make(map[string]*tls.Certificate) var mx sync.Mutex for _, cert := range certs { split := strings.Split(cert, ":") if len(split) != 2 { log.E.F("invalid certificate parameter format: `%s`", cert) continue } var err error var c tls.Certificate if c, err = tls.LoadX509KeyPair( split[1]+".crt", split[1]+".key", ); chk.E(err) { continue } certMap[split[0]] = &c } tc = m.TLSConfig() tc.GetCertificate = func(helo *tls.ClientHelloInfo) ( cert *tls.Certificate, err error, ) { mx.Lock() var own string for i := range certMap { // to also handle explicit subdomain certs, prioritize over a root wildcard. if helo.ServerName == i { own = i break } // if it got to us and ends in the same name dot tld assume the subdomain was // redirected or it's a wildcard certificate, thus only the ending needs to match. if strings.HasSuffix(helo.ServerName, i) { own = i break } } if own != "" { defer mx.Unlock() return certMap[own], nil } mx.Unlock() return m.GetCertificate(helo) } return } func setupServer(a runArgs) (s *http.Server, h http.Handler, err error) { var mapping map[string]string if mapping, err = readMapping(a.Conf); chk.E(err) { return } var proxy http.Handler if proxy, err = setProxy(mapping); chk.E(err) { return } if a.HSTS { proxy = &hsts.Proxy{Handler: proxy} } if err = os.MkdirAll(a.Cache, 0700); chk.E(err) { err = fmt.Errorf( "cannot create cache directory %q: %v", a.Cache, err, ) chk.E(err) return } m := autocert.Manager{ Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(a.Cache), HostPolicy: autocert.HostWhitelist(util.GetKeys(mapping)...), Email: a.Email, } s = &http.Server{ Handler: proxy, Addr: a.Addr, TLSConfig: TLSConfig(&m, a.Certs...), } h = m.HTTPHandler(nil) return } type NostrJSON struct { Names map[string]string `json:"names"` Relays map[string][]string `json:"relays"` } func setProxy(mapping map[string]string) (h http.Handler, err error) { if len(mapping) == 0 { return nil, fmt.Errorf("empty mapping") } mux := http.NewServeMux() for hostname, backendAddr := range mapping { hn, ba := hostname, backendAddr if strings.ContainsRune(hn, os.PathSeparator) { err = log.E.Err("invalid hostname: %q", hn) return } 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+") { split := strings.Split(ba, "git+") if len(split) != 2 { log.E.Ln("invalid go vanity redirect: %s: %s", hn, ba) continue } 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) }, ) 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"): log.I.Ln(hn, ba) var fb []byte if fb, err = os.ReadFile(ba); chk.E(err) { continue } var v NostrJSON if err = json.Unmarshal(fb, &v); chk.E(err) { continue } var jb []byte if jb, err = json.Marshal(v); chk.E(err) { continue } nostrJSON := string(jb) mux.HandleFunc( hn+"/.well-known/nostr.json", func(writer http.ResponseWriter, request *http.Request) { log.I.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) }, ) fin := hn + "/favicon.ico" var fi []byte if fi, err = os.ReadFile(fin); !chk.E(err) { fi = defaultFavicon } mux.HandleFunc( hn+"/favicon.ico", func(writer http.ResponseWriter, request *http.Request) { log.I.F("serving favicon to %s", hn) if _, err = writer.Write(fi); chk.E(err) { return } }, ) continue } } else if u, err := url.Parse(ba); err == nil { switch u.Scheme { case "http", "https": rp := reverse.NewSingleHostReverseProxy(u) modifyCORSResponse := func(res *http.Response) error { res.Header.Set( "Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE", ) // res.Header.Set("Access-Control-Allow-Credentials", "true") res.Header.Set("Access-Control-Allow-Origin", "*") return nil } rp.ModifyResponse = modifyCORSResponse rp.ErrorLog = stdLog.New( os.Stderr, "lerproxy", stdLog.Llongfile, ) rp.BufferPool = buf.Pool{} 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") req.Header.Set("X-Forwarded-For", req.RemoteAddr) req.Header.Set( "Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE", ) // req.Header.Set("Access-Control-Allow-Credentials", "true") req.Header.Set("Access-Control-Allow-Origin", "*") log.I.Ln(req.URL, req.RemoteAddr) }, Transport: &http.Transport{ DialContext: func(c context.T, n, addr string) ( net.Conn, error, ) { return net.DialTimeout(network, ba, 5*time.Second) }, }, ErrorLog: stdLog.New(io.Discard, "", 0), BufferPool: buf.Pool{}, } mux.Handle(hn+"/", rp) } return mux, nil } func readMapping(file string) (m map[string]string, err error) { var f *os.File if f, err = os.Open(file); chk.E(err) { return } 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 { err = fmt.Errorf("invalid line: %q", sc.Text()) log.E.Ln(err) chk.E(f.Close()) return } m[strings.TrimSpace(s[0])] = strings.TrimSpace(s[1]) } err = sc.Err() chk.E(err) chk.E(f.Close()) return }