diff options
Diffstat
| -rw-r--r-- | .github/workflows/build.yml | 34 | +13 −21 |
| -rw-r--r-- | .gitignore | 3 | +3 −0 |
| -rw-r--r-- | Taskfile.yml | 38 | +28 −10 |
| -rw-r--r-- | cmd/mirumd/main.go | 151 | +139 −12 |
| -rw-r--r-- | cmd/mirumw/main.go | 195 | +179 −16 |
| -rw-r--r-- | go.mod | 9 | +8 −1 |
| -rw-r--r-- | go.sum | 14 | +14 −0 |
| -rw-r--r-- | internal/protocol/hmac.go | 31 | +31 −0 |
| -rw-r--r-- | internal/protocol/platform.go | 59 | +59 −0 |
| -rw-r--r-- | internal/protocol/version.go | 44 | +44 −0 |
| -rw-r--r-- | internal/supervisor/supervisor.go | 4 | +2 −2 |
| -rw-r--r-- | internal/supervisor/systemd.go | 21 | +13 −8 |
| -rw-r--r-- | pkg/mirumd.yaml | 3 | +2 −1 |
| -rw-r--r-- | proto/mirum.proto | 170 | +170 −0 |
14 files changed, 705 insertions, 71 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 27a4e8f..8dc21c0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,25 +11,6 @@ on: branches: [main] jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 - with: - go-version-file: go.mod - - - name: Check licenses - uses: fsfe/reuse-action@676e2d560c9a403aa252096d99fcab3e1132b0f5 # v6.0.0 - - - name: Check formatting - run: test -z "$(gofmt -l .)" - - - name: Vet - run: go vet ./... - build: name: Build runs-on: ubuntu-latest @@ -39,6 +20,9 @@ jobs: steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Check licenses + uses: fsfe/reuse-action@676e2d560c9a403aa252096d99fcab3e1132b0f5 # v6.0.0 + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version-file: go.mod @@ -46,14 +30,19 @@ jobs: - name: Install tools run: | echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list - sudo apt update && sudo apt install nfpm + sudo apt update && sudo apt install nfpm protobuf-compiler sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin + go install google.golang.org/protobuf/cmd/protoc-gen-go@latest + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest - name: Import GPG key uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6.3.0 with: passphrase: ${{ secrets.GPG_PASSPHRASE }} gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + + - name: Check formatting + run: test -z "$(gofmt -l .)" - name: Determine version id: version @@ -77,6 +66,9 @@ jobs: GPG_KEY_ID: ${{ vars.GPG_KEY_ID }} GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} NFPM_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + + - name: Vet + run: go vet ./... - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: @@ -86,7 +78,7 @@ jobs: publish: name: Publish runs-on: ubuntu-latest - needs: [lint, build] + needs: [build] if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') permissions: contents: write diff --git a/.gitignore b/.gitignore index d893ba1..3aeef41 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ /build /.task + +# grpc generated code +internal/protocol/pb diff --git a/Taskfile.yml b/Taskfile.yml index 259fb37..1fc37eb 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,7 +1,7 @@ # Copyright (c) 2026 Nikolay Govorov # SPDX-License-Identifier: AGPL-3.0-or-later -version: '3' +version: "3" vars: BUILD_DIR: build @@ -9,29 +9,47 @@ vars: ARCHES: amd64 arm64 riscv64 ppc64le tasks: + proto: + desc: Generate gRPC code from proto files + sources: + - proto/*.proto + generates: + - internal/protocol/pb/*.pb.go + cmds: + - protoc --go_out=. --go-grpc_out=. proto/mirum.proto + build: - desc: Build for current arch + desc: "Build binaries (override GOOS/GOARCH for cross-compilation)" + deps: [proto] + vars: + GOOS: { sh: "echo ${GOOS:-$(go env GOOS)}" } + GOARCH: { sh: "echo ${GOARCH:-$(go env GOARCH)}" } + VERSION: { sh: cat VERSION } + LDFLAGS: "-X mrdimidium/mirum/internal/protocol.raw={{.VERSION}}" + env: + CGO_ENABLED: "0" + GOOS: "{{.GOOS}}" + GOARCH: "{{.GOARCH}}" cmds: - mkdir -p {{.BUILD_DIR}} - - go build -o {{.BUILD_DIR}}/mirumd ./cmd/mirumd - - go build -o {{.BUILD_DIR}}/mirumw ./cmd/mirumw + - go build -ldflags '{{.LDFLAGS}}' -o {{.BUILD_DIR}}/mirumd-{{.GOOS}}-{{.GOARCH}} ./cmd/mirumd + - go build -ldflags '{{.LDFLAGS}}' -o {{.BUILD_DIR}}/mirumw-{{.GOOS}}-{{.GOARCH}} ./cmd/mirumw + - cp {{.BUILD_DIR}}/mirumd-{{.GOOS}}-{{.GOARCH}} {{.BUILD_DIR}}/mirumd + - cp {{.BUILD_DIR}}/mirumw-{{.GOOS}}-{{.GOARCH}} {{.BUILD_DIR}}/mirumw package: desc: Build deb/rpm packages for all architectures requires: vars: [VERSION] env: - CGO_ENABLED: "0" VERSION: "{{.VERSION}}" cmds: - - mkdir -p {{.DIST_DIR}} {{.BUILD_DIR}} + - mkdir -p {{.DIST_DIR}} - for: { var: ARCHES } cmd: | - GOARCH={{.ITEM}} go build -o {{.BUILD_DIR}}/mirumd-linux-{{.ITEM}} ./cmd/mirumd - GOARCH={{.ITEM}} go build -o {{.BUILD_DIR}}/mirumw-linux-{{.ITEM}} ./cmd/mirumw - cp {{.BUILD_DIR}}/mirumd-linux-{{.ITEM}} {{.BUILD_DIR}}/mirumd - cp {{.BUILD_DIR}}/mirumw-linux-{{.ITEM}} {{.BUILD_DIR}}/mirumw + task build GOOS=linux GOARCH={{.ITEM}} ARCH={{.ITEM}} nfpm package --packager deb --target {{.DIST_DIR}}/ ARCH={{.ITEM}} nfpm package --packager rpm --target {{.DIST_DIR}}/ + rm {{.BUILD_DIR}}/mirumd {{.BUILD_DIR}}/mirumw # yaml-language-server: $schema=https://taskfile.dev/schema.json diff --git a/cmd/mirumd/main.go b/cmd/mirumd/main.go index f5ee0d5..1b0e3b3 100644 --- a/cmd/mirumd/main.go +++ b/cmd/mirumd/main.go @@ -23,23 +23,29 @@ import ( "strings" "time" + "mrdimidium/mirum/internal/protocol" + "mrdimidium/mirum/internal/protocol/pb" "mrdimidium/mirum/internal/supervisor" "github.com/coreos/go-systemd/v22/activation" "go.starlark.net/starlark" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/timestamppb" "gopkg.in/yaml.v3" ) type config struct { - Address string `yaml:"address"` - Secret string `yaml:"secret"` - Token string `yaml:"token"` - Script string `yaml:"script"` + GrpcAddr string `yaml:"grpc_addr"` + WwwAddr string `yaml:"www_addr"` + Secret string `yaml:"secret"` + Token string `yaml:"token"` + Script string `yaml:"script"` } var cfg = config{ - Address: ":3000", - Script: ".mirum/main.star", + GrpcAddr: ":2026", + WwwAddr: ":3000", + Script: ".mirum/main.star", } var configFile = flag.String("config", "", "path to config file") @@ -289,15 +295,24 @@ func main() { go processPush(push) }) - ln, err := socketActivationListener() + // gRPC server + grpcLn, err := net.Listen("tcp", cfg.GrpcAddr) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } + grpcSrv := grpc.NewServer(grpc.StreamInterceptor(streamTimeoutInterceptor)) + pb.RegisterMirumServer(grpcSrv, &mirumServer{secret: []byte(cfg.Secret)}) - slog.Info("listening", "addr", ln.Addr()) + // HTTP server + httpLn, err := socketActivationListener() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + httpSrv := &http.Server{Handler: mux} - srv := &http.Server{Handler: mux} + slog.Info("listening", "grpc", grpcLn.Addr(), "http", httpLn.Addr()) sup := supervisor.Detect() ctx := sup.WaitForStop(context.Background()) @@ -307,24 +322,136 @@ func main() { slog.Info("shutting down") sup.Stopping() + grpcSrv.GracefulStop() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - srv.Shutdown(shutdownCtx) + httpSrv.Shutdown(shutdownCtx) }() + go grpcSrv.Serve(grpcLn) + sup.Ready() go sup.StartWatchdog() - if err := srv.Serve(ln); err != http.ErrServerClosed { + if err := httpSrv.Serve(httpLn); err != http.ErrServerClosed { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } +type mirumServer struct { + pb.UnimplementedMirumServer + secret []byte +} + +func (s *mirumServer) Handshake(stream pb.Mirum_HandshakeServer) error { + // Step 1: receive worker nonce + in, err := stream.Recv() + if err != nil { + return fmt.Errorf("recv worker challenge: %w", err) + } + wc := in.GetWorkerChallenge() + if wc == nil { + return fmt.Errorf("expected WorkerChallenge") + } + workerNonce := wc.GetNonce() + if len(workerNonce) != protocol.NonceSize { + return fmt.Errorf("invalid nonce size: %d", len(workerNonce)) + } + + // Step 2: send server nonce + proof + serverNonce, err := protocol.GenerateNonce() + if err != nil { + return fmt.Errorf("generate nonce: %w", err) + } + if err := stream.Send(&pb.HandshakeOut{ + Step: &pb.HandshakeOut_ServerChallenge{ + ServerChallenge: &pb.ServerChallenge{ + Nonce: serverNonce, + Proof: protocol.ComputeProof(s.secret, workerNonce, serverNonce), + }, + }, + }); err != nil { + return fmt.Errorf("send server challenge: %w", err) + } + + // Step 3: receive worker proof + metadata + in, err = stream.Recv() + if err != nil { + return fmt.Errorf("recv worker proof: %w", err) + } + wp := in.GetWorkerProof() + if wp == nil { + return fmt.Errorf("expected WorkerProof") + } + + if !protocol.VerifyProof(s.secret, serverNonce, workerNonce, wp.GetProof()) { + return s.reject(stream, "invalid secret") + } + + // Check clock skew + wt := wp.GetWorkerTime() + if wt == nil { + return s.reject(stream, "worker_time is required") + } + skew := time.Since(wt.AsTime()).Abs() + if skew > time.Minute { + return s.reject(stream, fmt.Sprintf("clock skew too large: %s", skew.Truncate(time.Second))) + } + if skew > 10*time.Second { + slog.Warn("clock skew", "worker", wp.GetName(), "skew", skew.Truncate(time.Second)) + } + + slog.Info("worker connected", + "id", fmt.Sprintf("%x", wp.GetId()), + "name", wp.GetName(), + "os", wp.GetOs(), + "arch", wp.GetArch(), + "runtime", wp.GetRuntime(), + ) + + // Step 4: accept + return s.sendResult(stream, nil) +} + +func (s *mirumServer) sendResult(stream pb.Mirum_HandshakeServer, errMsg *string) error { + return stream.Send(&pb.HandshakeOut{ + Step: &pb.HandshakeOut_ServerResult{ + ServerResult: &pb.ServerResult{ + Error: errMsg, + ServerVersion: protocol.VersionProto(), + ServerTime: timestamppb.Now(), + }, + }, + }) +} + +func (s *mirumServer) reject(stream pb.Mirum_HandshakeServer, reason string) error { + if err := s.sendResult(stream, &reason); err != nil { + return err + } + return fmt.Errorf("%s", reason) +} + +func streamTimeoutInterceptor(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + if info.FullMethod == pb.Mirum_Handshake_FullMethodName { + done := make(chan error, 1) + go func() { done <- handler(srv, ss) }() + select { + case err := <-done: + return err + case <-time.After(30 * time.Second): + return fmt.Errorf("handshake timeout") + } + } + return handler(srv, ss) +} + func socketActivationListener() (net.Listener, error) { listeners, _ := activation.Listeners() if len(listeners) > 0 { return listeners[0], nil } - return net.Listen("tcp", cfg.Address) + return net.Listen("tcp", cfg.WwwAddr) } diff --git a/cmd/mirumw/main.go b/cmd/mirumw/main.go index 194abfa..1299b86 100644 --- a/cmd/mirumw/main.go +++ b/cmd/mirumw/main.go @@ -5,45 +5,195 @@ package main import ( "context" + "crypto/tls" "flag" "fmt" "log/slog" + "net" "os" + "time" + "mrdimidium/mirum/internal/protocol" + "mrdimidium/mirum/internal/protocol/pb" "mrdimidium/mirum/internal/supervisor" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/timestamppb" "gopkg.in/yaml.v3" ) -type config struct { - Server string `yaml:"server"` -} +// Runtime type of this worker binary. Different worker types +// (mirumw-vm, mirumw-docker, etc.) will have different values. +const workerRuntime = "host" -var cfg = config{ - Server: "localhost:2026", +type config struct { + Name string `yaml:"name"` + Server string `yaml:"server"` + Secret string `yaml:"secret"` + Insecure bool `yaml:"tls_insecure"` } -var configFile = flag.String("config", "", "path to config file") - -func main() { - flag.Parse() +func GetConfig(filename string) (*config, error) { + cfg := &config{ + Server: "localhost:2026", + } - if *configFile != "" { - data, err := os.ReadFile(*configFile) + if filename != "" { + data, err := os.ReadFile(filename) if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) + return nil, fmt.Errorf("couldn't read config: %w", err) } + if err := yaml.Unmarshal(data, &cfg); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) + return nil, fmt.Errorf("couldn't parse config: %w", err) + } + } + + return cfg, nil +} + +type client struct { + cfg *config + conn *grpc.ClientConn + handle pb.MirumClient +} + +func ClientConnect(cfg *config) (*client, error) { + var creds grpc.DialOption + if cfg.Insecure { + host, _, _ := net.SplitHostPort(cfg.Server) + if !isPrivateHost(host) { + return nil, fmt.Errorf("tls_insecure is only allowed for private/loopback addresses, got %s", host) } + + slog.Warn("TLS disabled, connection is not encrypted") + creds = grpc.WithTransportCredentials(insecure.NewCredentials()) + } else { + creds = grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})) + } + + conn, err := grpc.NewClient(cfg.Server, creds) + if err != nil { + return nil, err + } + + return &client{ + cfg: cfg, + conn: conn, + handle: pb.NewMirumClient(conn), + }, nil +} + +func (c *client) Close() error { + return c.conn.Close() +} + +func (c *client) Handshake(ctx context.Context) error { + stream, err := c.handle.Handshake(ctx) + if err != nil { + return fmt.Errorf("open stream: %w", err) } + // Step 1: send worker nonce + workerNonce, err := protocol.GenerateNonce() + if err != nil { + return fmt.Errorf("generate nonce: %w", err) + } + if err := stream.Send(&pb.HandshakeIn{ + Step: &pb.HandshakeIn_WorkerChallenge{ + WorkerChallenge: &pb.WorkerChallenge{Nonce: workerNonce}, + }, + }); err != nil { + return fmt.Errorf("send challenge: %w", err) + } + + // Step 2: receive server challenge, verify server + out, err := stream.Recv() + if err != nil { + return fmt.Errorf("recv server challenge: %w", err) + } + + sc := out.GetServerChallenge() + if sc == nil { + return fmt.Errorf("expected ServerChallenge") + } + if !protocol.VerifyProof([]byte(c.cfg.Secret), workerNonce, sc.GetNonce(), sc.GetProof()) { + return fmt.Errorf("server proof verification failed") + } + + // Step 3: send worker proof + metadata + name := c.cfg.Name + if name == "" { + name, _ = os.Hostname() + } + if err := stream.Send(&pb.HandshakeIn{ + Step: &pb.HandshakeIn_WorkerProof{ + WorkerProof: &pb.WorkerProof{ + Proof: protocol.ComputeProof([]byte(c.cfg.Secret), sc.GetNonce(), workerNonce), + Name: name, + Version: protocol.VersionProto(), + Os: protocol.DetectOs(), + Arch: protocol.DetectArch(), + Runtime: workerRuntime, + WorkerTime: timestamppb.Now(), + }, + }, + }); err != nil { + return fmt.Errorf("send proof: %w", err) + } + + // Step 4: receive result + out, err = stream.Recv() + if err != nil { + return fmt.Errorf("recv result: %w", err) + } + sr := out.GetServerResult() + if sr == nil { + return fmt.Errorf("expected ServerResult") + } + if sr.Error != nil { + return fmt.Errorf("server rejected: %s", *sr.Error) + } + + v := sr.GetServerVersion() + slog.Info("handshake ok", "server_version", fmt.Sprintf("%d.%d.%d", v.GetMajor(), v.GetMinor(), v.GetPatch())) + return nil +} + +func main() { + configFile := flag.String("config", "", "path to config file") + flag.Parse() + sup := supervisor.Detect() ctx := sup.WaitForStop(context.Background()) - slog.Info("connecting", "server", cfg.Server) + cfg, err := GetConfig(*configFile) + if err != nil { + slog.Error("config", "err", err) + os.Exit(1) + } + + client, err := ClientConnect(cfg) + if err != nil { + slog.Error("failed grpc client", "err", err) + os.Exit(1) + } + defer func() { + if err := client.Close(); err != nil { + slog.Warn("close grpc client", "err", err) + } + }() + + handshakeCtx, handshakeCancel := context.WithTimeout(ctx, 30*time.Second) + defer handshakeCancel() + if err := client.Handshake(handshakeCtx); err != nil { + slog.Error("handshake failed", "err", err) + os.Exit(1) + } else { + slog.Info("connected", "server", cfg.Server) + } sup.Ready() go sup.StartWatchdog() @@ -52,3 +202,16 @@ func main() { slog.Info("shutting down") sup.Stopping() } + +func isPrivateHost(host string) bool { + ips, err := net.LookupIP(host) + if err != nil { + return false + } + for _, ip := range ips { + if !ip.IsLoopback() && !ip.IsPrivate() && !ip.IsLinkLocalUnicast() { + return false + } + } + return len(ips) > 0 +} diff --git a/go.mod b/go.mod index 0f62d22..db39819 100644 --- a/go.mod +++ b/go.mod @@ -8,4 +8,11 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -require golang.org/x/sys v0.42.0 // indirect +require ( + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260330182312-d5a96adf58d8 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/go.sum b/go.sum index b67d3d2..0d9449e 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,22 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= go.starlark.net v0.0.0-20260326113308-fadfc96def35 h1:VYAqieSOJNxBDX8KJneTAwvdf4J4zRDE2u+UFXtt9h4= go.starlark.net v0.0.0-20260326113308-fadfc96def35/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260330182312-d5a96adf58d8 h1:OHkuo1i98/05rzpm9NBbfEtpJH/k3abEgZUKaAuCI7Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260330182312-d5a96adf58d8/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/protocol/hmac.go b/internal/protocol/hmac.go new file mode 100644 --- /dev/null +++ b/internal/protocol/hmac.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package protocol + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" +) + +const NonceSize = 32 + +func GenerateNonce() ([]byte, error) { + nonce := make([]byte, NonceSize) + _, err := rand.Read(nonce) + return nonce, err +} + +// ComputeProof returns HMAC-SHA256(secret, first || second). +func ComputeProof(secret, first, second []byte) []byte { + mac := hmac.New(sha256.New, secret) + mac.Write(first) + mac.Write(second) + return mac.Sum(nil) +} + +func VerifyProof(secret, first, second, proof []byte) bool { + expected := ComputeProof(secret, first, second) + return hmac.Equal(expected, proof) +} diff --git a/internal/protocol/platform.go b/internal/protocol/platform.go new file mode 100644 --- /dev/null +++ b/internal/protocol/platform.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package protocol + +import ( + "runtime" + + "mrdimidium/mirum/internal/protocol/pb" +) + +var osMap = map[string]pb.Os{ + "linux": pb.Os_OS_LINUX, + "darwin": pb.Os_OS_DARWIN, + "windows": pb.Os_OS_WINDOWS, + "freebsd": pb.Os_OS_FREEBSD, + "openbsd": pb.Os_OS_OPENBSD, + "netbsd": pb.Os_OS_NETBSD, + "dragonfly": pb.Os_OS_DRAGONFLY, + "illumos": pb.Os_OS_ILLUMOS, + "solaris": pb.Os_OS_SOLARIS, + "aix": pb.Os_OS_AIX, + "plan9": pb.Os_OS_PLAN9, + "android": pb.Os_OS_ANDROID, + "ios": pb.Os_OS_IOS, + "js": pb.Os_OS_JS, + "wasip1": pb.Os_OS_WASIP1, +} + +var archMap = map[string]pb.Arch{ + "amd64": pb.Arch_ARCH_AMD64, + "arm64": pb.Arch_ARCH_ARM64, + "386": pb.Arch_ARCH_386, + "arm": pb.Arch_ARCH_ARM, + "riscv64": pb.Arch_ARCH_RISCV64, + "ppc64le": pb.Arch_ARCH_PPC64LE, + "ppc64": pb.Arch_ARCH_PPC64, + "s390x": pb.Arch_ARCH_S390X, + "mips64le": pb.Arch_ARCH_MIPS64LE, + "mips64": pb.Arch_ARCH_MIPS64, + "mipsle": pb.Arch_ARCH_MIPSLE, + "mips": pb.Arch_ARCH_MIPS, + "loong64": pb.Arch_ARCH_LOONG64, + "wasm": pb.Arch_ARCH_WASM, +} + +func DetectOs() pb.Os { + if v, ok := osMap[runtime.GOOS]; ok { + return v + } + return pb.Os_OS_UNSPECIFIED +} + +func DetectArch() pb.Arch { + if v, ok := archMap[runtime.GOARCH]; ok { + return v + } + return pb.Arch_ARCH_UNSPECIFIED +} diff --git a/internal/protocol/version.go b/internal/protocol/version.go new file mode 100644 --- /dev/null +++ b/internal/protocol/version.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package protocol + +import ( + "fmt" + "strconv" + "strings" + + "mrdimidium/mirum/internal/protocol/pb" +) + +// Set via -ldflags "-X mrdimidium/mirum/internal/protocol.raw=0.1.0" +var raw string + +var ( + Major uint32 + Minor uint32 + Patch uint32 +) + +func init() { + parts := strings.SplitN(strings.TrimSpace(raw), ".", 3) + + if len(parts) == 3 { + Major = parseUint(parts[0]) + Minor = parseUint(parts[1]) + Patch = parseUint(parts[2]) + } +} + +func VersionString() string { + return fmt.Sprintf("%d.%d.%d", Major, Minor, Patch) +} + +func VersionProto() *pb.Version { + return &pb.Version{Major: Major, Minor: Minor, Patch: Patch} +} + +func parseUint(s string) uint32 { + n, _ := strconv.ParseUint(s, 10, 32) + return uint32(n) +} diff --git a/internal/supervisor/supervisor.go b/internal/supervisor/supervisor.go index 2e99265..f6fb619 100644 --- a/internal/supervisor/supervisor.go +++ b/internal/supervisor/supervisor.go @@ -33,8 +33,8 @@ type Supervisor interface { // Detect returns a Supervisor for the current platform. func Detect() Supervisor { - if n := detectSystemd(); n != nil { - return n + if detectSystemd() { + return &systemd{} } return &noop{} } diff --git a/internal/supervisor/systemd.go b/internal/supervisor/systemd.go index 88f1f94..f17307c 100644 --- a/internal/supervisor/systemd.go +++ b/internal/supervisor/systemd.go @@ -5,6 +5,7 @@ package supervisor import ( "context" + "log/slog" "os" "os/signal" "strconv" @@ -16,11 +17,8 @@ import ( type systemd struct{} -func detectSystemd() Supervisor { - if os.Getenv("NOTIFY_SOCKET") == "" { - return nil - } - return &systemd{} +func detectSystemd() bool { + return os.Getenv("NOTIFY_SOCKET") != "" } func (*systemd) WaitForStop(ctx context.Context) context.Context { @@ -30,11 +28,15 @@ func (*systemd) WaitForStop(ctx context.Context) context.Context { } func (*systemd) Ready() { - daemon.SdNotify(false, daemon.SdNotifyReady) + if _, err := daemon.SdNotify(false, daemon.SdNotifyReady); err != nil { + slog.Warn("sd_notify ready", "err", err) + } } func (*systemd) Stopping() { - daemon.SdNotify(false, daemon.SdNotifyStopping) + if _, err := daemon.SdNotify(false, daemon.SdNotifyStopping); err != nil { + slog.Warn("sd_notify stopping", "err", err) + } } func (*systemd) StartWatchdog() { @@ -48,7 +50,10 @@ func (*systemd) StartWatchdog() { } interval := time.Duration(usec) * time.Microsecond / 2 for { - daemon.SdNotify(false, daemon.SdNotifyWatchdog) + if _, err := daemon.SdNotify(false, daemon.SdNotifyWatchdog); err != nil { + slog.Warn("sd_notify watchdog", "err", err) + return + } time.Sleep(interval) } } diff --git a/pkg/mirumd.yaml b/pkg/mirumd.yaml index 7116877..93afcca 100644 --- a/pkg/mirumd.yaml +++ b/pkg/mirumd.yaml @@ -1,7 +1,8 @@ # Copyright (c) 2026 Nikolay Govorov # SPDX-License-Identifier: AGPL-3.0-or-later -address: :2026 +grpc_addr: :2026 +www_addr: :3000 secret: "" token: "" script: .mirum/main.star diff --git a/proto/mirum.proto b/proto/mirum.proto new file mode 100644 --- /dev/null +++ b/proto/mirum.proto @@ -0,0 +1,170 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +syntax = "proto3"; + +package mirum; + +option go_package = "internal/protocol/pb"; + +import "google/protobuf/timestamp.proto"; + +service Mirum { + // Handshake performs mutual authentication via HMAC-SHA256 challenge-response. + // + // Step 1 (W→S): WorkerChallenge — worker sends random nonce. + // Step 2 (S→W): ServerChallenge — server sends nonce + HMAC(secret, w_nonce || s_nonce). + // Step 3 (W→S): WorkerProof — worker sends HMAC(secret, s_nonce || w_nonce) + metadata. + // Step 4 (S→W): ServerResult — server accepts or rejects. + rpc Handshake(stream HandshakeIn) returns (stream HandshakeOut); +} + +message Version { + uint32 major = 1; + uint32 minor = 2; + uint32 patch = 3; +} + +enum Os { + OS_UNSPECIFIED = 0; + + // Major + OS_LINUX = 1; + OS_DARWIN = 2; + OS_WINDOWS = 3; + + // BSD family + OS_FREEBSD = 4; + OS_OPENBSD = 5; + OS_NETBSD = 6; + OS_DRAGONFLY = 7; + + // Unix / POSIX + OS_ILLUMOS = 8; + OS_SOLARIS = 9; + OS_AIX = 10; + OS_HURD = 11; + + // Research / exotic + OS_PLAN9 = 12; + OS_HAIKU = 13; + OS_REDOX = 14; + OS_FUCHSIA = 15; + OS_SERENITY = 16; + + // Embedded / real-time + OS_ZEPHYR = 17; + OS_NUTTX = 18; + + // Go-supported + OS_ANDROID = 19; + OS_IOS = 20; + OS_JS = 21; + OS_WASIP1 = 22; +} + +enum Arch { + ARCH_UNSPECIFIED = 0; + + // Common + ARCH_386 = 1; + ARCH_ARM = 2; + ARCH_AMD64 = 3; + ARCH_ARM64 = 4; + + // RISC-V + ARCH_RISCV64 = 5; + ARCH_RISCV32 = 6; + + // Power + ARCH_PPC = 7; + ARCH_PPC64 = 8; + ARCH_PPC64LE = 9; + + // IBM + ARCH_S390 = 10; + ARCH_S390X = 11; + + // MIPS + ARCH_MIPS64LE = 12; + ARCH_MIPS64 = 13; + ARCH_MIPSLE = 14; + ARCH_MIPS = 15; + + // Chinese + ARCH_LOONG64 = 16; + ARCH_SW64 = 17; + + // SPARC + ARCH_SPARC64 = 18; + ARCH_SPARC = 19; + + // Other + ARCH_ALPHA = 20; + ARCH_HPPA = 21; + ARCH_M68K = 22; + ARCH_SH4 = 23; + ARCH_IA64 = 24; + ARCH_WASM = 25; + ARCH_XTENSA = 26; + ARCH_ARC = 27; + ARCH_CSKY = 28; + ARCH_HEXAGON = 29; + ARCH_MICROBLAZE = 30; + ARCH_NIOS2 = 31; + ARCH_OPENRISC = 32; +} + +// Worker → Server +message HandshakeIn { + oneof step { + WorkerChallenge worker_challenge = 1; // step 1 + WorkerProof worker_proof = 2; // step 3 + } +} + +// Server → Worker +message HandshakeOut { + oneof step { + ServerChallenge server_challenge = 1; // step 2 + ServerResult server_result = 2; // step 4 + } +} + +// Step 1: Worker generates a random nonce and sends it to the server. +message WorkerChallenge { + bytes nonce = 1; +} + +// Step 2: Server generates its own nonce and proves it knows the secret. +message ServerChallenge { + bytes nonce = 1; + bytes proof = 2; // HMAC-SHA256(secret, worker_nonce || server_nonce) +} + +// Step 3: Worker proves it knows the secret and sends metadata. +// Metadata is sent only after the server is verified. +message WorkerProof { + bytes proof = 1; // HMAC-SHA256(secret, server_nonce || worker_nonce) + + bytes id = 2; + string name = 3; + Version version = 4; + + Os os = 5; + Arch arch = 6; + string runtime = 7; + + google.protobuf.Timestamp worker_time = 8; +} + +// Step 4: Server accepts or rejects the worker. +message ServerResult { + // A successful handshake doesn't mean the worker can run: + // the versions may be incompatible, the clocks may be out of sync, + // server limits may have been exceeded, or something else entirely. + optional string error = 1; + + Version server_version = 2; + google.protobuf.Timestamp server_time = 3; +} |
