mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-25 10:09:03 +01:00
Merge branch 'main' of https://github.com/pocket-id/pocket-id into actors/config
This commit is contained in:
@@ -2,6 +2,7 @@ package cmds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
@@ -64,13 +65,27 @@ func init() {
|
||||
},
|
||||
}
|
||||
|
||||
healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", "http://localhost:"+common.EnvConfig.Port, "Endpoint for Pocket ID")
|
||||
healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", defaultEndpoint(), "Endpoint for Pocket ID")
|
||||
healthcheckCmd.Flags().StringVar(&flags.UnixSocket, "unix-socket", "", "UNIX socket path for Pocket ID")
|
||||
healthcheckCmd.Flags().BoolVarP(&flags.Verbose, "verbose", "v", false, "Enable verbose mode")
|
||||
|
||||
rootCmd.AddCommand(healthcheckCmd)
|
||||
}
|
||||
|
||||
// The server only serves TLS when both a certificate and a key file are configured
|
||||
func tlsEnabled() bool {
|
||||
return common.EnvConfig.TLSCertFile != "" && common.EnvConfig.TLSKeyFile != ""
|
||||
}
|
||||
|
||||
func defaultEndpoint() string {
|
||||
scheme := "http"
|
||||
if tlsEnabled() {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
return scheme + "://localhost:" + common.EnvConfig.Port
|
||||
}
|
||||
|
||||
func healthcheck(ctx context.Context, flags healthcheckFlags) (*healthcheckResult, error) {
|
||||
url := strings.TrimRight(flags.Endpoint, "/") + "/healthz"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
@@ -78,17 +93,25 @@ func healthcheck(ctx context.Context, flags healthcheckFlags) (*healthcheckResul
|
||||
return nil, fmt.Errorf("failed to create request object for %q: %w", url, err)
|
||||
}
|
||||
|
||||
client := http.DefaultClient
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
if flags.UnixSocket != "" {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
dialer := net.Dialer{}
|
||||
return dialer.DialContext(ctx, "unix", flags.UnixSocket)
|
||||
}
|
||||
client = &http.Client{Transport: transport}
|
||||
}
|
||||
|
||||
// If TLS cert and key files are provided, there is a high chance that the server is using a self-signed certificate
|
||||
if tlsEnabled() {
|
||||
transport.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: true, //nolint:gosec // no sensitive data is transmitted in healthcheck
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to perform request to %q: %w", url, err)
|
||||
|
||||
@@ -2,15 +2,24 @@ package cmds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
func TestHealthcheckTCPSuccess(t *testing.T) {
|
||||
@@ -77,3 +86,123 @@ func TestHealthcheckUnixSocket(t *testing.T) {
|
||||
require.Equal(t, http.StatusNoContent, result.StatusCode)
|
||||
require.Equal(t, "http://localhost:1411/healthz", result.URL)
|
||||
}
|
||||
|
||||
// The server also serves TLS over a UNIX socket, so the healthcheck must keep dialing
|
||||
// the socket instead of falling back to the host in the endpoint
|
||||
func TestHealthcheckUnixSocketWithTLS(t *testing.T) {
|
||||
setTLSFiles(t)
|
||||
|
||||
socketPath := filepath.Join(shortTempDir(t), "s.sock")
|
||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "unix", socketPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
server := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/healthz", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}),
|
||||
ReadHeaderTimeout: time.Second,
|
||||
TLSConfig: newSelfSignedTLSConfig(t),
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- server.ServeTLS(listener, "", "")
|
||||
}()
|
||||
|
||||
// The connection is upgraded to HTTP/2, which keeps a graceful shutdown waiting for the idle client connection
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, server.Close())
|
||||
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
|
||||
})
|
||||
|
||||
result, err := healthcheck(t.Context(), healthcheckFlags{
|
||||
Endpoint: "https://localhost:1411",
|
||||
UnixSocket: socketPath,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusNoContent, result.StatusCode)
|
||||
}
|
||||
|
||||
// The healthcheck must reach a server that serves TLS with a self-signed certificate
|
||||
func TestHealthcheckTLSSelfSignedCertificate(t *testing.T) {
|
||||
setTLSFiles(t)
|
||||
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/healthz", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := healthcheck(t.Context(), healthcheckFlags{
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusNoContent, result.StatusCode)
|
||||
}
|
||||
|
||||
func TestDefaultEndpoint(t *testing.T) {
|
||||
t.Run("uses http if no TLS certificate is configured", func(t *testing.T) {
|
||||
assert.Equal(t, "http://localhost:"+common.EnvConfig.Port, defaultEndpoint())
|
||||
})
|
||||
|
||||
t.Run("uses https if a TLS certificate is configured", func(t *testing.T) {
|
||||
setTLSFiles(t)
|
||||
assert.Equal(t, "https://localhost:"+common.EnvConfig.Port, defaultEndpoint())
|
||||
})
|
||||
}
|
||||
|
||||
// t.TempDir embeds the test name, which can exceed the maximum socket path length
|
||||
func shortTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir, err := os.MkdirTemp("", "pid")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll(dir)
|
||||
})
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
// Only the presence of the paths matters, the healthcheck never reads the files
|
||||
func setTLSFiles(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
certFile, keyFile := common.EnvConfig.TLSCertFile, common.EnvConfig.TLSKeyFile
|
||||
t.Cleanup(func() {
|
||||
common.EnvConfig.TLSCertFile, common.EnvConfig.TLSKeyFile = certFile, keyFile
|
||||
})
|
||||
|
||||
common.EnvConfig.TLSCertFile = "cert.pem"
|
||||
common.EnvConfig.TLSKeyFile = "key.pem"
|
||||
}
|
||||
|
||||
func newSelfSignedTLSConfig(t *testing.T) *tls.Config {
|
||||
t.Helper()
|
||||
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now()
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
DNSNames: []string{"localhost"},
|
||||
NotBefore: now.Add(-time.Minute),
|
||||
NotAfter: now.Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
certificateDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{{
|
||||
Certificate: [][]byte{certificateDER},
|
||||
PrivateKey: privateKey,
|
||||
}},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user