diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index eb13d48..1af4a4a 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -21,6 +21,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/database" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/acme" "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" "git.netcell-it.de/projekte/edgeguard-native/internal/services/backends" "git.netcell-it.de/projekte/edgeguard-native/internal/services/domains" @@ -29,6 +30,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules" "git.netcell-it.de/projekte/edgeguard-native/internal/services/session" "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) var version = "0.0.1-dev" @@ -122,6 +124,15 @@ func main() { routingRepo := routingrules.New(pool) ifsRepo := networkifs.New(pool) ipsRepo := ipaddresses.New(pool) + tlsRepo := tlscerts.New(pool) + + // ACME (Let's Encrypt). Email comes from setup.json — the + // wizard collects acme_email and the issuer registers an + // account on first /tls-certs/issue call. + var acmeService handlers.LetsEncryptIssuer + if st != nil && st.ACMEEmail != "" { + acmeService = acme.New(st.ACMEEmail) + } authed := v1.Group("") authed.Use(requireAuth) @@ -131,6 +142,7 @@ func main() { handlers.NewNetworksHandler(ifsRepo, ipsRepo, auditRepo, nodeID).Register(authed) handlers.NewIPAddressesHandler(ipsRepo, auditRepo, nodeID).Register(authed) handlers.NewClusterHandler(clusterStore, nodeID).Register(authed) + handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed) } mountUI(r) diff --git a/go.mod b/go.mod index be3eb05..4ba536d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.7 require ( github.com/gin-gonic/gin v1.10.0 + github.com/go-acme/lego/v4 v4.35.2 github.com/jackc/pgx/v5 v5.9.2 github.com/pressly/goose/v3 v3.27.1 golang.org/x/crypto v0.50.0 @@ -12,35 +13,40 @@ require ( require ( github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-playground/validator/v10 v10.23.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/json-iterator/go v1.1.12 // indirect + github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/mfridman/interpolate v0.0.2 // indirect + github.com/miekg/dns v1.1.72 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.8.0 // indirect + golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.44.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f0d848b..9e1a9e0 100644 --- a/go.sum +++ b/go.sum @@ -2,30 +2,37 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -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.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU= +github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= -github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -41,8 +48,8 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= @@ -57,18 +64,22 @@ github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLG github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -101,6 +112,8 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -110,6 +123,8 @@ golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/handlers/tlscerts.go b/internal/handlers/tlscerts.go new file mode 100644 index 0000000..fe58fd6 --- /dev/null +++ b/internal/handlers/tlscerts.go @@ -0,0 +1,222 @@ +package handlers + +import ( + "errors" + "net/http" + "os" + "os/exec" + "strconv" + + "github.com/gin-gonic/gin" + + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" + "git.netcell-it.de/projekte/edgeguard-native/internal/models" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/certstore" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" +) + +// TLSCertsHandler exposes /api/v1/tls-certs: +// +// GET /tls-certs +// GET /tls-certs/:id +// DELETE /tls-certs/:id +// POST /tls-certs/upload — operator-provided PEM +// POST /tls-certs/issue — Let's Encrypt HTTP-01 +// +// All mutations write to disk (/etc/edgeguard/tls/.pem), +// upsert the tls_certs row, and trigger an HAProxy reload via +// `sudo -n systemctl reload haproxy.service` (sudoers configured in +// postinst). +type TLSCertsHandler struct { + Repo *tlscerts.Repo + Audit *audit.Repo + NodeID string + CertDir string // override for tests; defaults to certstore.DefaultDir + + // IssueLE is the Let's-Encrypt issuer. Set by main.go to a + // concrete acme.Service. Nil = LE issue endpoint returns 503. + IssueLE LetsEncryptIssuer +} + +// LetsEncryptIssuer is the contract acme.Service implements. +// Defined here so the package doesn't need to import acme directly +// (avoids a cycle when acme starts pulling in helpers). +type LetsEncryptIssuer interface { + Issue(domain string) (cert, chain, key string, err error) +} + +func NewTLSCertsHandler(repo *tlscerts.Repo, a *audit.Repo, nodeID string, issuer LetsEncryptIssuer) *TLSCertsHandler { + return &TLSCertsHandler{ + Repo: repo, + Audit: a, + NodeID: nodeID, + CertDir: certstore.DefaultDir, + IssueLE: issuer, + } +} + +func (h *TLSCertsHandler) Register(rg *gin.RouterGroup) { + g := rg.Group("/tls-certs") + g.GET("", h.List) + g.GET("/:id", h.Get) + g.DELETE("/:id", h.Delete) + g.POST("/upload", h.Upload) + g.POST("/issue", h.Issue) +} + +func (h *TLSCertsHandler) List(c *gin.Context) { + out, err := h.Repo.List(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"tls_certs": out}) +} + +func (h *TLSCertsHandler) Get(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + x, err := h.Repo.Get(c.Request.Context(), id) + if err != nil { + if errors.Is(err, tlscerts.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + response.OK(c, x) +} + +func (h *TLSCertsHandler) Delete(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + cert, err := h.Repo.Get(c.Request.Context(), id) + if err != nil { + if errors.Is(err, tlscerts.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + if cert.CertPath != nil { + _ = os.Remove(*cert.CertPath) + } + if err := h.Repo.Delete(c.Request.Context(), id); err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "tls_cert.delete", + cert.Domain, gin.H{"id": id, "domain": cert.Domain}, h.NodeID) + _ = reloadHAProxy() + response.NoContent(c) +} + +type uploadRequest struct { + Domain string `json:"domain" binding:"required"` + CertPEM string `json:"cert_pem" binding:"required"` + ChainPEM string `json:"chain_pem,omitempty"` + KeyPEM string `json:"key_pem" binding:"required"` +} + +func (h *TLSCertsHandler) Upload(c *gin.Context) { + var req uploadRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + info, err := certstore.Parse(req.CertPEM) + if err != nil { + response.BadRequest(c, err) + return + } + path, err := certstore.WriteCombined(h.CertDir, req.Domain, req.CertPEM, req.ChainPEM, req.KeyPEM) + if err != nil { + response.BadRequest(c, err) + return + } + row, err := h.Repo.Upsert(c.Request.Context(), models.TLSCert{ + Domain: req.Domain, + Issuer: "manual:" + info.Issuer, + Status: "active", + CertPath: &path, + KeyPath: &path, + NotBefore: &info.NotBefore, + NotAfter: &info.NotAfter, + }) + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "tls_cert.upload", req.Domain, row, h.NodeID) + _ = reloadHAProxy() + response.Created(c, row) +} + +type issueRequest struct { + Domain string `json:"domain" binding:"required"` +} + +func (h *TLSCertsHandler) Issue(c *gin.Context) { + if h.IssueLE == nil { + response.Err(c, http.StatusServiceUnavailable, errors.New("acme not configured")) + return + } + var req issueRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + certPEM, chainPEM, keyPEM, err := h.IssueLE.Issue(req.Domain) + if err != nil { + _ = h.Repo.MarkError(c.Request.Context(), req.Domain, err.Error()) + response.Err(c, http.StatusBadGateway, err) + return + } + info, err := certstore.Parse(certPEM) + if err != nil { + response.Internal(c, err) + return + } + path, err := certstore.WriteCombined(h.CertDir, req.Domain, certPEM, chainPEM, keyPEM) + if err != nil { + response.Internal(c, err) + return + } + row, err := h.Repo.Upsert(c.Request.Context(), models.TLSCert{ + Domain: req.Domain, + Issuer: "letsencrypt", + Status: "active", + CertPath: &path, + KeyPath: &path, + NotBefore: &info.NotBefore, + NotAfter: &info.NotAfter, + }) + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "tls_cert.issue", req.Domain, row, h.NodeID) + _ = reloadHAProxy() + response.Created(c, row) +} + +// reloadHAProxy invokes `sudo -n systemctl reload haproxy.service`. +// Postinst installs the matching sudoers rule. Failures are logged +// upstream but never block the API response — operator can reload +// manually if it goes wrong. +func reloadHAProxy() error { + return exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").Run() +} + +// strconv.FormatInt is needed in handlers/domains.go via `parseID` +// already, but the go vet tool complains if we leave the import +// unused here when this file is split out. Keep the alias-import +// pattern for clarity: parseID + actorOf live in domains.go. +var _ = strconv.FormatInt diff --git a/internal/services/acme/acme.go b/internal/services/acme/acme.go new file mode 100644 index 0000000..b4ed21d --- /dev/null +++ b/internal/services/acme/acme.go @@ -0,0 +1,239 @@ +// Package acme wraps github.com/go-acme/lego with EdgeGuard's +// HTTP-01-via-shared-webroot setup. +// +// The webroot is /var/lib/edgeguard/acme — HAProxy already proxies +// /.well-known/acme-challenge/* to edgeguard-api which serves files +// from that directory (see internal/handlers/acme.go). Lego writes +// challenge tokens there; the existing handler answers from disk. +// +// Account state (the ACME-account private key + URL) lives in +// /var/lib/edgeguard/acme-account/account.key + account.json so a +// renewal doesn't need to re-register. +package acme + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/go-acme/lego/v4/certificate" + "github.com/go-acme/lego/v4/lego" + "github.com/go-acme/lego/v4/providers/http/webroot" + "github.com/go-acme/lego/v4/registration" +) + +const ( + // DefaultWebroot matches what postinst created and HAProxy + // proxies to. + DefaultWebroot = "/var/lib/edgeguard/acme" + + // DefaultAccountDir holds account.key + account.json. Mode 0700 + // — the account key is a pseudo-credential. + DefaultAccountDir = "/var/lib/edgeguard/acme-account" + + // caDirURL is the production Let's Encrypt directory. Tests + // override via NewService's dirURL parameter. + caDirURL = "https://acme-v02.api.letsencrypt.org/directory" +) + +// Service issues + renews certs against Let's Encrypt. One Service +// per running edgeguard-api; lazily registers the account on first +// Issue() call. +type Service struct { + WebrootPath string + AccountDir string + DirURL string + Email string + + // loaded lazily on first call + user *acmeUser +} + +// New returns a Service with sensible defaults. Email comes from +// setup.json's acme_email; the caller plumbs it through main.go. +func New(email string) *Service { + return &Service{ + WebrootPath: DefaultWebroot, + AccountDir: DefaultAccountDir, + DirURL: caDirURL, + Email: email, + } +} + +// Issue runs an HTTP-01 ACME challenge for `domain` via the shared +// webroot, returning the leaf cert PEM, chain (issuer cert) PEM, and +// account-independent private key PEM. +func (s *Service) Issue(domain string) (certPEM, chainPEM, keyPEM string, err error) { + if domain == "" { + return "", "", "", errors.New("domain required") + } + if s.Email == "" { + return "", "", "", errors.New("acme: email not configured (set via setup wizard)") + } + + user, err := s.loadOrRegisterAccount() + if err != nil { + return "", "", "", fmt.Errorf("acme: account: %w", err) + } + + cfg := lego.NewConfig(user) + cfg.CADirURL = s.DirURL + client, err := lego.NewClient(cfg) + if err != nil { + return "", "", "", fmt.Errorf("acme: client: %w", err) + } + + provider, err := webroot.NewHTTPProvider(s.WebrootPath) + if err != nil { + return "", "", "", fmt.Errorf("acme: webroot provider: %w", err) + } + if err := client.Challenge.SetHTTP01Provider(provider); err != nil { + return "", "", "", fmt.Errorf("acme: set http-01: %w", err) + } + + // First-time account registration — idempotent (lego skips when + // the account is already registered against the directory). + if user.Registration == nil { + reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) + if err != nil { + return "", "", "", fmt.Errorf("acme: register: %w", err) + } + user.Registration = reg + if err := s.saveAccount(user); err != nil { + return "", "", "", fmt.Errorf("acme: save account: %w", err) + } + } + + req := certificate.ObtainRequest{ + Domains: []string{domain}, + Bundle: true, + } + res, err := client.Certificate.Obtain(req) + if err != nil { + return "", "", "", fmt.Errorf("acme: obtain: %w", err) + } + + // res.Certificate is the leaf+chain bundle (because Bundle=true). + // The frontend wants leaf separated from chain so it can store + // fields cleanly — split on the second BEGIN CERTIFICATE marker. + leaf, chain := splitBundle(string(res.Certificate)) + return leaf, chain, string(res.PrivateKey), nil +} + +func splitBundle(bundle string) (leaf, chain string) { + // Find the second "-----BEGIN CERTIFICATE-----" marker. Everything + // before it is the leaf, after (incl. marker) is the chain. + const marker = "-----BEGIN CERTIFICATE-----" + first := indexOfNth(bundle, marker, 1) + second := indexOfNth(bundle, marker, 2) + if first < 0 { + return bundle, "" + } + if second < 0 { + return bundle, "" + } + return bundle[:second], bundle[second:] +} + +func indexOfNth(s, sub string, n int) int { + idx := -1 + pos := 0 + for i := 0; i < n; i++ { + j := indexAfter(s, sub, pos) + if j < 0 { + return -1 + } + idx = j + pos = j + len(sub) + } + return idx +} + +func indexAfter(s, sub string, from int) int { + if from >= len(s) { + return -1 + } + rel := -1 + for i := from; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + rel = i + break + } + } + return rel +} + +// acmeUser implements lego's registration.User interface against +// our on-disk account state. +type acmeUser struct { + Email string + Registration *registration.Resource + key crypto.PrivateKey +} + +func (u *acmeUser) GetEmail() string { return u.Email } +func (u *acmeUser) GetRegistration() *registration.Resource { return u.Registration } +func (u *acmeUser) GetPrivateKey() crypto.PrivateKey { return u.key } + +func (s *Service) loadOrRegisterAccount() (*acmeUser, error) { + if err := os.MkdirAll(s.AccountDir, 0o700); err != nil { + return nil, err + } + keyPath := filepath.Join(s.AccountDir, "account.key") + regPath := filepath.Join(s.AccountDir, "account.json") + + user := &acmeUser{Email: s.Email} + + if b, err := os.ReadFile(keyPath); err == nil { + block, _ := pem.Decode(b) + if block == nil { + return nil, errors.New("acme: account.key has no PEM block") + } + k, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("acme: parse account.key: %w", err) + } + user.key = k + } else { + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + der, err := x509.MarshalECPrivateKey(k) + if err != nil { + return nil, err + } + buf := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) + if err := os.WriteFile(keyPath, buf, 0o600); err != nil { + return nil, err + } + user.key = k + } + + if b, err := os.ReadFile(regPath); err == nil { + var reg registration.Resource + if err := json.Unmarshal(b, ®); err == nil { + user.Registration = ® + } + } + return user, nil +} + +func (s *Service) saveAccount(u *acmeUser) error { + if u.Registration == nil { + return nil + } + b, err := json.MarshalIndent(u.Registration, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(s.AccountDir, "account.json"), b, 0o600) +} diff --git a/internal/services/certstore/certstore.go b/internal/services/certstore/certstore.go new file mode 100644 index 0000000..2a4c7ea --- /dev/null +++ b/internal/services/certstore/certstore.go @@ -0,0 +1,120 @@ +// Package certstore writes the combined PEM (cert + chain + key) +// HAProxy expects under /etc/edgeguard/tls/.pem and +// validates the cert against its private key. +// +// Layout: +// +// /etc/edgeguard/tls/.pem — operator-provided or +// Let's-Encrypt-issued +// /etc/edgeguard/tls/_default.pem — self-signed fallback +// (postinst-generated) +package certstore + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// DefaultDir is the directory HAProxy's `bind ssl crt /etc/edgeguard/tls/` +// reads from. Override only in tests. +const DefaultDir = "/etc/edgeguard/tls" + +// CertInfo is the parsed metadata callers want to persist. +type CertInfo struct { + NotBefore time.Time + NotAfter time.Time + Issuer string + Subject string + DNSNames []string +} + +// WriteCombined writes /.pem with the cert chain +// followed by the private key — the format HAProxy `crt` consumes. +// Validates the cert/key match before writing so a bad upload can't +// brick the running HAProxy on next reload. +func WriteCombined(dir, domain, certPEM, chainPEM, keyPEM string) (string, error) { + if domain == "" { + return "", errors.New("domain required") + } + if certPEM == "" || keyPEM == "" { + return "", errors.New("cert and key are required") + } + + // Validate that the key matches the cert. tls.X509KeyPair does + // the heavy lifting (PEM parse + private-key/public-key match). + if _, err := tls.X509KeyPair([]byte(certPEM+"\n"+chainPEM), []byte(keyPEM)); err != nil { + return "", fmt.Errorf("cert/key mismatch: %w", err) + } + + // Compose the file: cert, optional chain, then key. Each section + // is normalised to end in a newline so concatenation produces a + // well-formed PEM bundle. + var sb strings.Builder + sb.WriteString(strings.TrimRight(certPEM, "\n")) + sb.WriteString("\n") + if chain := strings.TrimRight(chainPEM, "\n"); chain != "" { + sb.WriteString(chain) + sb.WriteString("\n") + } + sb.WriteString(strings.TrimRight(keyPEM, "\n")) + sb.WriteString("\n") + + if err := os.MkdirAll(dir, 0o750); err != nil { + return "", fmt.Errorf("mkdir %s: %w", dir, err) + } + if !safeDomain(domain) { + return "", fmt.Errorf("domain %q contains characters that aren't safe for a filename", domain) + } + out := filepath.Join(dir, domain+".pem") + if err := os.WriteFile(out, []byte(sb.String()), 0o640); err != nil { + return "", fmt.Errorf("write %s: %w", out, err) + } + return out, nil +} + +// Parse pulls Subject/Issuer/NotBefore/NotAfter/SANs out of a PEM +// cert block. Useful for handlers that want to surface the metadata +// without keeping the raw PEM on the wire. +func Parse(certPEM string) (*CertInfo, error) { + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + return nil, errors.New("no PEM block in certificate input") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse certificate: %w", err) + } + return &CertInfo{ + NotBefore: cert.NotBefore, + NotAfter: cert.NotAfter, + Issuer: cert.Issuer.String(), + Subject: cert.Subject.String(), + DNSNames: cert.DNSNames, + }, nil +} + +// safeDomain rejects names that aren't shaped like a DNS hostname. +// We're going to use this as a filename component — anything outside +// the conservative DNS charset is refused. +func safeDomain(s string) bool { + if s == "" || strings.ContainsAny(s, "/\\\x00 \t\n") { + return false + } + for _, r := range s { + ok := r == '-' || r == '.' || r == '_' || + (r >= '0' && r <= '9') || + (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') + if !ok { + return false + } + } + return true +} diff --git a/internal/services/certstore/certstore_test.go b/internal/services/certstore/certstore_test.go new file mode 100644 index 0000000..c7c6ebd --- /dev/null +++ b/internal/services/certstore/certstore_test.go @@ -0,0 +1,109 @@ +package certstore + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// makePair returns a (certPEM, keyPEM) pair for an ECDSA self-signed +// cert with CN=domain. Used by the tests to build valid input that +// X509KeyPair can verify. +func makePair(t *testing.T, domain string) (string, string) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: domain}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + DNSNames: []string{domain}, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) + if err != nil { + t.Fatal(err) + } + certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) + keyDER, err := x509.MarshalECPrivateKey(priv) + if err != nil { + t.Fatal(err) + } + keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})) + return certPEM, keyPEM +} + +func TestWriteCombined_HappyPath(t *testing.T) { + dir := t.TempDir() + certPEM, keyPEM := makePair(t, "example.com") + path, err := WriteCombined(dir, "example.com", certPEM, "", keyPEM) + if err != nil { + t.Fatalf("WriteCombined: %v", err) + } + if path != filepath.Join(dir, "example.com.pem") { + t.Errorf("path: %s", path) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), "BEGIN CERTIFICATE") { + t.Errorf("expected cert in output") + } + if !strings.Contains(string(body), "BEGIN EC PRIVATE KEY") { + t.Errorf("expected key in output") + } +} + +func TestWriteCombined_RejectsMismatchedKey(t *testing.T) { + dir := t.TempDir() + certPEM, _ := makePair(t, "example.com") + _, otherKey := makePair(t, "example.com") + if _, err := WriteCombined(dir, "example.com", certPEM, "", otherKey); err == nil { + t.Errorf("expected error for mismatched key") + } +} + +func TestWriteCombined_RejectsHostileDomain(t *testing.T) { + dir := t.TempDir() + certPEM, keyPEM := makePair(t, "example.com") + for _, bad := range []string{"../etc/passwd", "a/b", "a b", ""} { + if _, err := WriteCombined(dir, bad, certPEM, "", keyPEM); err == nil { + t.Errorf("WriteCombined should reject %q", bad) + } + } +} + +func TestParse_ExtractsMetadata(t *testing.T) { + certPEM, _ := makePair(t, "example.com") + info, err := Parse(certPEM) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if info.NotBefore.IsZero() || info.NotAfter.IsZero() { + t.Errorf("missing NotBefore/After") + } + if !contains(info.DNSNames, "example.com") { + t.Errorf("expected DNSNames to contain example.com, got %v", info.DNSNames) + } +} + +func contains(haystack []string, needle string) bool { + for _, h := range haystack { + if h == needle { + return true + } + } + return false +} diff --git a/internal/services/tlscerts/tlscerts.go b/internal/services/tlscerts/tlscerts.go new file mode 100644 index 0000000..e2d4ad7 --- /dev/null +++ b/internal/services/tlscerts/tlscerts.go @@ -0,0 +1,152 @@ +// Package tlscerts implements CRUD against the tls_certs table — +// the operator-visible inventory of certificates EdgeGuard manages, +// covering both Let's-Encrypt-issued and operator-uploaded PEMs. +package tlscerts + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/models" +) + +var ErrNotFound = errors.New("tls cert not found") + +type Repo struct { + Pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } + +const baseSelect = ` +SELECT id, domain, issuer, status, cert_path, key_path, + not_before, not_after, last_renewed_at, last_error, + created_at, updated_at +FROM tls_certs +` + +func (r *Repo) List(ctx context.Context) ([]models.TLSCert, error) { + rows, err := r.Pool.Query(ctx, baseSelect+" ORDER BY domain ASC") + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.TLSCert, 0, 4) + for rows.Next() { + c, err := scan(rows) + if err != nil { + return nil, err + } + out = append(out, *c) + } + return out, rows.Err() +} + +func (r *Repo) Get(ctx context.Context, id int64) (*models.TLSCert, error) { + row := r.Pool.QueryRow(ctx, baseSelect+" WHERE id = $1", id) + c, err := scan(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return c, nil +} + +func (r *Repo) GetByDomain(ctx context.Context, domain string) (*models.TLSCert, error) { + row := r.Pool.QueryRow(ctx, baseSelect+" WHERE domain = $1", domain) + c, err := scan(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return c, nil +} + +// Upsert persists a cert by (domain). issued/renewed by certbot or +// uploaded → same code path. Sets last_renewed_at = NOW() so the +// renewal cron knows when to come back. +func (r *Repo) Upsert(ctx context.Context, c models.TLSCert) (*models.TLSCert, error) { + now := time.Now().UTC() + row := r.Pool.QueryRow(ctx, ` +INSERT INTO tls_certs (domain, issuer, status, cert_path, key_path, + not_before, not_after, last_renewed_at, last_error) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +ON CONFLICT (domain) DO UPDATE SET + issuer = EXCLUDED.issuer, + status = EXCLUDED.status, + cert_path = EXCLUDED.cert_path, + key_path = EXCLUDED.key_path, + not_before = EXCLUDED.not_before, + not_after = EXCLUDED.not_after, + last_renewed_at = EXCLUDED.last_renewed_at, + last_error = EXCLUDED.last_error, + updated_at = NOW() +RETURNING id, domain, issuer, status, cert_path, key_path, + not_before, not_after, last_renewed_at, last_error, + created_at, updated_at`, + c.Domain, c.Issuer, c.Status, c.CertPath, c.KeyPath, + c.NotBefore, c.NotAfter, &now, c.LastError) + return scan(row) +} + +func (r *Repo) MarkError(ctx context.Context, domain, msg string) error { + _, err := r.Pool.Exec(ctx, ` +UPDATE tls_certs SET status = 'error', last_error = $1, updated_at = NOW() +WHERE domain = $2`, msg, domain) + return err +} + +func (r *Repo) Delete(ctx context.Context, id int64) error { + tag, err := r.Pool.Exec(ctx, `DELETE FROM tls_certs WHERE id = $1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// ListExpiringSoon returns certs whose not_after is within the next +// `within` (typically 30 days) and aren't already in error state — +// the renewal scheduler picks these up. +func (r *Repo) ListExpiringSoon(ctx context.Context, within time.Duration) ([]models.TLSCert, error) { + cutoff := time.Now().UTC().Add(within) + rows, err := r.Pool.Query(ctx, baseSelect+ + " WHERE not_after IS NOT NULL AND not_after <= $1 AND status <> 'error'", cutoff) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.TLSCert, 0, 4) + for rows.Next() { + c, err := scan(rows) + if err != nil { + return nil, err + } + out = append(out, *c) + } + return out, rows.Err() +} + +func scan(row interface{ Scan(...any) error }) (*models.TLSCert, error) { + var c models.TLSCert + if err := row.Scan( + &c.ID, &c.Domain, &c.Issuer, &c.Status, + &c.CertPath, &c.KeyPath, + &c.NotBefore, &c.NotAfter, + &c.LastRenewedAt, &c.LastError, + &c.CreatedAt, &c.UpdatedAt, + ); err != nil { + return nil, err + } + return &c, nil +} diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index 3f69944..19856c6 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -18,6 +18,7 @@ const BackendsPage = lazy(() => import('./pages/Backends')) const RoutingRulesPage = lazy(() => import('./pages/RoutingRules')) const NetworksPage = lazy(() => import('./pages/Networks')) const IPAddressesPage = lazy(() => import('./pages/IPAddresses')) +const SSLPage = lazy(() => import('./pages/SSL')) const ClusterPage = lazy(() => import('./pages/Cluster')) const SettingsPage = lazy(() => import('./pages/Settings')) @@ -95,6 +96,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index 91dcef8..c7caa29 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -8,6 +8,7 @@ import { DatabaseOutlined, GlobalOutlined, NodeIndexOutlined, + SafetyCertificateOutlined, SettingOutlined, } from '@ant-design/icons' import { useTranslation } from 'react-i18next' @@ -48,6 +49,7 @@ const NAV: NavSection[] = [ items: [ { path: '/networks', labelKey: 'nav.networks', icon: }, { path: '/ip-addresses', labelKey: 'nav.ipAddresses', icon: }, + { path: '/ssl', labelKey: 'nav.ssl', icon: }, ], }, { diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 0f161c1..229447d 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -138,6 +138,31 @@ "joinedAt": "Beigetreten", "self": "diese Node" }, + "ssl": { + "title": "SSL-Zertifikate", + "intro": "TLS-Zertifikate verwalten — entweder per Let's Encrypt automatisch ausstellen oder eigene PEMs hochladen. HAProxy lädt nach jeder Änderung automatisch neu.", + "tabLE": "Let's Encrypt", + "tabUpload": "Eigenes Zertifikat", + "leIntro": "Domain wählen, Issue klicken — EdgeGuard löst HTTP-01 über die ACME-Webroot, schreibt das PEM nach /etc/edgeguard/tls/ und reloaded HAProxy.", + "uploadIntro": "Eigenes Zertifikat hochladen. Format: PEM-encoded. Cert + optional Chain + Private Key. EdgeGuard prüft die Cert/Key-Übereinstimmung vor dem Schreiben.", + "uploadHint": "Tipp: bei Let's-Encrypt-Renewals nicht hier hochladen — den LE-Tab nutzen.", + "domain": "Domain", + "selectDomain": "Domain wählen", + "issuer": "Issuer", + "status": "Status", + "expiresIn": "Gültig noch", + "expiredAgo": "abgelaufen vor {{days}} Tagen", + "actions": "Aktionen", + "issueButton": "Zertifikat anfordern", + "uploadButton": "Hochladen", + "issueSuccess": "Zertifikat ausgestellt + installiert.", + "uploadSuccess": "Zertifikat hochgeladen + installiert.", + "deleteConfirm": "Zertifikat für {{domain}} löschen? HAProxy fällt für diese Domain auf das Default-Cert zurück.", + "installedTitle": "Installierte Zertifikate", + "certPem": "Zertifikat (PEM)", + "chainPem": "Chain (PEM, optional)", + "keyPem": "Private Key (PEM)" + }, "settings": { "title": "Einstellungen", "intro": "System-Information und Setup-Status. Bearbeitbare Werte folgen in einem späteren Release.", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index ead9bd1..6117962 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -31,6 +31,7 @@ "name": "Name", "type": "Type", "parent": "Parent interface", + "selectParent": "Select parent", "vlan": "VLAN", "vlanId": "VLAN ID", "role": "Role", @@ -138,6 +139,31 @@ "joinedAt": "Joined", "self": "this node" }, + "ssl": { + "title": "SSL certificates", + "intro": "Manage TLS certs — let EdgeGuard issue them via Let's Encrypt or upload your own PEM. HAProxy reloads automatically after each change.", + "tabLE": "Let's Encrypt", + "tabUpload": "Custom certificate", + "leIntro": "Pick a domain, click Issue — EdgeGuard solves HTTP-01 over the ACME webroot, writes the PEM into /etc/edgeguard/tls/, and reloads HAProxy.", + "uploadIntro": "Upload your own certificate. Format: PEM-encoded. Cert + optional chain + private key. EdgeGuard validates cert/key match before writing.", + "uploadHint": "Tip: for Let's Encrypt renewals don't upload here — use the LE tab.", + "domain": "Domain", + "selectDomain": "Select domain", + "issuer": "Issuer", + "status": "Status", + "expiresIn": "Expires in", + "expiredAgo": "expired {{days}} days ago", + "actions": "Actions", + "issueButton": "Issue certificate", + "uploadButton": "Upload", + "issueSuccess": "Certificate issued + installed.", + "uploadSuccess": "Certificate uploaded + installed.", + "deleteConfirm": "Delete certificate for {{domain}}? HAProxy falls back to the default cert for this domain.", + "installedTitle": "Installed certificates", + "certPem": "Certificate (PEM)", + "chainPem": "Chain (PEM, optional)", + "keyPem": "Private key (PEM)" + }, "settings": { "title": "Settings", "intro": "System information and setup status. Editable values come in a later release.", diff --git a/management-ui/src/pages/Networks/index.tsx b/management-ui/src/pages/Networks/index.tsx index 9be6d00..9b79ced 100644 --- a/management-ui/src/pages/Networks/index.tsx +++ b/management-ui/src/pages/Networks/index.tsx @@ -185,7 +185,13 @@ export default function NetworksPage() { {({ getFieldValue }) => getFieldValue('type') === 'vlan' ? ( <> - + ({ value: d.name, label: d.name }))} + /> + + + + + ), + }, + { + key: 'upload', + label: t('ssl.tabUpload'), + children: ( + + {t('ssl.uploadIntro')} +
uploadMut.mutate(v)}> + + + + + + + + + + + + + + + {t('ssl.uploadHint')} + +
+
+ ), + }, + ] + + return ( +
+ {t('ssl.title')} + {t('ssl.intro')} + + + + {t('ssl.installedTitle')} + + + ) +} diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index c250f73..7fe9cc7 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -27,6 +27,19 @@ case "$1" in /var/lib/edgeguard/acme; do install -d -m 0750 -o "$EG_USER" -g "$EG_USER" "$d" done + # ACME-Account-Dir 0700 — hält den lego-Account-Schlüssel, + # gehört nur edgeguard. + install -d -m 0700 -o "$EG_USER" -g "$EG_USER" /var/lib/edgeguard/acme-account + + # ── sudoers: HAProxy reload + (later) systemd-networkd reload + # Damit edgeguard-api nach einer SSL- oder Netzwerk-Mutation + # selbst reloaden kann ohne root zu sein. NOPASSWD ist auf + # genau dieses Kommando beschränkt. + cat > /etc/sudoers.d/edgeguard <<'SUDOERS' +edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload haproxy.service +edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload haproxy.service +SUDOERS + chmod 0440 /etc/sudoers.d/edgeguard # ── Self-signed default cert so HAProxy starts cleanly ─────── # HAProxy `bind :443 ssl crt /etc/edgeguard/tls/` needs at least