Skip to content

Commit

Permalink
Add license, readme, go mod tidy linter, improved logging, systemd wa…
Browse files Browse the repository at this point in the history
…tchdog, 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
  • Loading branch information
nateinaction authored Jul 16, 2024
1 parent e6ba6c1 commit e5c8173
Show file tree
Hide file tree
Showing 23 changed files with 605 additions and 88 deletions.
23 changes: 23 additions & 0 deletions .github/workflows/gomodtidy.yaml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
name: golangci-lint
name: lint

on:
push:
branches:
- main
pull_request:

jobs:
Expand Down
30 changes: 30 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -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 }}
16 changes: 16 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -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 ./...
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.vscode
bin/
dist/
66 changes: 66 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions LICENSE.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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/ ./...
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
```
8 changes: 6 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
)
15 changes: 15 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down
69 changes: 50 additions & 19 deletions internal/certmanager/certmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
}
Loading

0 comments on commit e5c8173

Please sign in to comment.