9 Commits

Author SHA1 Message Date
81fbc9b2a4 remove noisy logs 2025-10-10 10:15:51 +01:00
9baf63915c Update README to include capability setup instructions
- Added instructions for setting the `cap_net_bind_service` capability to allow binding to ports 80 and 443 after compilation.
2025-10-10 10:11:23 +01:00
fb3174f91f Add custom favicon support with loading mechanism
- Introduced a new configuration option to specify a custom favicon file.
- Implemented a function to load the favicon data, falling back to the embedded default if not provided.
- Added a handler to serve the favicon as `favicon.ico` for each hostname in the reverse proxy.
2025-10-10 10:07:47 +01:00
069d804c00 Fix X-Forwarded-For header to use only the first IP in the client IP list 2025-08-21 14:53:50 +01:00
b98a7d525b Fix X-Forwarded-For header to use only the first IP in the client IP list 2025-08-21 14:49:13 +01:00
9085df44b8 Add X-Forwarded-For header and log headers in reverse proxy
- Extracted client IP using `remoteIP` and added it to `X-Forwarded-For` header.
- Added logging for request headers in the reverse proxy.
2025-08-21 14:39:50 +01:00
42deda6d06 Add RFC 7239 Forwarded header support to reverse proxy
- Introduced `appendForwardedHeader` to construct and append the RFC 7239 compliant Forwarded header.
- Updated reverse proxy and helper functions to include client, host, and protocol details in the Forwarded header.
- Improved header compatibility for applications using proxy configurations.
2025-08-21 13:47:41 +01:00
e2cbd41e56 Update logging levels for favicon and nostr JSON handlers 2025-08-21 12:57:35 +01:00
84a4105a2a Update logging levels for favicon and nostr JSON handlers 2025-08-21 12:51:11 +01:00
3 changed files with 173 additions and 29 deletions

View File

@@ -1,3 +1,7 @@
= reverse
simple reverse proxy with letsencrypt, nostr nip-05 and go vanity redirects
simple reverse proxy with letsencrypt, nostr nip-05 and go vanity redirects
after compiling, you need to set the cap_net_bind_service capability to allow it to bind to 80 and 443
setcap 'cap_net_bind_service=+ep' /path/to/reverse

196
main.go
View File

