reverted to old leproxy code
This commit is contained in:
83
cmd/hello/main.go
Normal file
83
cmd/hello/main.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
225
config/config.go
225
config/config.go
@@ -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
|
|
||||||
}
|
|
||||||
6
go.mod
6
go.mod
@@ -3,8 +3,10 @@ module github.com/mleku/reverse
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/artyom/autoflags v1.1.1
|
||||||
github.com/mleku/lol v1.0.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 (
|
require (
|
||||||
@@ -12,5 +14,7 @@ require (
|
|||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/sys v0.35.0 // indirect
|
||||||
|
golang.org/x/text v0.28.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
12
go.sum
12
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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=
|
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/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 h1:CMGpeMTh2iE7Xc4/dtJxs0vZqwRmPiGxsqpWxdmOZKc=
|
||||||
github.com/mleku/lol v1.0.1/go.mod h1:daW3rL0XP4ZKscvWn990AJCrJs2Lsu+sdrI9cWbacWE=
|
github.com/mleku/lol v1.0.1/go.mod h1:daW3rL0XP4ZKscvWn990AJCrJs2Lsu+sdrI9cWbacWE=
|
||||||
go-simpler.org/env v0.12.0 h1:kt/lBts0J1kjWJAnB740goNdvwNxt5emhYngL0Fzufs=
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
go-simpler.org/env v0.12.0/go.mod h1:cc/5Md9JCUM7LVLtN0HYjPTDcI3Q8TDaPlNTAlDU+WI=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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=
|
||||||
|
|||||||
475
main.go
475
main.go
@@ -1,21 +1,478 @@
|
|||||||
|
// Command leproxy implements https reverse proxy with automatic Letsencrypt usage for multiple
|
||||||
|
// hostnames/backends
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
log2 "log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/artyom/autoflags"
|
||||||
"github.com/mleku/lol/chk"
|
"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() {
|
func main() {
|
||||||
var err error
|
args := runArgs{
|
||||||
var cfg *config.C
|
Addr: ":https",
|
||||||
if cfg, err = config.New(); chk.T(err) {
|
HTTP: ":http",
|
||||||
if err != nil {
|
Conf: os.Getenv("HOME") + "/.config/reverse/mapping.conf",
|
||||||
fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", err)
|
Cache: os.Getenv("HOME") + "/.cache/reverse",
|
||||||
}
|
RTo: time.Minute,
|
||||||
config.PrintHelp(cfg, os.Stderr)
|
WTo: 5 * time.Minute,
|
||||||
os.Exit(0)
|
}
|
||||||
|
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+<repository-path>".
|
||||||
|
//
|
||||||
|
// - 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(
|
||||||
|
`<html><head><meta name="go-import" content="%s git %s"/><meta http-equiv = "refresh" content = " 3 ; url = %s"/></head><body>redirecting to <a href="%s">%s</a></body></html>`,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user