From e5c8173c5ba3c9a3b44b4efbf62e2a751e0d67ff Mon Sep 17 00:00:00 2001 From: Nate Gay Date: Mon, 15 Jul 2024 22:29:51 -0500 Subject: [PATCH] Add license, readme, go mod tidy linter, improved logging, systemd watchdog, as well as tests and release ci (#1) * Add license, readme, change repo name, and a go mod tidy linter * Fix cert parsing issue, additional logging * Add support for modifying the nginx config and restarting nginx * Add retries * logging remaining time on each cert check * Simplify now that I know that tailscale calls are cached locally * Improve logging and add systemd healthchecks * Add goreleaser pipeline * Add a few tests and a CI pipeline for tests --- .github/workflows/gomodtidy.yaml | 23 ++++ .../{golangci-lint.yaml => lint.yaml} | 6 +- .github/workflows/release.yaml | 30 +++++ .github/workflows/test.yaml | 16 +++ .gitignore | 3 + .goreleaser.yaml | 66 ++++++++++ LICENSE.md | 21 ++++ Makefile | 8 ++ README.md | 16 +++ go.mod | 8 +- go.sum | 15 +++ internal/certmanager/certmanager.go | 69 ++++++++--- internal/helpers/helpers.go | 18 +++ internal/helpers/helpers_test.go | 39 ++++++ internal/pikvm/fs.go | 31 +++++ internal/pikvm/nginx.go | 19 +++ internal/pikvm/pikvm.go | 26 ---- internal/pikvm/sslconf.go | 75 ++++++++++++ internal/sslpaths/sslpaths.go | 29 ++++- internal/sslpaths/sslpaths_test.go | 42 +++++++ internal/tailscale/client.go | 3 +- main.go | 114 +++++++++++++----- pikvm-tailscale-cert-renewer.service | 16 +++ 23 files changed, 605 insertions(+), 88 deletions(-) create mode 100644 .github/workflows/gomodtidy.yaml rename .github/workflows/{golangci-lint.yaml => lint.yaml} (85%) create mode 100644 .github/workflows/release.yaml create mode 100644 .github/workflows/test.yaml create mode 100644 .gitignore create mode 100644 .goreleaser.yaml create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 internal/helpers/helpers.go create mode 100644 internal/helpers/helpers_test.go create mode 100644 internal/pikvm/fs.go create mode 100644 internal/pikvm/nginx.go delete mode 100644 internal/pikvm/pikvm.go create mode 100644 internal/pikvm/sslconf.go create mode 100644 internal/sslpaths/sslpaths_test.go create mode 100644 pikvm-tailscale-cert-renewer.service diff --git a/.github/workflows/gomodtidy.yaml b/.github/workflows/gomodtidy.yaml new file mode 100644 index 0000000..75078e8 --- /dev/null +++ b/.github/workflows/gomodtidy.yaml @@ -0,0 +1,23 @@ +name: go mod tidy + +on: + pull_request: + +jobs: + gomodtidy: + name: tidy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + - name: go mod tidy + run: go mod tidy + - name: git diff + run: | + git diff --exit-code --quiet + if [ $? -ne 0 ]; then + echo "Please run 'go mod tidy' and commit the changes" + exit 1 + fi diff --git a/.github/workflows/golangci-lint.yaml b/.github/workflows/lint.yaml similarity index 85% rename from .github/workflows/golangci-lint.yaml rename to .github/workflows/lint.yaml index 6d976a7..82a3827 100644 --- a/.github/workflows/golangci-lint.yaml +++ b/.github/workflows/lint.yaml @@ -1,8 +1,6 @@ -name: golangci-lint +name: lint + on: - push: - branches: - - main pull_request: jobs: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..d039289 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,30 @@ +name: release + +on: + push: + tags: + - "*" + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "v2.1.0" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..f50c958 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,16 @@ +name: test + +on: + pull_request: + +jobs: + gotest: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + - name: go test + run: go test -v -race -cover ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bcb7705 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.vscode +bin/ +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..b781e8d --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,66 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj + +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - arm + goarm: + - 7 + +report_sizes: true + +archives: + - format: tar.gz + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + files: + - LICENSE.md + - README.md + - pikvm-tailscale-cert-renewer.service + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + +gomod: + proxy: true + env: + - GOPROXY=https://proxy.golang.org,direct + - GOSUMDB=sum.golang.org + mod: mod + # gobinary: go1.22.5 + +# .goreleaser.yaml +release: + github: + owner: nateinaction + name: pikvm-tailscale-cert-renewer + + # TODO: Remove when comfortable with goreleaser setup + draft: true + + # Will mark the release as not ready for production in case + # there is an indicator for this in the tag e.g. v1.0.0-rc1 + prerelease: auto + + # Header for the release body. + header: | + ## PiKVM Tailscale Cert Renewer diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..7f7f176 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Nate Gay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index 4d8fc8b..f4d9b78 100644 --- a/Makefile +++ b/Makefile @@ -15,3 +15,11 @@ fmt: -v ~/.cache/golangci-lint/$(GOLANGCI_LINT_VERSION):/root/.cache \ -w /app \ golangci/golangci-lint:$(GOLANGCI_LINT_VERSION) golangci-lint run --fix + +.PHONY: test +test: + go test -v -race -cover ./... + +.PHONY: build +build: + GOOS=linux GOARCH=arm CGO_ENABLED=0 go build -o bin/ ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3e24f8 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# PiKVM Tailscale Cert Renewer + +This is a tool to automatically renew tailscale certs for a PiKVM + +This tool assumes you have setup your PiKVM and the [tailscale integration](https://docs.pikvm.org/tailscale/) using the [official docs](https://docs.pikvm.org/). This tool is designed around the following information from the docs: +>If you have a certificate (making a cert falls outside the scope of PiKVM - please reference OpenSSL documentation or use Let's Encrypt), replace keys in /etc/kvmd/nginx/ssl, edit /etc/kvmd/nginx/ssl.conf if necessary and restart kvmd-nginx service. *[PiKVM Common Questions](https://docs.pikvm.org/faq/#common-questions)* + +This tool automatically discovers your tailscale domain, creates and renews certs for that domain, sets the cert path in the nginx config, and restarts NGINX. + +``` +[root@pikvm ~]# systemctl edit --force --full pikvm-tailscale-cert-renewer.service +Successfully installed edited file '/etc/systemd/system/pikvm-tailscale-cert-renewer.service'. +[root@pikvm ~]# systemctl enable pikvm-tailscale-cert-re^C +[root@pikvm ~]# mv pikvm-tailscale-cert-renewer /usr/local/bin/ +[root@pikvm ~]# systemctl enable pikvm-tailscale-cert-renewer.service +``` diff --git a/go.mod b/go.mod index d5874c9..dc0fb9d 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,12 @@ -module github.com/nateinaction/tailscale-cert-renewer +module github.com/nateinaction/pikvm-tailscale-cert-renewer go 1.22.5 +require ( + github.com/coreos/go-systemd/v22 v22.5.0 + tailscale.com v1.68.2 +) + require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/akutz/memconn v0.1.0 // indirect @@ -26,5 +31,4 @@ require ( golang.org/x/sys v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect - tailscale.com v1.68.2 // indirect ) diff --git a/go.sum b/go.sum index 751288a..cd7ca41 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,17 @@ github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/cilium/ebpf v0.12.3 h1:8ht6F9MquybnY97at+VDZb3eQQr8ev79RueWeVaEcG4= +github.com/cilium/ebpf v0.12.3/go.mod h1:TctK1ivibvI3znr66ljgi4hqOT8EYQjz1KWBfb1UVgM= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE= github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= @@ -16,12 +23,18 @@ github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0 github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8= github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -34,6 +47,8 @@ golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= diff --git a/internal/certmanager/certmanager.go b/internal/certmanager/certmanager.go index 4eb111a..d0be70b 100644 --- a/internal/certmanager/certmanager.go +++ b/internal/certmanager/certmanager.go @@ -2,14 +2,15 @@ package certmanager import ( "context" - "crypto/x509" "errors" "fmt" + "log/slog" "os" - "time" + "reflect" - "github.com/nateinaction/tailscale-cert-renewer/internal/sslpaths" - "github.com/nateinaction/tailscale-cert-renewer/internal/tailscale" + "github.com/nateinaction/pikvm-tailscale-cert-renewer/internal/pikvm" + "github.com/nateinaction/pikvm-tailscale-cert-renewer/internal/sslpaths" + "github.com/nateinaction/pikvm-tailscale-cert-renewer/internal/tailscale" ) type CertManager struct { @@ -25,38 +26,54 @@ func NewCertManager(ssl *sslpaths.SSLPaths) *CertManager { const ( certDirPerms = 0o755 certFilePerms = 0o644 - closeToExpire = -7 * 24 * time.Hour ) var ( - ErrExpiringSoon = errors.New("connection error") - ErrDoesNotExist = errors.New("cert does not exist") + ErrCertDoesNotExist = errors.New("cert does not exist") + ErrKeyDoesNotExist = errors.New("key does not exist") + ErrCertDoesNotMatch = errors.New("cert does not match") + ErrKeyDoesNotMatch = errors.New("key does not match") ) -// CheckCert checks if the cert exists and is not expiring soon -func (c *CertManager) CheckCert() error { - _, err := os.Stat(c.ssl.GetCertPath()) - if errors.Is(err, os.ErrNotExist) { - return ErrDoesNotExist +// CheckCert checks the cert and key files to see if they exist and match the tailscale cert +func (c *CertManager) CheckCert(ctx context.Context) error { + if _, err := os.Stat(c.ssl.GetCertPath()); os.IsNotExist(err) { + slog.Warn("cert file does not exist", "path", c.ssl.GetCertPath()) + + return ErrCertDoesNotExist + } + + if _, err := os.Stat(c.ssl.GetKeyPath()); os.IsNotExist(err) { + slog.Warn("key file does not exist", "path", c.ssl.GetKeyPath()) + + return ErrKeyDoesNotExist } + tsCert, tsKey, err := tailscale.CertPair(ctx, c.ssl.GetDomain()) if err != nil { - return fmt.Errorf("failed to stat cert file: %w", err) + return fmt.Errorf("failed to get tailscale cert pair: %w", err) } - b, err := os.ReadFile(c.ssl.GetCertPath()) + fsCert, err := os.ReadFile(c.ssl.GetCertPath()) if err != nil { return fmt.Errorf("failed to read cert file: %w", err) } - cert, err := x509.ParseCertificate(b) + fsKey, err := os.ReadFile(c.ssl.GetKeyPath()) if err != nil { - return fmt.Errorf("failed to parse cert: %w", err) + return fmt.Errorf("failed to read key file: %w", err) } - renewIfAfter := time.Now().Add(closeToExpire) - if cert.NotAfter.After(renewIfAfter) { - return ErrExpiringSoon + if !reflect.DeepEqual(tsCert, fsCert) { + slog.Warn("tailscale and filesystem certs do not match", "path", c.ssl.GetCertPath()) + + return ErrCertDoesNotMatch + } + + if !reflect.DeepEqual(tsKey, fsKey) { + slog.Warn("tailscale and filesystem keys do not match", "path", c.ssl.GetCertPath()) + + return ErrKeyDoesNotMatch } return nil @@ -69,6 +86,16 @@ func (c *CertManager) GenerateCert(ctx context.Context) error { return fmt.Errorf("failed to get tailscale cert pair: %w", err) } + if err := pikvm.SetFSReadWrite(); err != nil { + return fmt.Errorf("failed filesystem mode change: %w", err) + } + + defer func() { + if err := pikvm.SetFSReadOnly(); err != nil { + slog.Error("failed filesystem mode change", "error", err) + } + }() + if _, err := os.Stat(c.ssl.GetDir()); os.IsNotExist(err) { if err := os.MkdirAll(c.ssl.GetDir(), certDirPerms); err != nil { return fmt.Errorf("failed to create cert path: %w", err) @@ -79,9 +106,13 @@ func (c *CertManager) GenerateCert(ctx context.Context) error { return fmt.Errorf("failed to write cert file: %w", err) } + slog.Info("wrote cert file", "path", c.ssl.GetCertPath()) + if err := os.WriteFile(c.ssl.GetKeyPath(), key, certFilePerms); err != nil { return fmt.Errorf("failed to write key file: %w", err) } + slog.Info("wrote key file", "path", c.ssl.GetKeyPath()) + return nil } diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go new file mode 100644 index 0000000..37ad8bd --- /dev/null +++ b/internal/helpers/helpers.go @@ -0,0 +1,18 @@ +package helpers + +import ( + "fmt" + "regexp" +) + +// SetLine sets the line in the contents if the regex matches +func SetLine(contents []string, regex *regexp.Regexp, replacementLine string) []string { + for i, line := range contents { + if regex.MatchString(line) { + contents[i] = replacementLine + return contents + } + } + + return append(contents, fmt.Sprintf("%s\n", replacementLine)) +} diff --git a/internal/helpers/helpers_test.go b/internal/helpers/helpers_test.go new file mode 100644 index 0000000..804fcc5 --- /dev/null +++ b/internal/helpers/helpers_test.go @@ -0,0 +1,39 @@ +package helpers_test + +import ( + "regexp" + "testing" + + "github.com/nateinaction/pikvm-tailscale-cert-renewer/internal/helpers" +) + +func TestSetLine(t *testing.T) { + t.Parallel() + + contents := []string{ + "line 1", + "line 2", + "line 3", + } + + regex := regexp.MustCompile(`^line \d$`) + certLine := "new line" + + expected := []string{ + "new line", + "line 2", + "line 3", + } + + result := helpers.SetLine(contents, regex, certLine) + + if len(result) != len(expected) { + t.Errorf("Expected %d lines, but got %d", len(expected), len(result)) + } + + for i, line := range result { + if line != expected[i] { + t.Errorf("Expected line '%s', but got '%s'", expected[i], line) + } + } +} diff --git a/internal/pikvm/fs.go b/internal/pikvm/fs.go new file mode 100644 index 0000000..14474d8 --- /dev/null +++ b/internal/pikvm/fs.go @@ -0,0 +1,31 @@ +package pikvm + +import ( + "fmt" + "log/slog" + "os/exec" +) + +// SetFSReadOnly sets the filesystem to read-only mode +func SetFSReadOnly() error { + out, err := exec.Command("ro").Output() + if err != nil { + return fmt.Errorf("failed enable read-only mode with output: %s: %w", out, err) + } + + slog.Info("filesystem mode changed to read-only") + + return nil +} + +// SetFSReadWrite sets the filesystem to read-write mode +func SetFSReadWrite() error { + out, err := exec.Command("rw").Output() + if err != nil { + return fmt.Errorf("failed enable read/write mode with output: %s: %w", out, err) + } + + slog.Info("filesystem mode changed to read/write") + + return nil +} diff --git a/internal/pikvm/nginx.go b/internal/pikvm/nginx.go new file mode 100644 index 0000000..0b6c6c3 --- /dev/null +++ b/internal/pikvm/nginx.go @@ -0,0 +1,19 @@ +package pikvm + +import ( + "fmt" + "log/slog" + "os/exec" +) + +// RestartNginx restarts the kvmd-nginx service +func RestartNginx() error { + out, err := exec.Command("systemctl", "restart", "kvmd-nginx").Output() + if err != nil { + return fmt.Errorf("failed restart kvmd-nginx with output: %s: %w", out, err) + } + + slog.Info("kvmd-nginx restarted") + + return nil +} diff --git a/internal/pikvm/pikvm.go b/internal/pikvm/pikvm.go deleted file mode 100644 index 3d26238..0000000 --- a/internal/pikvm/pikvm.go +++ /dev/null @@ -1,26 +0,0 @@ -package pikvm - -import ( - "fmt" - "os/exec" -) - -// SetFSReadOnly sets the filesystem to read-only mode -func SetFSReadOnly() error { - cmd := exec.Command("ro") - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed enable read-only mode: %w", err) - } - - return nil -} - -// SetFSReadWrite sets the filesystem to read-write mode -func SetFSReadWrite() error { - cmd := exec.Command("rw") - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed enable read/write mode: %w", err) - } - - return nil -} diff --git a/internal/pikvm/sslconf.go b/internal/pikvm/sslconf.go new file mode 100644 index 0000000..5c2e5b9 --- /dev/null +++ b/internal/pikvm/sslconf.go @@ -0,0 +1,75 @@ +package pikvm + +import ( + "fmt" + "log/slog" + "os" + "regexp" + "slices" + "strings" + + "github.com/nateinaction/pikvm-tailscale-cert-renewer/internal/helpers" + "github.com/nateinaction/pikvm-tailscale-cert-renewer/internal/sslpaths" +) + +var ( + certlineRegex = regexp.MustCompile(`^ssl_certificate\s+.*`) + keylineRegex = regexp.MustCompile(`^ssl_certificate_key\s+.*`) + + ErrNginxConfigMissingSSLDetails = fmt.Errorf("nginx config missing ssl details") +) + +const ( + nginxSSLConf = "/etc/kvmd/nginx/ssl.conf" + nginxSSLConfPerms = 0o644 +) + +// CheckNginxConfig checks the nginx config for the cert and key lines +func CheckNginxConfig(ssl *sslpaths.SSLPaths) error { + b, err := os.ReadFile(nginxSSLConf) + if err != nil { + return fmt.Errorf("failed to read ssl.conf: %w", err) + } + + lines := strings.Split(string(b), "\n") + + if !slices.Contains(lines, ssl.GetNginxConfigCertLine()) || + !slices.Contains(lines, ssl.GetNginxConfigKeyLine()) { + slog.Warn("cert or key line not found in nginx config", "path", nginxSSLConf) + + return ErrNginxConfigMissingSSLDetails + } + + return nil +} + +// WriteNginxConfig writes the cert and key lines to the nginx config +func WriteNginxConfig(ssl *sslpaths.SSLPaths) error { + b, err := os.ReadFile(nginxSSLConf) + if err != nil { + return fmt.Errorf("failed to read ssl.conf: %w", err) + } + + lines := strings.Split(string(b), "\n") + + lines = helpers.SetLine(lines, certlineRegex, ssl.GetNginxConfigCertLine()) + lines = helpers.SetLine(lines, keylineRegex, ssl.GetNginxConfigKeyLine()) + + if err := SetFSReadWrite(); err != nil { + return fmt.Errorf("failed filesystem mode change: %w", err) + } + + defer func() { + if err := SetFSReadOnly(); err != nil { + slog.Error("failed filesystem mode change", "error", err) + } + }() + + if err := os.WriteFile(nginxSSLConf, []byte(strings.Join(lines, "\n")), nginxSSLConfPerms); err != nil { + return fmt.Errorf("failed to write to nginx ssl config at %s: %w", nginxSSLConf, err) + } + + slog.Info("wrote to nginx ssl config", "path", nginxSSLConf) + + return nil +} diff --git a/internal/sslpaths/sslpaths.go b/internal/sslpaths/sslpaths.go index 045369a..df022ae 100644 --- a/internal/sslpaths/sslpaths.go +++ b/internal/sslpaths/sslpaths.go @@ -1,22 +1,31 @@ package sslpaths -import "path" +import ( + "fmt" + "path" +) // SSLPaths is a struct that holds the paths to the SSL certificate and key type SSLPaths struct { - cert string - dir string - domain string - key string + cert string + dir string + domain string + key string + nginxConfigCertLine string + nginxConfigKeyLine string } func NewSSLPaths(dir, domain string) *SSLPaths { - return &SSLPaths{ + sslP := &SSLPaths{ cert: path.Join(dir, domain+".crt"), dir: dir, domain: domain, key: path.Join(dir, domain+".key"), } + sslP.nginxConfigCertLine = fmt.Sprintf("ssl_certificate %s;", sslP.GetCertPath()) + sslP.nginxConfigKeyLine = fmt.Sprintf("ssl_certificate_key %s;", sslP.GetKeyPath()) + + return sslP } func (c *SSLPaths) GetCertPath() string { @@ -34,3 +43,11 @@ func (c *SSLPaths) GetDir() string { func (c *SSLPaths) GetDomain() string { return c.domain } + +func (c *SSLPaths) GetNginxConfigCertLine() string { + return c.nginxConfigCertLine +} + +func (c *SSLPaths) GetNginxConfigKeyLine() string { + return c.nginxConfigKeyLine +} diff --git a/internal/sslpaths/sslpaths_test.go b/internal/sslpaths/sslpaths_test.go new file mode 100644 index 0000000..8986f62 --- /dev/null +++ b/internal/sslpaths/sslpaths_test.go @@ -0,0 +1,42 @@ +package sslpaths_test + +import ( + "testing" + + "github.com/nateinaction/pikvm-tailscale-cert-renewer/internal/sslpaths" +) + +func TestNewSSLPaths(t *testing.T) { + t.Parallel() + + dir := "/path/to/dir" + domain := "example.com" + + sslP := sslpaths.NewSSLPaths(dir, domain) + + if sslP.GetCertPath() != "/path/to/dir/example.com.crt" { + t.Errorf("Expected cert path '/path/to/dir/example.com.crt', but got '%s'", sslP.GetCertPath()) + } + + if sslP.GetKeyPath() != "/path/to/dir/example.com.key" { + t.Errorf("Expected key path '/path/to/dir/example.com.key', but got '%s'", sslP.GetKeyPath()) + } + + if sslP.GetDir() != "/path/to/dir" { + t.Errorf("Expected dir '/path/to/dir', but got '%s'", sslP.GetDir()) + } + + if sslP.GetDomain() != "example.com" { + t.Errorf("Expected domain 'example.com', but got '%s'", sslP.GetDomain()) + } + + if sslP.GetNginxConfigCertLine() != "ssl_certificate /path/to/dir/example.com.crt;" { + t.Errorf("Expected nginx config cert line 'ssl_certificate /path/to/dir/example.com.crt;', but got '%s'", + sslP.GetNginxConfigCertLine()) + } + + if sslP.GetNginxConfigKeyLine() != "ssl_certificate_key /path/to/dir/example.com.key;" { + t.Errorf("Expected nginx config key line 'ssl_certificate_key /path/to/dir/example.com.key;', but got '%s'", + sslP.GetNginxConfigKeyLine()) + } +} diff --git a/internal/tailscale/client.go b/internal/tailscale/client.go index a0efc99..bf1e9f9 100644 --- a/internal/tailscale/client.go +++ b/internal/tailscale/client.go @@ -3,6 +3,7 @@ package tailscale import ( "context" "fmt" + "strings" ts "tailscale.com/client/tailscale" ) @@ -16,7 +17,7 @@ func GetDomain(ctx context.Context) (string, error) { return "", fmt.Errorf("failed to get status: %w", err) } - return statusResp.Self.DNSName, nil + return strings.TrimSuffix(statusResp.Self.DNSName, "."), nil } // CertPair generates the cert pair for the given domain diff --git a/main.go b/main.go index ccd795c..f9fa809 100644 --- a/main.go +++ b/main.go @@ -3,58 +3,112 @@ package main import ( "context" "errors" + "fmt" "log/slog" + "os" "time" - "github.com/nateinaction/tailscale-cert-renewer/internal/certmanager" - "github.com/nateinaction/tailscale-cert-renewer/internal/pikvm" - "github.com/nateinaction/tailscale-cert-renewer/internal/sslpaths" - "github.com/nateinaction/tailscale-cert-renewer/internal/tailscale" + systemd "github.com/coreos/go-systemd/v22/daemon" + "github.com/nateinaction/pikvm-tailscale-cert-renewer/internal/certmanager" + "github.com/nateinaction/pikvm-tailscale-cert-renewer/internal/pikvm" + "github.com/nateinaction/pikvm-tailscale-cert-renewer/internal/sslpaths" + "github.com/nateinaction/pikvm-tailscale-cert-renewer/internal/tailscale" ) -const timeToSleep = 24 * time.Hour +const ( + timeToSleep = 1 * time.Minute +) func main() { ctx := context.Background() - for { - domain, err := tailscale.GetDomain(ctx) + slog.Info("starting tailscale cert renewer") + + systemdOk, err := systemd.SdNotify(false, systemd.SdNotifyReady) + if !systemdOk { if err != nil { - slog.Error("failed to get domain", "error", err) - continue + slog.Error("failed to notify systemd, notify socket is unset") + os.Exit(1) } - ssl := sslpaths.NewSSLPaths("/etc/kvmd/nginx/ssl/", domain) - - certManager := certmanager.NewCertManager(ssl) + slog.Error("failed to notify systemd", "error", err) + os.Exit(1) + } - if err := certManager.CheckCert(); err != nil { - if errors.Is(err, certmanager.ErrDoesNotExist) || errors.Is(err, certmanager.ErrExpiringSoon) { - slog.Info("cert is missing or expiring soon, generating new cert", "reason", err) + for { + systemdOk, err := systemd.SdNotify(false, systemd.SdNotifyWatchdog) + if !systemdOk { + if err != nil { + slog.Error("failed to notify systemd, notify socket is unset") + } - if err := pikvm.SetFSReadWrite(); err != nil { - slog.Error("failed filesystem mode change", "error", err) - continue - } + slog.Error("failed to notify systemd", "error", err) + } - genCert(ctx, certManager) - } else { - slog.Error("failed to check cert", "error", err) - } + if err := doCertCheckAndRenewal(ctx); err != nil { + slog.Error("failed to check or renew cert", "time_until_retry", timeToSleep, "error", err) } time.Sleep(timeToSleep) } } -func genCert(ctx context.Context, certManager *certmanager.CertManager) { - defer func() { - if err := pikvm.SetFSReadOnly(); err != nil { - slog.Error("failed filesystem mode change", "error", err) +func doCertCheckAndRenewal(ctx context.Context) error { + domain, err := tailscale.GetDomain(ctx) + if err != nil { + return fmt.Errorf("failed to get tailscale domain: %w", err) + } + + ssl := sslpaths.NewSSLPaths("/etc/kvmd/nginx/ssl/", domain) + + certManager := certmanager.NewCertManager(ssl) + + sslCertsChanged, err := changedSSLCerts(ctx, certManager) + if err != nil { + return fmt.Errorf("failed to check ssl certs: %w", err) + } + + nginxConfigChanged, err := changedNginxConfig(ssl) + if err != nil { + return fmt.Errorf("failed to check nginx config: %w", err) + } + + if sslCertsChanged || nginxConfigChanged { + if err := pikvm.RestartNginx(); err != nil { + return fmt.Errorf("failed to restart nginx: %w", err) + } + } + + return nil +} + +func changedSSLCerts(ctx context.Context, certManager *certmanager.CertManager) (bool, error) { + if err := certManager.CheckCert(ctx); errors.Is(err, certmanager.ErrCertDoesNotExist) || + errors.Is(err, certmanager.ErrKeyDoesNotExist) || + errors.Is(err, certmanager.ErrCertDoesNotMatch) || + errors.Is(err, certmanager.ErrKeyDoesNotMatch) { + if err := certManager.GenerateCert(ctx); err != nil { + return false, fmt.Errorf("failed to generate cert: %w", err) } - }() - if err := certManager.GenerateCert(ctx); err != nil { - slog.Error("failed to generate cert", "error", err) + return true, nil + } else if err != nil { + return false, fmt.Errorf("failed to check cert: %w", err) } + + return false, nil +} + +func changedNginxConfig(ssl *sslpaths.SSLPaths) (bool, error) { + if err := pikvm.CheckNginxConfig(ssl); errors.Is(err, pikvm.ErrNginxConfigMissingSSLDetails) { + if err := pikvm.WriteNginxConfig(ssl); err != nil { + return false, fmt.Errorf("failed to write nginx config: %w", err) + } + + return true, nil + } else if err != nil { + return false, fmt.Errorf("failed to set certs in nginx config: %w", err) + } + + return false, nil } diff --git a/pikvm-tailscale-cert-renewer.service b/pikvm-tailscale-cert-renewer.service new file mode 100644 index 0000000..8685c40 --- /dev/null +++ b/pikvm-tailscale-cert-renewer.service @@ -0,0 +1,16 @@ +[Unit] +Description=PiKVM Tailscale Certificate Renewer +After=network.target + +[Service] +Type=notify +ExecStart=/usr/local/bin/pikvm-tailscale-cert-renewer +Restart=always +RestartSec=3 +TimeoutSec=5 +WatchdogSec=120 +User=root +Group=root + +[Install] +WantedBy=multi-user.target