Major refactoring

This commit is contained in:
2026-03-04 18:28:52 +01:00
parent 2cbab1a0a2
commit 45495f4b47
21 changed files with 885 additions and 15 deletions

View File

@@ -1,4 +1,4 @@
VERSION := 1.0.0-beta
VERSION := 1.0.1-beta
BUILD := $(shell git rev-parse --short HEAD)
GO := go
@@ -6,7 +6,13 @@ GO := go
BUILD_FLAGS := -buildmode=pie -trimpath
LDFLAGS := -linkmode=external -extldflags="-Wl,-z,relro,-z,now" -X git.nevets.tech/Keys/CertManager/internal.Version=$(VERSION) -X git.nevets.tech/Keys/CertManager/internal.Build=$(BUILD)
build:
.PHONY: proto build stage
proto:
@protoc --go_out=./proto --go-grpc_out=./proto proto/hook.proto
@protoc --go_out=./proto --go-grpc_out=./proto proto/symlink.proto
build: proto
$(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o ./certman .
@cp ./certman ./certman-$(VERSION)-amd64

15
certman-exec.service Normal file
View File

@@ -0,0 +1,15 @@
[Unit]
Description=CertMan Executor daemon
Requires=certman.socket
After=network.target
[Service]
ExecStart=/usr/local/bin/certman executor
User=root
Group=root
KillSignal=SIGTERM
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target

12
certman.socket Normal file
View File

@@ -0,0 +1,12 @@
[Unit]
Description=certman hook daemon socket
[Socket]
ListenStream=/run/certman.sock
SocketUser=root
SocketGroup=certsock
SocketMode=0660
RemoveOnStop=true
[Install]
WantedBy=sockets.target

View File

@@ -135,7 +135,7 @@ func Tick() {
keyLinks := domainConfig.GetStringSlice("Certificates.key_symlinks")
for _, keyLink := range keyLinks {
err = internal.LinkFile(filepath.Join(certsDir, domainStr+".crt"), keyLink, domainStr, ".key")
err = internal.LinkFile(filepath.Join(certsDir, domainStr+".key"), keyLink, domainStr, ".key")
if err != nil {
fmt.Printf("Error linking cert %s to %s: %v\n", keyLink, domainStr, err)
continue

57
client/grpc.go Normal file
View File

@@ -0,0 +1,57 @@
package client
import (
"context"
"flag"
"fmt"
"log"
"time"
"git.nevets.tech/Keys/CertManager/internal"
pb "git.nevets.tech/Keys/CertManager/proto/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
var (
tls = flag.Bool("tls", false, "Connection uses TLS if true, else plain TCP")
caFile = flag.String("ca_file", "", "The file containing the CA root cert file")
serverAddr = flag.String("addr", "localhost:50051", "The server address in the format of host:port")
serverHostOverride = flag.String("server_host_override", "x.test.example.com", "The server name used to verify the hostname returned by the TLS handshake")
)
func SendHook(domain string) {
conn, err := grpc.NewClient(
"unix:///run/certman.sock",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatalf("fail to dial: %v", err)
}
defer conn.Close()
client := pb.NewHookServiceClient(conn)
hooks, err := internal.PostPullHooks(domain)
if err != nil {
fmt.Printf("Error getting hooks: %v\n", err)
return
}
for _, hook := range hooks {
sendHook(client, hook)
}
}
func sendHook(client pb.HookServiceClient, hook *pb.Hook) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := client.ExecuteHook(ctx, &pb.ExecuteHookRequest{Hook: hook})
if err != nil {
fmt.Printf("Error executing hook: %v\n", err)
return
}
if res.GetError() != "" {
fmt.Printf("Error executing hook: %s\n", res.GetError())
}
}

27
commands/executor.go Normal file
View File

@@ -0,0 +1,27 @@
package commands
import (
"fmt"
"os"
"os/signal"
"syscall"
"git.nevets.tech/Keys/CertManager/executor"
)
var executorServer *executor.Server
func StartExecutorCmd() error {
executorServer = &executor.Server{}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
executorServer.Stop()
}()
if err := executorServer.Start(); err != nil {
return fmt.Errorf("failed to start executor server: %w", err)
}
return nil
}

View File

@@ -13,8 +13,13 @@ import (
func NewDomainCmd(domain, domainDir string, dirOverridden bool) error {
//TODO add config option for "overriden dir"
err := internal.LoadConfig()
if err != nil {
return err
}
fmt.Printf("Creating new domain %s\n", domain)
err := internal.CreateDomainConfig(domain)
err = internal.CreateDomainConfig(domain)
if err != nil {
return err
}
@@ -54,6 +59,11 @@ func InstallCmd(isThin bool, mode string) error {
internal.MakeDirs()
internal.CreateConfig(mode)
err := internal.LoadConfig()
if err != nil {
return err
}
f, err := os.OpenFile("/var/run/certman.pid", os.O_RDONLY|os.O_CREATE, 0755)
if err != nil {
return fmt.Errorf("error creating pid file: %v", err)
@@ -65,8 +75,16 @@ func InstallCmd(isThin bool, mode string) error {
newUserCmd := exec.Command("useradd", "-d", "/var/local/certman", "-U", "-r", "-s", "/sbin/nologin", "certman")
if output, err := newUserCmd.CombinedOutput(); err != nil {
if !strings.Contains(err.Error(), "exit status 9") {
return fmt.Errorf("error creating user: %v: output %s", err, output)
}
}
newGroupCmd := exec.Command("groupadd", "-r", "-U", "certman", "certsock")
if output, err := newGroupCmd.CombinedOutput(); err != nil {
if !strings.Contains(err.Error(), "exit status 9") {
return fmt.Errorf("error creating group: %v: output %s", err, output)
}
}
certmanUser, err := user.Lookup("certman")
if err != nil {
return fmt.Errorf("error getting user certman: %v", err)

43
executor/executor.go Normal file
View File

@@ -0,0 +1,43 @@
package executor
import (
"fmt"
"net"
"sync"
pb "git.nevets.tech/Keys/CertManager/proto/v1"
"github.com/coreos/go-systemd/v22/activation"
"google.golang.org/grpc"
)
type Server struct {
listener net.Listener
wg sync.WaitGroup
}
func (s *Server) Start() error {
listeners, err := activation.Listeners()
if err != nil {
return fmt.Errorf("systemd activation listeners: %v", err)
}
if len(listeners) != 1 {
return fmt.Errorf("systemd activation listeners: expected 1, got %d", len(listeners))
}
s.listener = listeners[0]
srv := grpc.NewServer()
pb.RegisterHookServiceServer(srv, &hookServer{})
err = srv.Serve(s.listener)
if err != nil {
return fmt.Errorf("error creating grpc listener: %v", err)
}
fmt.Printf("Started gRPC server on %s\n", s.listener.Addr())
return nil
}
func (s *Server) Stop() {
if s.listener != nil {
_ = s.listener.Close()
}
}

73
executor/hook.go Normal file
View File

@@ -0,0 +1,73 @@
package executor
import (
"context"
"errors"
"os"
"os/exec"
"syscall"
"time"
"git.nevets.tech/Keys/CertManager/internal"
pb "git.nevets.tech/Keys/CertManager/proto/v1"
)
type hookServer struct {
pb.UnimplementedHookServiceServer
}
func (s *hookServer) ExecuteHook(ctx context.Context, req *pb.ExecuteHookRequest) (*pb.ExecuteHookResponse, error) {
h := req.GetHook()
if h == nil {
return &pb.ExecuteHookResponse{Error: "missing hook"}, nil
}
// Minimal validation
if len(h.GetCommand()) == 0 {
return &pb.ExecuteHookResponse{Error: "command is empty"}, nil
}
// Timeout
timeout := time.Duration(h.GetTimeoutSeconds()) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Build command
cmdArgs := h.GetCommand()
cmd := exec.CommandContext(ctx, cmdArgs[0], cmdArgs[1:]...)
if cwd := h.GetCwd(); cwd != "" {
cmd.Dir = cwd
}
// Env: inherit current + overlay provided
env := os.Environ()
for k, v := range h.GetEnv() {
env = append(env, k+"="+v)
}
cmd.Env = env
// Run as user/group if specified (Linux/Unix)
if h.GetUser() != "" || h.GetGroup() != "" {
cred, err := internal.MakeCredential(h.GetUser(), h.GetGroup())
if err != nil {
return &pb.ExecuteHookResponse{Error: brief(err)}, nil
}
cmd.SysProcAttr = &syscall.SysProcAttr{
Credential: cred,
}
}
// Were intentionally NOT returning stdout/stderr; only a brief error on failure.
if err := cmd.Run(); err != nil {
// If context deadline hit, make the error message short and explicit.
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return &pb.ExecuteHookResponse{Error: "hook timed out"}, nil
}
return &pb.ExecuteHookResponse{Error: brief(err)}, nil
}
return &pb.ExecuteHookResponse{Error: ""}, nil
}

8
executor/util.go Normal file
View File

@@ -0,0 +1,8 @@
package executor
import "fmt"
// brief tries to keep errors short and non-leaky.
func brief(err error) string {
return fmt.Sprintf("hook failed: %v", err)
}

4
go.mod
View File

@@ -4,6 +4,7 @@ go 1.25.0
require (
code.gitea.io/sdk/gitea v0.23.2
github.com/coreos/go-systemd/v22 v22.7.0
github.com/go-acme/lego/v4 v4.32.0
github.com/go-git/go-billy/v5 v5.8.0
github.com/go-git/go-git/v5 v5.17.0
@@ -11,6 +12,8 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.48.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
)
require (
@@ -52,5 +55,6 @@ require (
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)

35
go.sum
View File

@@ -15,8 +15,12 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
@@ -50,17 +54,18 @@ github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxe
github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v55 v55.0.0 h1:4pp/1tNMB9X/LuAhs5i0KQAE40NmiR/y6prLNb9x9cg=
github.com/google/go-github/v55 v55.0.0/go.mod h1:JLahOTA1DnXzhxEymmFF5PP2tSS9JVNj68mSZNDwskA=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
@@ -123,6 +128,18 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -163,6 +180,14 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

View File

@@ -10,6 +10,7 @@ import (
"strings"
"sync"
pb "git.nevets.tech/Keys/CertManager/proto/v1"
"github.com/google/uuid"
"github.com/spf13/viper"
)
@@ -186,6 +187,18 @@ func SaveDomainConfigs() error {
return nil
}
// ---------------------------------------------------------------------------
// Domain Specific Lookups
// ---------------------------------------------------------------------------
func PostPullHooks(domain string) ([]*pb.Hook, error) {
var hooks []*pb.Hook
if err := viper.UnmarshalKey("Hooks.PostPull", hooks); err != nil {
return nil, err
}
return hooks, nil
}
// ---------------------------------------------------------------------------
// Effective lookups (domain → global fallback)
// ---------------------------------------------------------------------------
@@ -297,7 +310,7 @@ func CreateDomainConfig(domain string) error {
"{domain}", domain,
"{key}", key,
).Replace(defaultServerDomainConfig)
case "Client":
case "client":
content = strings.NewReplacer(
"{domain}", domain,
"{key}", key,
@@ -393,6 +406,12 @@ crypto_key = '{key}'
domain_name = '{domain}'
enabled = true
[Hooks.PostPull]
command = []
cwd = "/dev/null"
timeout_seconds = 30
env = { "FOO" = "bar" }
[Repo]
repo_suffix = '-certificates'
`

1
internal/grpc.go Normal file
View File

@@ -0,0 +1 @@
package internal

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"io/fs"
"os"
"os/user"
"path/filepath"
"strconv"
"strings"
@@ -224,12 +225,12 @@ func LinkFile(source, target, domain, extension string) error {
}
if linkInfo.IsDir() {
target = filepath.Join(target, domain+extension)
}
err = os.Symlink(source, target)
if err != nil {
return err
}
}
return nil
}
@@ -295,3 +296,64 @@ func ChownRecursive(path string, uid, gid int) error {
return os.Chown(name, uid, gid)
})
}
func LookupGID(group string) (int, error) {
g, err := user.LookupGroup(group)
if err != nil {
return 0, err
}
return strconv.Atoi(g.Gid)
}
// MakeCredential resolves username/groupname to uid/gid for syscall.Credential.
// Note: actually *using* different credentials typically requires the server
// process to have appropriate privileges (often root).
func MakeCredential(username, groupname string) (*syscall.Credential, error) {
var uid, gid uint32
var haveUID, haveGID bool
if username != "" {
u, err := user.Lookup(username)
if err != nil {
return nil, fmt.Errorf("unknown user")
}
parsed, err := strconv.ParseUint(u.Uid, 10, 32)
if err != nil {
return nil, fmt.Errorf("bad uid")
}
uid = uint32(parsed)
haveUID = true
// If group not explicitly provided, default to user's primary group.
if groupname == "" && u.Gid != "" {
parsedG, err := strconv.ParseUint(u.Gid, 10, 32)
if err == nil {
gid = uint32(parsedG)
haveGID = true
}
}
}
if groupname != "" {
g, err := user.LookupGroup(groupname)
if err != nil {
return nil, fmt.Errorf("unknown group")
}
parsed, err := strconv.ParseUint(g.Gid, 10, 32)
if err != nil {
return nil, fmt.Errorf("bad gid")
}
gid = uint32(parsed)
haveGID = true
}
// If only group was provided, keep current uid.
if !haveUID {
uid = uint32(os.Getuid())
}
if !haveGID {
gid = uint32(os.Getgid())
}
return &syscall.Credential{Uid: uid, Gid: gid}, nil
}

View File

@@ -99,6 +99,14 @@ func main() {
rootCmd.AddCommand(certCmd)
rootCmd.AddCommand(&cobra.Command{
Use: "executor",
Short: "Privileged daemon",
RunE: func(cmd *cobra.Command, args []string) error {
return commands.StartExecutorCmd()
},
})
daemonCmd := &cobra.Command{
Use: "daemon",
Short: "Daemon management",

27
proto/hook.proto Normal file
View File

@@ -0,0 +1,27 @@
syntax = "proto3";
package hooks.v1;
option go_package = "/v1";
service HookService {
rpc ExecuteHook(ExecuteHookRequest) returns (ExecuteHookResponse);
}
message Hook {
string name = 1;
repeated string command = 2;
string user = 3;
string group = 4;
string cwd = 5;
int32 timeout_seconds = 6;
map<string, string> env = 7;
}
message ExecuteHookRequest {
Hook hook = 1;
}
message ExecuteHookResponse {
string error = 1;
}

5
proto/symlink.proto Normal file
View File

@@ -0,0 +1,5 @@
syntax = "proto3";
package hooks.v1;
option go_package = "/v1";

280
proto/v1/hook.pb.go Normal file
View File

@@ -0,0 +1,280 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.30.2
// source: proto/hook.proto
package v1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type Hook struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Command []string `protobuf:"bytes,2,rep,name=command,proto3" json:"command,omitempty"`
User string `protobuf:"bytes,3,opt,name=user,proto3" json:"user,omitempty"`
Group string `protobuf:"bytes,4,opt,name=group,proto3" json:"group,omitempty"`
Cwd string `protobuf:"bytes,5,opt,name=cwd,proto3" json:"cwd,omitempty"`
TimeoutSeconds int32 `protobuf:"varint,6,opt,name=timeout_seconds,json=timeoutSeconds,proto3" json:"timeout_seconds,omitempty"`
Env map[string]string `protobuf:"bytes,7,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Hook) Reset() {
*x = Hook{}
mi := &file_proto_hook_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Hook) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Hook) ProtoMessage() {}
func (x *Hook) ProtoReflect() protoreflect.Message {
mi := &file_proto_hook_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Hook.ProtoReflect.Descriptor instead.
func (*Hook) Descriptor() ([]byte, []int) {
return file_proto_hook_proto_rawDescGZIP(), []int{0}
}
func (x *Hook) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *Hook) GetCommand() []string {
if x != nil {
return x.Command
}
return nil
}
func (x *Hook) GetUser() string {
if x != nil {
return x.User
}
return ""
}
func (x *Hook) GetGroup() string {
if x != nil {
return x.Group
}
return ""
}
func (x *Hook) GetCwd() string {
if x != nil {
return x.Cwd
}
return ""
}
func (x *Hook) GetTimeoutSeconds() int32 {
if x != nil {
return x.TimeoutSeconds
}
return 0
}
func (x *Hook) GetEnv() map[string]string {
if x != nil {
return x.Env
}
return nil
}
type ExecuteHookRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Hook *Hook `protobuf:"bytes,1,opt,name=hook,proto3" json:"hook,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExecuteHookRequest) Reset() {
*x = ExecuteHookRequest{}
mi := &file_proto_hook_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExecuteHookRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExecuteHookRequest) ProtoMessage() {}
func (x *ExecuteHookRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_hook_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExecuteHookRequest.ProtoReflect.Descriptor instead.
func (*ExecuteHookRequest) Descriptor() ([]byte, []int) {
return file_proto_hook_proto_rawDescGZIP(), []int{1}
}
func (x *ExecuteHookRequest) GetHook() *Hook {
if x != nil {
return x.Hook
}
return nil
}
type ExecuteHookResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExecuteHookResponse) Reset() {
*x = ExecuteHookResponse{}
mi := &file_proto_hook_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExecuteHookResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExecuteHookResponse) ProtoMessage() {}
func (x *ExecuteHookResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_hook_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExecuteHookResponse.ProtoReflect.Descriptor instead.
func (*ExecuteHookResponse) Descriptor() ([]byte, []int) {
return file_proto_hook_proto_rawDescGZIP(), []int{2}
}
func (x *ExecuteHookResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
var File_proto_hook_proto protoreflect.FileDescriptor
const file_proto_hook_proto_rawDesc = "" +
"\n" +
"\x10proto/hook.proto\x12\bhooks.v1\"\xfc\x01\n" +
"\x04Hook\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" +
"\acommand\x18\x02 \x03(\tR\acommand\x12\x12\n" +
"\x04user\x18\x03 \x01(\tR\x04user\x12\x14\n" +
"\x05group\x18\x04 \x01(\tR\x05group\x12\x10\n" +
"\x03cwd\x18\x05 \x01(\tR\x03cwd\x12'\n" +
"\x0ftimeout_seconds\x18\x06 \x01(\x05R\x0etimeoutSeconds\x12)\n" +
"\x03env\x18\a \x03(\v2\x17.hooks.v1.Hook.EnvEntryR\x03env\x1a6\n" +
"\bEnvEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"8\n" +
"\x12ExecuteHookRequest\x12\"\n" +
"\x04hook\x18\x01 \x01(\v2\x0e.hooks.v1.HookR\x04hook\"+\n" +
"\x13ExecuteHookResponse\x12\x14\n" +
"\x05error\x18\x01 \x01(\tR\x05error2Y\n" +
"\vHookService\x12J\n" +
"\vExecuteHook\x12\x1c.hooks.v1.ExecuteHookRequest\x1a\x1d.hooks.v1.ExecuteHookResponseB\x05Z\x03/v1b\x06proto3"
var (
file_proto_hook_proto_rawDescOnce sync.Once
file_proto_hook_proto_rawDescData []byte
)
func file_proto_hook_proto_rawDescGZIP() []byte {
file_proto_hook_proto_rawDescOnce.Do(func() {
file_proto_hook_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_hook_proto_rawDesc), len(file_proto_hook_proto_rawDesc)))
})
return file_proto_hook_proto_rawDescData
}
var file_proto_hook_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
var file_proto_hook_proto_goTypes = []any{
(*Hook)(nil), // 0: hooks.v1.Hook
(*ExecuteHookRequest)(nil), // 1: hooks.v1.ExecuteHookRequest
(*ExecuteHookResponse)(nil), // 2: hooks.v1.ExecuteHookResponse
nil, // 3: hooks.v1.Hook.EnvEntry
}
var file_proto_hook_proto_depIdxs = []int32{
3, // 0: hooks.v1.Hook.env:type_name -> hooks.v1.Hook.EnvEntry
0, // 1: hooks.v1.ExecuteHookRequest.hook:type_name -> hooks.v1.Hook
1, // 2: hooks.v1.HookService.ExecuteHook:input_type -> hooks.v1.ExecuteHookRequest
2, // 3: hooks.v1.HookService.ExecuteHook:output_type -> hooks.v1.ExecuteHookResponse
3, // [3:4] is the sub-list for method output_type
2, // [2:3] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
}
func init() { file_proto_hook_proto_init() }
func file_proto_hook_proto_init() {
if File_proto_hook_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_hook_proto_rawDesc), len(file_proto_hook_proto_rawDesc)),
NumEnums: 0,
NumMessages: 4,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_proto_hook_proto_goTypes,
DependencyIndexes: file_proto_hook_proto_depIdxs,
MessageInfos: file_proto_hook_proto_msgTypes,
}.Build()
File_proto_hook_proto = out.File
file_proto_hook_proto_goTypes = nil
file_proto_hook_proto_depIdxs = nil
}

121
proto/v1/hook_grpc.pb.go Normal file
View File

@@ -0,0 +1,121 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.0
// - protoc v6.30.2
// source: proto/hook.proto
package v1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
HookService_ExecuteHook_FullMethodName = "/hooks.v1.HookService/ExecuteHook"
)
// HookServiceClient is the client API for HookService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type HookServiceClient interface {
ExecuteHook(ctx context.Context, in *ExecuteHookRequest, opts ...grpc.CallOption) (*ExecuteHookResponse, error)
}
type hookServiceClient struct {
cc grpc.ClientConnInterface
}
func NewHookServiceClient(cc grpc.ClientConnInterface) HookServiceClient {
return &hookServiceClient{cc}
}
func (c *hookServiceClient) ExecuteHook(ctx context.Context, in *ExecuteHookRequest, opts ...grpc.CallOption) (*ExecuteHookResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ExecuteHookResponse)
err := c.cc.Invoke(ctx, HookService_ExecuteHook_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// HookServiceServer is the server API for HookService service.
// All implementations must embed UnimplementedHookServiceServer
// for forward compatibility.
type HookServiceServer interface {
ExecuteHook(context.Context, *ExecuteHookRequest) (*ExecuteHookResponse, error)
mustEmbedUnimplementedHookServiceServer()
}
// UnimplementedHookServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedHookServiceServer struct{}
func (UnimplementedHookServiceServer) ExecuteHook(context.Context, *ExecuteHookRequest) (*ExecuteHookResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ExecuteHook not implemented")
}
func (UnimplementedHookServiceServer) mustEmbedUnimplementedHookServiceServer() {}
func (UnimplementedHookServiceServer) testEmbeddedByValue() {}
// UnsafeHookServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to HookServiceServer will
// result in compilation errors.
type UnsafeHookServiceServer interface {
mustEmbedUnimplementedHookServiceServer()
}
func RegisterHookServiceServer(s grpc.ServiceRegistrar, srv HookServiceServer) {
// If the following call panics, it indicates UnimplementedHookServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&HookService_ServiceDesc, srv)
}
func _HookService_ExecuteHook_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExecuteHookRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(HookServiceServer).ExecuteHook(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: HookService_ExecuteHook_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(HookServiceServer).ExecuteHook(ctx, req.(*ExecuteHookRequest))
}
return interceptor(ctx, in, info, handler)
}
// HookService_ServiceDesc is the grpc.ServiceDesc for HookService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var HookService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hooks.v1.HookService",
HandlerType: (*HookServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ExecuteHook",
Handler: _HookService_ExecuteHook_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/hook.proto",
}

59
proto/v1/symlink.pb.go Normal file
View File

@@ -0,0 +1,59 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.30.2
// source: proto/symlink.proto
package v1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
var File_proto_symlink_proto protoreflect.FileDescriptor
const file_proto_symlink_proto_rawDesc = "" +
"\n" +
"\x13proto/symlink.proto\x12\bhooks.v1B\x05Z\x03/v1b\x06proto3"
var file_proto_symlink_proto_goTypes = []any{}
var file_proto_symlink_proto_depIdxs = []int32{
0, // [0:0] is the sub-list for method output_type
0, // [0:0] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_proto_symlink_proto_init() }
func file_proto_symlink_proto_init() {
if File_proto_symlink_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_symlink_proto_rawDesc), len(file_proto_symlink_proto_rawDesc)),
NumEnums: 0,
NumMessages: 0,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_proto_symlink_proto_goTypes,
DependencyIndexes: file_proto_symlink_proto_depIdxs,
}.Build()
File_proto_symlink_proto = out.File
file_proto_symlink_proto_goTypes = nil
file_proto_symlink_proto_depIdxs = nil
}