@@ -35,6 +35,12 @@ import (
//go:embed favicon.ico
var defaultFavicon []byte
//go:embed orly.png
var orlyFavicon []byte
// faviconData holds the favicon data to serve
var faviconData []byte
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
@@ -44,16 +50,17 @@ func main() {
}
type C struct {
Addr string `env:"REVERSE_LISTEN" usage:"address to listen at" default:":443"`
Conf string `env:"REVERSE_MAP" usage:"file with host/backend mapping" default:"~/.config/reverse/mapping.conf"`
Cache string `env:"REVERSE_CACHE" usage:"path to directory to cache key and certificates" default:"~/.cache/reverse"`
HSTS bool `env:"REVERSE_HSTS" usage:"add Strict-Transport-Security header" default:"true"`
Email string `env:"REVERSE_EMAIL" usage:"contact email address presented to letsencrypt CA"`
HTTP string `env:"REVERSE_HTTP" usage:"optional address to serve http-to-https redirects and ACME http-01 challenge responses" default:":80"`
RTo time.Duration `env:"REVERSE_RTO" usage:"maximum duration before timing out read of the request" default:"1m"`
WTo time.Duration `env:"REVERSE_WTO" usage:"maximum duration before timing out write of the response" default:"5m"`
Idle time.Duration `env:"REVERSE_IDLE" usage:"how long idle connection is kept before closing (set rto, wto to 0 to use this)"`
Certs []string `env:"REVERSE_CERTS" usage:"certificates and the domain they match: eg: mleku.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"`
Addr string `env:"REVERSE_LISTEN" usage:"address to listen at" default:":443"`
Conf string `env:"REVERSE_MAP" usage:"file with host/backend mapping" default:"~/.config/reverse/mapping.conf"`
Cache string `env:"REVERSE_CACHE" usage:"path to directory to cache key and certificates" default:"~/.cache/reverse"`
HSTS bool `env:"REVERSE_HSTS" usage:"add Strict-Transport-Security header" default:"true"`
Email string `env:"REVERSE_EMAIL" usage:"contact email address presented to letsencrypt CA"`
HTTP string `env:"REVERSE_HTTP" usage:"optional address to serve http-to-https redirects and ACME http-01 challenge responses" default:":80"`
RTo time.Duration `env:"REVERSE_RTO" usage:"maximum duration before timing out read of the request" default:"1m"`
WTo time.Duration `env:"REVERSE_WTO" usage:"maximum duration before timing out write of the response" default:"5m"`
Idle time.Duration `env:"REVERSE_IDLE" usage:"how long idle connection is kept before closing (set rto, wto to 0 to use this)"`
Certs []string `env:"REVERSE_CERTS" usage:"certificates and the domain they match: eg: mleku.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"`
Favicon string `env:"REVERSE_FAVICON" usage:"path to custom favicon file to serve as favicon.ico (overrides default orly.png)"`
}
// GetEnv checks if the first command line argument is "env" and returns
@@ -271,6 +278,12 @@ func run(ctx context.Context) error {
if cfg.Cache == "" {
return fmt.Errorf("no cache specified")
}
// Load favicon data
if err := loadFavicon(cfg.Favicon); err != nil {
return fmt.Errorf("failed to load favicon: %v", err)
}
srv, httpHandler, err := setupServer(
cfg.Addr, cfg.Conf, cfg.Cache, cfg.Email, cfg.HSTS, cfg,
)
@@ -437,6 +450,7 @@ func setProxy(mapping map[string]string) (http.Handler, error) {
network, ba = "unix", ba+string(byte(0))
} else if strings.HasPrefix(ba, "git+") {
GoVanity(hn, ba, mux)
addFaviconHandler(hn, mux)
continue
} else if filepath.IsAbs(ba) {
network = "unix"
@@ -446,25 +460,13 @@ func setProxy(mapping map[string]string) (http.Handler, error) {
// this path as static site
fs := http.FileServer(http.Dir(ba))
mux.Handle(hn+"/", fs)
addFaviconHandler(hn, mux)
continue
case strings.HasSuffix(ba, "nostr.json"):
if err := NostrDNS(hn, ba, mux); err != nil {
continue
}
fin := hn + "/favicon.ico"
var fi []byte
var err error
if fi, err = os.ReadFile(fin); !chk.E(err) {
fi = defaultFavicon
}
mux.HandleFunc(
hn+"/favicon.ico",
func(writer http.ResponseWriter, request *http.Request) {
if _, err = writer.Write(fi); chk.E(err) {
return
}
},
)
addFaviconHandler(hn, mux)
continue
}
} else if u, err := url.Parse(ba); err == nil {
@@ -474,6 +476,7 @@ func setProxy(mapping map[string]string) (http.Handler, error) {
rp.ErrorLog = log2.New(io.Discard, "", 0)
rp.BufferPool = bufPool{}
mux.Handle(hn+"/", rp)
addFaviconHandler(hn, mux)
continue
}
}
@@ -481,17 +484,25 @@ func setProxy(mapping map[string]string) (http.Handler, error) {
Director: func(req *http.Request) {
req.URL.Scheme = "http"
req.URL.Host = req.Host
// Backward-compatible header widely used by apps
req.Header.Set("X-Forwarded-Proto", "https")
clientIP := remoteIP(req.RemoteAddr)
split := strings.Split(clientIP, ",")
req.Header.Set("X-Forwarded-For", split[0])
// Standard RFC 7239 Forwarded header
appendForwardedHeader(req)
},
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),
ErrorLog: log2.New(os.Stderr, "reverse", 0),
BufferPool: bufPool{},
}
mux.Handle(hn+"/", rp)
// Add favicon handler for this hostname
addFaviconHandler(hn, mux)
}
return mux, nil
}
@@ -549,8 +560,80 @@ var bufferPool = &sync.Pool{
},
}
// appendForwardedHeader constructs and appends a RFC 7239 Forwarded header
// entry for the current proxy hop. It preserves existing Forwarded header
// values by appending a new entry separated by a comma.
func appendForwardedHeader(req *http.Request) {
// Determine client IP (for=)
clientIP := remoteIP(req.RemoteAddr)
forPart := "for=" + formatForwardedNode(clientIP)
// Determine local address (by=) if available
byPart := ""
if la, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr); ok && la != nil {
lip := localIP(la)
if lip != "" {
byPart = "by=" + formatForwardedNode(lip)
}
}
// Host and proto
protoPart := "proto=https"
// Quote host to be safe when port is present
hostPart := "host=\"" + req.Host + "\""
parts := []string{forPart}
if byPart != "" {
parts = append(parts, byPart)
}
parts = append(parts, protoPart, hostPart)
entry := strings.Join(parts, ";")
existing := req.Header.Get("Forwarded")
if existing == "" {
req.Header.Set("Forwarded", entry)
} else {
req.Header.Set("Forwarded", existing+", "+entry)
}
}
// formatForwardedNode formats an IP for use in Forwarded header node
// identifiers. IPv6 addresses are wrapped as "[ipv6]" and quoted,
// IPv4 are left as-is. If ip can't be parsed, returns "unknown".
func formatForwardedNode(ip string) string {
parsed := net.ParseIP(ip)
if parsed == nil {
return "unknown"
}
if strings.Contains(ip, ":") {
return "\"[" + ip + "]\""
}
return ip
}
// remoteIP extracts the host part from a network address string.
func remoteIP(addr string) string {
if h, _, err := net.SplitHostPort(addr); err == nil {
return h
}
return addr
}
// localIP tries to get the string IP from a net.Addr (e.g., *net.TCPAddr)
func localIP(addr net.Addr) string {
switch v := addr.(type) {
case *net.TCPAddr:
if v.IP != nil {
return v.IP.String()
}
return remoteIP(v.String())
default:
return remoteIP(addr.String())
}
}
// newSingleHostReverseProxy is a copy of httputil.NewSingleHostReverseProxy
// with addition of "X-Forwarded-Proto" header.
// with addition of "X-Forwarded-Proto" and standard RFC 7239 "Forwarded" header.
func newSingleHostReverseProxy(target *url.URL) *httputil.ReverseProxy {
targetQuery := target.RawQuery
director := func(req *http.Request) {
@@ -565,7 +648,13 @@ func newSingleHostReverseProxy(target *url.URL) *httputil.ReverseProxy {
if _, ok := req.Header["User-Agent"]; !ok {
req.Header.Set("User-Agent", "")
}
// Backward-compatible header widely used by apps
req.Header.Set("X-Forwarded-Proto", "https")
clientIP := remoteIP(req.RemoteAddr)
split := strings.Split(clientIP, ",")
req.Header.Set("X-Forwarded-For", split[0])
// Standard RFC 7239 Forwarded header
appendForwardedHeader(req)
}
return &httputil.ReverseProxy{Director: director}
}
@@ -714,7 +803,6 @@ type NostrJSON struct {
// - 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
@@ -731,7 +819,7 @@ func NostrDNS(hn, ba string, mux *http.ServeMux) (err error) {
mux.HandleFunc(
hn+"/.well-known/nostr.json",
func(writer http.ResponseWriter, request *http.Request) {
log.T.Ln("serving nostr json to", hn)
log.I.Ln("serving nip-05 to", hn)
writer.Header().Set(
"Access-Control-Allow-Methods",
"GET,HEAD,PUT,PATCH,POST,DELETE",
@@ -748,5 +836,57 @@ func NostrDNS(hn, ba string, mux *http.ServeMux) (err error) {
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) {
if _, err = writer.Write(fi); chk.E(err) {
return
}
},
)
return
}
// loadFavicon loads the favicon data from the configured file or uses the default
func loadFavicon(faviconPath string) error {
if faviconPath == "" {
// Use default orly.png
faviconData = orlyFavicon
return nil
}
// Expand ~ in path
faviconPath = strings.Replace(faviconPath, "~", os.Getenv("HOME"), 1)
// Load custom favicon file
data, err := os.ReadFile(faviconPath)
if err != nil {
return fmt.Errorf("failed to read favicon file %q: %v", faviconPath, err)
}
faviconData = data
log.I.Ln("loaded custom favicon from", faviconPath)
return nil
}
// addFaviconHandler adds a favicon handler for the given hostname that serves
// the configured favicon as favicon.ico
func addFaviconHandler(hostname string, mux *http.ServeMux) {
mux.HandleFunc(
hostname+"/favicon.ico",
func(w http.ResponseWriter, r *http.Request) {
log.I.Ln("serving favicon for", hostname)
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", fmt.Sprint(len(faviconData)))
w.Header().Set("Cache-Control", "public, max-age=31536000") // 1 year cache
if _, err := w.Write(faviconData); err != nil {
log.E.Ln("error writing favicon:", err)
}
},
)
}

BIN
orly.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB