feat(ssl): TLS-Cert-Verwaltung in der GUI — Let's Encrypt + eigenes PEM
Backend: * internal/services/tlscerts/ — Repo (List/Get/Upsert/Delete/ GetByDomain/ListExpiringSoon/MarkError) gegen tls_certs-Tabelle. * internal/services/certstore/ — WriteCombined verifiziert cert/key match via tls.X509KeyPair, schreibt /etc/edgeguard/tls/<domain>.pem (HAProxy-format: cert + chain + key konkatenert). Parse extrahiert NotBefore/After/Issuer/SANs aus dem PEM. Domain-Charset-Whitelist gegen Path-Traversal beim Filename. 4 Tests (happy path, mismatched key, hostile filename, parse). * internal/services/acme/ — go-acme/lego v4 mit HTTP-01 über die bestehende /var/lib/edgeguard/acme-Webroot (HAProxy proxied dort schon hin). Account-Key persistent in /var/lib/edgeguard/acme- account/account.key, Registrierung lazy beim ersten Issue(). * internal/handlers/tlscerts.go — REST CRUD + /upload (custom PEM) + /issue (LE HTTP-01) auf /api/v1/tls-certs. Reload HAProxy via sudo nach jeder Mutation. Audit-Log pro Aktion. Frontend: * management-ui/src/pages/SSL/ — Tabs (Let's Encrypt / Eigenes Zertifikat) plus Tabelle aller installierten Zerts mit expires-in-Anzeige (orange ab <30 Tage, rot wenn abgelaufen) und Status-Tags. Sidebar-Eintrag, i18n de/en. * Networks-Form: Parent-Interface ist jetzt ein Select aus den System-Discovered-Interfaces statt freier Input — User-Wunsch. Packaging: * postinst legt /var/lib/edgeguard/acme-account/ 0700 an. * postinst installt /etc/sudoers.d/edgeguard mit NOPASSWD-Rule für systemctl reload haproxy.service — damit der edgeguard-User reloaden kann ohne root. Live deployed auf 89.163.205.6. /api/v1/tls-certs antwortet jetzt 401 ohne Cookie (Route registriert), POST /tls-certs/upload + /issue sind bereit. ACME-Issue gegen externe FQDN (utm-1.netcell-it.de) braucht nur noch die Domain, die im wizard schon angelegt ist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
"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"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
"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/audit"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
"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/routingrules"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
"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/setup"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "0.0.1-dev"
|
var version = "0.0.1-dev"
|
||||||
@@ -122,6 +124,15 @@ func main() {
|
|||||||
routingRepo := routingrules.New(pool)
|
routingRepo := routingrules.New(pool)
|
||||||
ifsRepo := networkifs.New(pool)
|
ifsRepo := networkifs.New(pool)
|
||||||
ipsRepo := ipaddresses.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 := v1.Group("")
|
||||||
authed.Use(requireAuth)
|
authed.Use(requireAuth)
|
||||||
@@ -131,6 +142,7 @@ func main() {
|
|||||||
handlers.NewNetworksHandler(ifsRepo, ipsRepo, auditRepo, nodeID).Register(authed)
|
handlers.NewNetworksHandler(ifsRepo, ipsRepo, auditRepo, nodeID).Register(authed)
|
||||||
handlers.NewIPAddressesHandler(ipsRepo, auditRepo, nodeID).Register(authed)
|
handlers.NewIPAddressesHandler(ipsRepo, auditRepo, nodeID).Register(authed)
|
||||||
handlers.NewClusterHandler(clusterStore, nodeID).Register(authed)
|
handlers.NewClusterHandler(clusterStore, nodeID).Register(authed)
|
||||||
|
handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed)
|
||||||
}
|
}
|
||||||
|
|
||||||
mountUI(r)
|
mountUI(r)
|
||||||
|
|||||||
14
go.mod
14
go.mod
@@ -4,6 +4,7 @@ go 1.25.7
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.10.0
|
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/jackc/pgx/v5 v5.9.2
|
||||||
github.com/pressly/goose/v3 v3.27.1
|
github.com/pressly/goose/v3 v3.27.1
|
||||||
golang.org/x/crypto v0.50.0
|
golang.org/x/crypto v0.50.0
|
||||||
@@ -12,35 +13,40 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // 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/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // 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/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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // 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/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||||
github.com/mfridman/interpolate v0.0.2 // 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/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/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/arch v0.8.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/net v0.53.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
golang.org/x/text v0.36.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
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
33
go.sum
33
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 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 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
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 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
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 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
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/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.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.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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
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 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
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 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
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 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
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.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
|
||||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
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/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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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.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 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
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/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 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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.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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
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/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.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 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
||||||
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
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/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 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
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 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
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/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 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
222
internal/handlers/tlscerts.go
Normal file
222
internal/handlers/tlscerts.go
Normal file
@@ -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/<domain>.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
|
||||||
239
internal/services/acme/acme.go
Normal file
239
internal/services/acme/acme.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
120
internal/services/certstore/certstore.go
Normal file
120
internal/services/certstore/certstore.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// Package certstore writes the combined PEM (cert + chain + key)
|
||||||
|
// HAProxy expects under /etc/edgeguard/tls/<domain>.pem and
|
||||||
|
// validates the cert against its private key.
|
||||||
|
//
|
||||||
|
// Layout:
|
||||||
|
//
|
||||||
|
// /etc/edgeguard/tls/<domain>.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 <dir>/<domain>.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
|
||||||
|
}
|
||||||
109
internal/services/certstore/certstore_test.go
Normal file
109
internal/services/certstore/certstore_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
152
internal/services/tlscerts/tlscerts.go
Normal file
152
internal/services/tlscerts/tlscerts.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ const BackendsPage = lazy(() => import('./pages/Backends'))
|
|||||||
const RoutingRulesPage = lazy(() => import('./pages/RoutingRules'))
|
const RoutingRulesPage = lazy(() => import('./pages/RoutingRules'))
|
||||||
const NetworksPage = lazy(() => import('./pages/Networks'))
|
const NetworksPage = lazy(() => import('./pages/Networks'))
|
||||||
const IPAddressesPage = lazy(() => import('./pages/IPAddresses'))
|
const IPAddressesPage = lazy(() => import('./pages/IPAddresses'))
|
||||||
|
const SSLPage = lazy(() => import('./pages/SSL'))
|
||||||
const ClusterPage = lazy(() => import('./pages/Cluster'))
|
const ClusterPage = lazy(() => import('./pages/Cluster'))
|
||||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||||
|
|
||||||
@@ -95,6 +96,7 @@ export default function App() {
|
|||||||
<Route path="/routing-rules" element={<RoutingRulesPage />} />
|
<Route path="/routing-rules" element={<RoutingRulesPage />} />
|
||||||
<Route path="/networks" element={<NetworksPage />} />
|
<Route path="/networks" element={<NetworksPage />} />
|
||||||
<Route path="/ip-addresses" element={<IPAddressesPage />} />
|
<Route path="/ip-addresses" element={<IPAddressesPage />} />
|
||||||
|
<Route path="/ssl" element={<SSLPage />} />
|
||||||
<Route path="/cluster" element={<ClusterPage />} />
|
<Route path="/cluster" element={<ClusterPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
NodeIndexOutlined,
|
NodeIndexOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -48,6 +49,7 @@ const NAV: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ path: '/networks', labelKey: 'nav.networks', icon: <ClusterOutlined /> },
|
{ path: '/networks', labelKey: 'nav.networks', icon: <ClusterOutlined /> },
|
||||||
{ path: '/ip-addresses', labelKey: 'nav.ipAddresses', icon: <NodeIndexOutlined /> },
|
{ path: '/ip-addresses', labelKey: 'nav.ipAddresses', icon: <NodeIndexOutlined /> },
|
||||||
|
{ path: '/ssl', labelKey: 'nav.ssl', icon: <SafetyCertificateOutlined /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -138,6 +138,31 @@
|
|||||||
"joinedAt": "Beigetreten",
|
"joinedAt": "Beigetreten",
|
||||||
"self": "diese Node"
|
"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": {
|
"settings": {
|
||||||
"title": "Einstellungen",
|
"title": "Einstellungen",
|
||||||
"intro": "System-Information und Setup-Status. Bearbeitbare Werte folgen in einem späteren Release.",
|
"intro": "System-Information und Setup-Status. Bearbeitbare Werte folgen in einem späteren Release.",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"name": "Name",
|
"name": "Name",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"parent": "Parent interface",
|
"parent": "Parent interface",
|
||||||
|
"selectParent": "Select parent",
|
||||||
"vlan": "VLAN",
|
"vlan": "VLAN",
|
||||||
"vlanId": "VLAN ID",
|
"vlanId": "VLAN ID",
|
||||||
"role": "Role",
|
"role": "Role",
|
||||||
@@ -138,6 +139,31 @@
|
|||||||
"joinedAt": "Joined",
|
"joinedAt": "Joined",
|
||||||
"self": "this node"
|
"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": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"intro": "System information and setup status. Editable values come in a later release.",
|
"intro": "System information and setup status. Editable values come in a later release.",
|
||||||
|
|||||||
@@ -185,7 +185,13 @@ export default function NetworksPage() {
|
|||||||
{({ getFieldValue }) => getFieldValue('type') === 'vlan' ? (
|
{({ getFieldValue }) => getFieldValue('type') === 'vlan' ? (
|
||||||
<>
|
<>
|
||||||
<Form.Item label={t('networks.parent')} name="parent" rules={[{ required: true }]}>
|
<Form.Item label={t('networks.parent')} name="parent" rules={[{ required: true }]}>
|
||||||
<Input placeholder="eth0" />
|
<Select
|
||||||
|
placeholder={t('networks.selectParent')}
|
||||||
|
showSearch
|
||||||
|
options={(sys ?? [])
|
||||||
|
.filter((i) => i.ifname !== 'lo')
|
||||||
|
.map((i) => ({ value: i.ifname, label: i.ifname }))}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t('networks.vlanId')} name="vlan_id" rules={[{ required: true }]}>
|
<Form.Item label={t('networks.vlanId')} name="vlan_id" rules={[{ required: true }]}>
|
||||||
<InputNumber min={1} max={4094} style={{ width: '100%' }} />
|
<InputNumber min={1} max={4094} style={{ width: '100%' }} />
|
||||||
|
|||||||
198
management-ui/src/pages/SSL/index.tsx
Normal file
198
management-ui/src/pages/SSL/index.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Alert, Button, Card, Form, Input, Popconfirm, Select, Space, Table, Tabs, Tag, Typography, message } from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import apiClient, { isEnvelope } from '../../api/client'
|
||||||
|
|
||||||
|
interface TLSCert {
|
||||||
|
id: number
|
||||||
|
domain: string
|
||||||
|
issuer: string
|
||||||
|
status: 'pending' | 'active' | 'renewing' | 'expired' | 'error'
|
||||||
|
cert_path?: string | null
|
||||||
|
key_path?: string | null
|
||||||
|
not_before?: string | null
|
||||||
|
not_after?: string | null
|
||||||
|
last_renewed_at?: string | null
|
||||||
|
last_error?: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadValues {
|
||||||
|
domain: string
|
||||||
|
cert_pem: string
|
||||||
|
chain_pem?: string
|
||||||
|
key_pem: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IssueValues { domain: string }
|
||||||
|
|
||||||
|
interface Domain { id: number; name: string }
|
||||||
|
|
||||||
|
async function listCerts(): Promise<TLSCert[]> {
|
||||||
|
const r = await apiClient.get('/tls-certs')
|
||||||
|
if (!isEnvelope(r.data)) return []
|
||||||
|
return (r.data.data as { tls_certs?: TLSCert[] }).tls_certs ?? []
|
||||||
|
}
|
||||||
|
async function listDomains(): Promise<Domain[]> {
|
||||||
|
const r = await apiClient.get('/domains')
|
||||||
|
if (!isEnvelope(r.data)) return []
|
||||||
|
return (r.data.data as { domains?: Domain[] }).domains ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<TLSCert['status'], string> = {
|
||||||
|
active: 'green',
|
||||||
|
renewing: 'blue',
|
||||||
|
pending: 'gold',
|
||||||
|
expired: 'red',
|
||||||
|
error: 'red',
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysUntil(s?: string | null): number | null {
|
||||||
|
if (!s) return null
|
||||||
|
const t = new Date(s).getTime()
|
||||||
|
if (Number.isNaN(t)) return null
|
||||||
|
return Math.round((t - Date.now()) / 86_400_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SSLPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const { data: certs, isLoading } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
|
||||||
|
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
|
||||||
|
|
||||||
|
const [issueForm] = Form.useForm<IssueValues>()
|
||||||
|
const [uploadForm] = Form.useForm<UploadValues>()
|
||||||
|
const [issueErr, setIssueErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const issueMut = useMutation({
|
||||||
|
mutationFn: async (v: IssueValues) => {
|
||||||
|
const r = await apiClient.post('/tls-certs/issue', v)
|
||||||
|
return r.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success(t('ssl.issueSuccess'))
|
||||||
|
issueForm.resetFields()
|
||||||
|
setIssueErr(null)
|
||||||
|
void qc.invalidateQueries({ queryKey: ['tls-certs'] })
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setIssueErr(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploadMut = useMutation({
|
||||||
|
mutationFn: async (v: UploadValues) => {
|
||||||
|
const r = await apiClient.post('/tls-certs/upload', v)
|
||||||
|
return r.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success(t('ssl.uploadSuccess'))
|
||||||
|
uploadForm.resetFields()
|
||||||
|
void qc.invalidateQueries({ queryKey: ['tls-certs'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const delMut = useMutation({
|
||||||
|
mutationFn: async (id: number) => { await apiClient.delete(`/tls-certs/${id}`) },
|
||||||
|
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['tls-certs'] }) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ColumnsType<TLSCert> = [
|
||||||
|
{ title: t('ssl.domain'), dataIndex: 'domain', key: 'domain', render: (s: string) => <code>{s}</code> },
|
||||||
|
{ title: t('ssl.issuer'), dataIndex: 'issuer', key: 'issuer' },
|
||||||
|
{
|
||||||
|
title: t('ssl.status'), dataIndex: 'status', key: 'status',
|
||||||
|
render: (s: TLSCert['status']) => <Tag color={STATUS_COLORS[s] ?? 'default'}>{s}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('ssl.expiresIn'), key: 'expires',
|
||||||
|
render: (_, row) => {
|
||||||
|
const d = daysUntil(row.not_after)
|
||||||
|
if (d == null) return '—'
|
||||||
|
if (d < 0) return <Tag color="red">{t('ssl.expiredAgo', { days: -d })}</Tag>
|
||||||
|
if (d < 30) return <Tag color="orange">{d}d</Tag>
|
||||||
|
return `${d}d`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('ssl.actions'), key: 'actions',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Popconfirm
|
||||||
|
title={t('ssl.deleteConfirm', { domain: row.domain })}
|
||||||
|
onConfirm={() => delMut.mutate(row.id)}
|
||||||
|
>
|
||||||
|
<Button size="small" danger>{t('common.delete')}</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
key: 'letsencrypt',
|
||||||
|
label: t('ssl.tabLE'),
|
||||||
|
children: (
|
||||||
|
<Card size="small">
|
||||||
|
<Typography.Paragraph type="secondary">{t('ssl.leIntro')}</Typography.Paragraph>
|
||||||
|
{issueErr && <Alert type="error" closable showIcon style={{ marginBottom: 12 }} message={issueErr} onClose={() => setIssueErr(null)} />}
|
||||||
|
<Form form={issueForm} layout="vertical" onFinish={(v) => issueMut.mutate(v)}>
|
||||||
|
<Form.Item label={t('ssl.domain')} name="domain" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
placeholder={t('ssl.selectDomain')}
|
||||||
|
options={(domains ?? []).map((d) => ({ value: d.name, label: d.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={issueMut.isPending}>
|
||||||
|
{t('ssl.issueButton')}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'upload',
|
||||||
|
label: t('ssl.tabUpload'),
|
||||||
|
children: (
|
||||||
|
<Card size="small">
|
||||||
|
<Typography.Paragraph type="secondary">{t('ssl.uploadIntro')}</Typography.Paragraph>
|
||||||
|
<Form form={uploadForm} layout="vertical" onFinish={(v) => uploadMut.mutate(v)}>
|
||||||
|
<Form.Item label={t('ssl.domain')} name="domain" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="example.com" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('ssl.certPem')} name="cert_pem" rules={[{ required: true }]}>
|
||||||
|
<Input.TextArea rows={6} placeholder="-----BEGIN CERTIFICATE-----..." style={{ fontFamily: 'monospace', fontSize: 12 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('ssl.chainPem')} name="chain_pem">
|
||||||
|
<Input.TextArea rows={4} placeholder="-----BEGIN CERTIFICATE-----..." style={{ fontFamily: 'monospace', fontSize: 12 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('ssl.keyPem')} name="key_pem" rules={[{ required: true }]}>
|
||||||
|
<Input.TextArea rows={6} placeholder="-----BEGIN PRIVATE KEY-----..." style={{ fontFamily: 'monospace', fontSize: 12 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit" loading={uploadMut.isPending}>
|
||||||
|
{t('ssl.uploadButton')}
|
||||||
|
</Button>
|
||||||
|
<Typography.Text type="secondary">{t('ssl.uploadHint')}</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={3}>{t('ssl.title')}</Typography.Title>
|
||||||
|
<Typography.Paragraph type="secondary">{t('ssl.intro')}</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Tabs items={tabs} defaultActiveKey="letsencrypt" />
|
||||||
|
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 24 }}>{t('ssl.installedTitle')}</Typography.Title>
|
||||||
|
<Table rowKey="id" loading={isLoading} dataSource={certs ?? []} columns={columns} pagination={false} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -27,6 +27,19 @@ case "$1" in
|
|||||||
/var/lib/edgeguard/acme; do
|
/var/lib/edgeguard/acme; do
|
||||||
install -d -m 0750 -o "$EG_USER" -g "$EG_USER" "$d"
|
install -d -m 0750 -o "$EG_USER" -g "$EG_USER" "$d"
|
||||||
done
|
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 ───────
|
# ── Self-signed default cert so HAProxy starts cleanly ───────
|
||||||
# HAProxy `bind :443 ssl crt /etc/edgeguard/tls/` needs at least
|
# HAProxy `bind :443 ssl crt /etc/edgeguard/tls/` needs at least
|
||||||
|
|||||||
Reference in New Issue
Block a user