diff --git a/README.md b/README.md index 35950d4..8e15909 100644 --- a/README.md +++ b/README.md @@ -17,56 +17,57 @@ Just replace `http.ListenAndServe` with `anyhttp.ListenAndServe`. Syntax - unix/ + unix?path=&mode=&remove_existing= Examples - unix/relative/path.sock - unix//var/run/app/absolutepath.sock + unix?path=relative/path.sock + unix?path=/var/run/app/absolutepath.sock + unix?path=/run/app.sock&mode=600&remove_existing=no + +| option | description | default | +|-----------------|------------------------------------------------|----------| +| path | path to unix socket | Required | +| mode | socket file mode | 666 | +| remove_existing | Whether to remove existing socket file or fail | true | ### Systemd Socket activated fd: Syntax - sysd/fdidx/ - sysd/fdname/ + sysd?idx=&name=&check_pid=&unset_env=&idle_timeout= + +Only one of `idx` or `name` has to be set Examples: # First (or only) socket fd passed to app - sysd/fdidx/0 + sysd?idx=0 # Socket with FileDescriptorName - sysd/fdname/myapp + sysd?name=myapp - # Using default name - sysd/fdname/myapp.socket + # Using default name and auto shutdown if no requests received in last 30 minutes + sysd?name=myapp.socket&idle_timeout=30m -### TCP port +| option | description | default | +|--------------|--------------------------------------------------------------------------------------------|------------------| +| name | Name configured via FileDescriptorName or socket file name | Required | +| idx | FD Index. Actual fd num will be 3 + idx | Required | +| idle_timeout | time to wait before shutdown. [syntax][0] | no auto shutdown | +| check_pid | Check process PID matches LISTEN_PID | true | +| unset_env | Unsets the LISTEN\* environment variables, so they don't get passed to any child processes | true | -If the address is a number less than 65536, it is assumed as a port and passed -as `http.ListenAndServe(":",...)` Anything else is directly passed to -`http.ListenAndServe` as well. Below examples should work +### TCP + +If the address is not one of above, it is assumed to be tcp and passed to `http.ListenAndServe`. + +Examples: :http :8888 127.0.0.1:8080 -## Idle server auto shutdown - -When using systemd socket activation, idle servers can be shut down to save on -resources. They will be restarted with socket activation when new request -arrives. Quick example for the case. (Error checking skipped for brevity) - -```go -addrType, httpServer, done, _ := anyhttp.Serve(addr, idle.WrapHandler(nil)) -if addrType == anyhttp.SystemdFD { - idle.Wait(30 * time.Minute) - httpServer.Shutdown(context.TODO()) -} -<-done -``` - ## Documentation https://pkg.go.dev/go.balki.me/anyhttp @@ -75,3 +76,5 @@ https://pkg.go.dev/go.balki.me/anyhttp * https://gist.github.com/teknoraver/5ffacb8757330715bcbcc90e6d46ac74#file-unixhttpd-go * https://github.com/coreos/go-systemd/tree/main/activation + +[0]: https://pkg.go.dev/time#ParseDuration diff --git a/anyhttp.go b/anyhttp.go index 5c06154..789b279 100644 --- a/anyhttp.go +++ b/anyhttp.go @@ -2,16 +2,21 @@ package anyhttp import ( + "context" "errors" "fmt" "io/fs" "net" "net/http" + "net/url" "os" "strconv" "strings" "sync" "syscall" + "time" + + "go.balki.me/anyhttp/idle" ) // AddressType of the address passed @@ -97,6 +102,8 @@ type SysdConfig struct { CheckPID bool // Unsets the LISTEN* environment variables, so they don't get passed to any child processes UnsetEnv bool + // Shutdown http server if no requests received for below timeout + IdleTimeout *time.Duration } // DefaultSysdConfig has the default values for SysdConfig @@ -196,65 +203,57 @@ func (s *SysdConfig) GetListener() (net.Listener, error) { return nil, errors.New("neither FDIndex nor FDName set") } -// GetListener gets a unix or systemd socket listener -func GetListener(addr string) (AddressType, net.Listener, error) { - if strings.HasPrefix(addr, "unix/") { - usc := NewUnixSocketConfig(strings.TrimPrefix(addr, "unix/")) - l, err := usc.GetListener() - return UnixSocket, l, err - } - - if strings.HasPrefix(addr, "sysd/fdidx/") { - idx, err := strconv.Atoi(strings.TrimPrefix(addr, "sysd/fdidx/")) - if err != nil { - return Unknown, nil, fmt.Errorf("invalid fdidx, addr:%q err: %w", addr, err) - } - sysdc := NewSysDConfigWithFDIdx(idx) - l, err := sysdc.GetListener() - return SystemdFD, l, err - } - - if strings.HasPrefix(addr, "sysd/fdname/") { - sysdc := NewSysDConfigWithFDName(strings.TrimPrefix(addr, "sysd/fdname/")) - l, err := sysdc.GetListener() - return SystemdFD, l, err - } - - if port, err := strconv.Atoi(addr); err == nil { - if port > 0 && port < 65536 { - addr = fmt.Sprintf(":%v", port) - } else { - return Unknown, nil, fmt.Errorf("invalid port: %v", port) - } - } - - if addr == "" { - addr = ":http" - } - - l, err := net.Listen("tcp", addr) - return TCP, l, err -} - // Serve creates and serve a http server. -func Serve(addr string, h http.Handler) (AddressType, *http.Server, <-chan error, error) { - addrType, listener, err := GetListener(addr) +func Serve(addr string, h http.Handler) (addrType AddressType, srv *http.Server, idler idle.Idler, done <-chan error, err error) { + addrType, usc, sysc, err := parseAddress(addr) if err != nil { - return addrType, nil, nil, err + return } - srv := &http.Server{Handler: h} - done := make(chan error) - go func() { - done <- srv.Serve(listener) - close(done) + + listener, err := func() (net.Listener, error) { + if usc != nil { + return usc.GetListener() + } else if sysc != nil { + return sysc.GetListener() + } + if addr == "" { + addr = ":http" + } + return net.Listen("tcp", addr) }() - return addrType, srv, done, nil + if err != nil { + return + } + errChan := make(chan error) + done = errChan + if addrType == SystemdFD && sysc.IdleTimeout != nil { + idler = idle.CreateIdler(*sysc.IdleTimeout) + srv = &http.Server{Handler: idle.WrapIdlerHandler(idler, h)} + waitErrChan := make(chan error) + go func() { + waitErrChan <- srv.Serve(listener) + }() + go func() { + select { + case err := <-waitErrChan: + errChan <- err + case <-idler.Chan(): + errChan <- srv.Shutdown(context.TODO()) + } + }() + } else { + srv = &http.Server{Handler: h} + go func() { + errChan <- srv.Serve(listener) + }() + } + return } // ListenAndServe is the drop-in replacement for `http.ListenAndServe`. // Supports unix and systemd sockets in addition func ListenAndServe(addr string, h http.Handler) error { - _, _, done, err := Serve(addr, h) + _, _, _, done, err := Serve(addr, h) if err != nil { return err } @@ -267,3 +266,98 @@ func UnsetSystemdListenVars() { _ = os.Unsetenv("LISTEN_FDS") _ = os.Unsetenv("LISTEN_FDNAMES") } + +func parseAddress(addr string) (addrType AddressType, usc *UnixSocketConfig, sysc *SysdConfig, err error) { + usc = nil + sysc = nil + err = nil + u, err := url.Parse(addr) + if err != nil { + return TCP, nil, nil, nil + } + if u.Path == "unix" { + duc := DefaultUnixSocketConfig + usc = &duc + addrType = UnixSocket + for key, val := range u.Query() { + if len(val) != 1 { + err = fmt.Errorf("unix socket address error. Multiple %v found: %v", key, val) + return + } + if key == "path" { + usc.SocketPath = val[0] + } else if key == "mode" { + if _, serr := fmt.Sscanf(val[0], "%o", &usc.SocketMode); serr != nil { + err = fmt.Errorf("unix socket address error. Bad mode: %v, err: %w", val, serr) + return + } + } else if key == "remove_existing" { + if removeExisting, berr := strconv.ParseBool(val[0]); berr == nil { + usc.RemoveExisting = removeExisting + } else { + err = fmt.Errorf("unix socket address error. Bad remove_existing: %v, err: %w", val, berr) + return + } + } else { + err = fmt.Errorf("unix socket address error. Bad option; key: %v, val: %v", key, val) + return + } + } + if usc.SocketPath == "" { + err = fmt.Errorf("unix socket address error. Missing path; addr: %v", addr) + return + } + } else if u.Path == "sysd" { + dsc := DefaultSysdConfig + sysc = &dsc + addrType = SystemdFD + for key, val := range u.Query() { + if len(val) != 1 { + err = fmt.Errorf("systemd socket fd address error. Multiple %v found: %v", key, val) + return + } + if key == "name" { + sysc.FDName = &val[0] + } else if key == "idx" { + if idx, ierr := strconv.Atoi(val[0]); ierr == nil { + sysc.FDIndex = &idx + } else { + err = fmt.Errorf("systemd socket fd address error. Bad idx: %v, err: %w", val, ierr) + return + } + } else if key == "check_pid" { + if checkPID, berr := strconv.ParseBool(val[0]); berr == nil { + sysc.CheckPID = checkPID + } else { + err = fmt.Errorf("systemd socket fd address error. Bad check_pid: %v, err: %w", val, berr) + return + } + } else if key == "unset_env" { + if unsetEnv, berr := strconv.ParseBool(val[0]); berr == nil { + sysc.UnsetEnv = unsetEnv + } else { + err = fmt.Errorf("systemd socket fd address error. Bad unset_env: %v, err: %w", val, berr) + return + } + } else if key == "idle_timeout" { + if timeout, terr := time.ParseDuration(val[0]); terr == nil { + sysc.IdleTimeout = &timeout + } else { + err = fmt.Errorf("systemd socket fd address error. Bad idle_timeout: %v, err: %w", val, terr) + return + } + } else { + err = fmt.Errorf("systemd socket fd address error. Bad option; key: %v, val: %v", key, val) + return + } + } + if (sysc.FDIndex == nil) == (sysc.FDName == nil) { + err = fmt.Errorf("systemd socket fd address error. Exactly only one of name and idx has to be set. name: %v, idx: %v", sysc.FDName, sysc.FDIndex) + return + } + } else { + // Just assume as TCP address + return TCP, nil, nil, nil + } + return +} diff --git a/anyhttp_test.go b/anyhttp_test.go new file mode 100644 index 0000000..74f58ad --- /dev/null +++ b/anyhttp_test.go @@ -0,0 +1,132 @@ +package anyhttp + +import ( + "encoding/json" + "testing" + "time" +) + +func Test_parseAddress(t *testing.T) { + tests := []struct { + name string // description of this test case + // Named input parameters for target function. + addr string + wantAddrType AddressType + wantUsc *UnixSocketConfig + wantSysc *SysdConfig + wantErr bool + }{ + { + name: "tcp port", + addr: ":8080", + wantAddrType: TCP, + wantUsc: nil, + wantSysc: nil, + wantErr: false, + }, + { + name: "unix address", + addr: "unix?path=/run/foo.sock&mode=660", + wantAddrType: UnixSocket, + wantUsc: &UnixSocketConfig{ + SocketPath: "/run/foo.sock", + SocketMode: 0660, + RemoveExisting: true, + }, + wantSysc: nil, + wantErr: false, + }, + { + name: "systemd address", + addr: "sysd?name=foo.socket", + wantAddrType: SystemdFD, + wantUsc: nil, + wantSysc: &SysdConfig{ + FDIndex: nil, + FDName: ptr("foo.socket"), + CheckPID: true, + UnsetEnv: true, + IdleTimeout: nil, + }, + wantErr: false, + }, + { + name: "systemd address with index", + addr: "sysd?idx=0&idle_timeout=30m", + wantAddrType: SystemdFD, + wantUsc: nil, + wantSysc: &SysdConfig{ + FDIndex: ptr(0), + FDName: nil, + CheckPID: true, + UnsetEnv: true, + IdleTimeout: ptr(30 * time.Minute), + }, + wantErr: false, + }, + { + name: "systemd address. Bad example", + addr: "sysd?idx=0&idle_timeout=30m&name=foo", + wantAddrType: SystemdFD, + wantUsc: nil, + wantSysc: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotAddrType, gotUsc, gotSysc, gotErr := parseAddress(tt.addr) + if gotErr != nil { + if !tt.wantErr { + t.Errorf("parseAddress() failed: %v", gotErr) + } + return + } + if tt.wantErr { + t.Fatal("parseAddress() succeeded unexpectedly") + } + + if gotAddrType != tt.wantAddrType { + t.Errorf("parseAddress() addrType = %v, want %v", gotAddrType, tt.wantAddrType) + } + + if !check(gotUsc, tt.wantUsc) { + t.Errorf("parseAddress() Usc = %v, want %v", gotUsc, tt.wantUsc) + } + if !check(gotSysc, tt.wantSysc) { + if (gotSysc == nil || tt.wantSysc == nil) || + !(check(gotSysc.FDIndex, tt.wantSysc.FDIndex) && + check(gotSysc.FDName, tt.wantSysc.FDName) && + check(gotSysc.IdleTimeout, tt.wantSysc.IdleTimeout)) { + t.Errorf("parseAddress() Sysc = %v, want %v", asJSON(gotSysc), asJSON(tt.wantSysc)) + } + } + }) + } +} + +// Helpers + +// print value instead of pointer +func asJSON[T any](val T) string { + op, err := json.Marshal(val) + if err != nil { + return err.Error() + } + return string(op) +} + +func ptr[T any](val T) *T { + return &val +} + +// nil safe equal check +func check[T comparable](got, want *T) bool { + if (got == nil) != (want == nil) { + return false + } + if got == nil { + return true + } + return *got == *want +}