Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
fe83bd5b71
|
|||
|
456a0ce108
|
|||
|
81fbc9b2a4
|
|||
|
9baf63915c
|
|||
|
fb3174f91f
|
|||
|
069d804c00
|
|||
|
b98a7d525b
|
|||
|
9085df44b8
|
|||
|
42deda6d06
|
|||
|
e2cbd41e56
|
|||
|
84a4105a2a
|
|||
|
99e319c0be
|
|||
|
d9899fd23e
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -87,6 +87,7 @@ node_modules/**
|
||||
!.gitignore
|
||||
!version
|
||||
!out.jsonl
|
||||
!Dockerfile*
|
||||
# ...even if they are in subdirectories
|
||||
!*/
|
||||
/blocklist.json
|
||||
|
||||
@@ -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
|
||||
BIN
favicon.ico
Normal file
BIN
favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
2
go.mod
2
go.mod
@@ -3,10 +3,10 @@ module reverse.mleku.dev
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
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
|
||||
lol.mleku.dev v1.0.2
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
4
go.sum
4
go.sum
@@ -6,8 +6,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
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=
|
||||
@@ -21,3 +19,5 @@ 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=
|
||||
lol.mleku.dev v1.0.2 h1:bSV1hHnkmt1hq+9nSvRwN6wgcI7itbM3XRZ4dMB438c=
|
||||
lol.mleku.dev v1.0.2/go.mod h1:DQ0WnmkntA9dPLCXgvtIgYt5G0HSqx3wSTLolHgWeLA=
|
||||
|
||||
225
main.go
225
main.go
@@ -6,6 +6,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -31,8 +32,19 @@ import (
|
||||
"lol.mleku.dev/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
//go:embed favicon.ico
|
||||
var defaultFavicon []byte
|
||||
|
||||
//go:embed orly.png
|
||||
var orlyFavicon []byte
|
||||
|
||||
// faviconData holds the favicon data to serve
|
||||
var faviconData []byte
|
||||
|
||||
// faviconContentType holds the content type for the favicon
|
||||
var faviconContentType string
|
||||
|
||||
func main() {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
if err := run(ctx); err != nil {
|
||||
@@ -41,16 +53,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 favicon.ico)"`
|
||||
}
|
||||
|
||||
// GetEnv checks if the first command line argument is "env" and returns
|
||||
@@ -268,6 +281,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,
|
||||
)
|
||||
@@ -434,6 +453,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"
|
||||
@@ -443,11 +463,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
|
||||
}
|
||||
addFaviconHandler(hn, mux)
|
||||
continue
|
||||
}
|
||||
} else if u, err := url.Parse(ba); err == nil {
|
||||
@@ -456,7 +478,16 @@ func setProxy(mapping map[string]string) (http.Handler, error) {
|
||||
rp := newSingleHostReverseProxy(u)
|
||||
rp.ErrorLog = log2.New(io.Discard, "", 0)
|
||||
rp.BufferPool = bufPool{}
|
||||
rp.ModifyResponse = func(resp *http.Response) error {
|
||||
// Add CORS headers to all proxied responses
|
||||
resp.Header.Set("Access-Control-Allow-Origin", "*")
|
||||
resp.Header.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
resp.Header.Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization, Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version, Sec-WebSocket-Protocol, Sec-WebSocket-Extensions")
|
||||
resp.Header.Set("Access-Control-Max-Age", "86400")
|
||||
return nil
|
||||
}
|
||||
mux.Handle(hn+"/", rp)
|
||||
addFaviconHandler(hn, mux)
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -464,17 +495,33 @@ 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),
|
||||
ModifyResponse: func(resp *http.Response) error {
|
||||
// Add CORS headers to all proxied responses
|
||||
resp.Header.Set("Access-Control-Allow-Origin", "*")
|
||||
resp.Header.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
resp.Header.Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization, Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version, Sec-WebSocket-Protocol, Sec-WebSocket-Extensions")
|
||||
resp.Header.Set("Access-Control-Max-Age", "86400")
|
||||
return nil
|
||||
},
|
||||
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
|
||||
}
|
||||
@@ -532,8 +579,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) {
|
||||
@@ -548,7 +667,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}
|
||||
}
|
||||
@@ -697,7 +822,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
|
||||
@@ -714,7 +838,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",
|
||||
@@ -731,5 +855,76 @@ 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 embedded favicon.ico
|
||||
faviconData = defaultFavicon
|
||||
faviconContentType = "image/x-icon"
|
||||
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
|
||||
|
||||
// Determine content type based on file extension
|
||||
ext := strings.ToLower(filepath.Ext(faviconPath))
|
||||
switch ext {
|
||||
case ".ico":
|
||||
faviconContentType = "image/x-icon"
|
||||
case ".png":
|
||||
faviconContentType = "image/png"
|
||||
case ".gif":
|
||||
faviconContentType = "image/gif"
|
||||
case ".jpg", ".jpeg":
|
||||
faviconContentType = "image/jpeg"
|
||||
case ".svg":
|
||||
faviconContentType = "image/svg+xml"
|
||||
default:
|
||||
faviconContentType = "image/x-icon" // default fallback
|
||||
}
|
||||
|
||||
log.I.Ln("loaded custom favicon from", faviconPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addFaviconHandler adds a favicon handler for the given hostname that serves
|
||||
// the configured favicon (default favicon.ico or custom file) 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", faviconContentType)
|
||||
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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user