aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat
-rw-r--r--.github/workflows/build.yml34+13 −21
-rw-r--r--.gitignore3+3 −0
-rw-r--r--Taskfile.yml38+28 −10
-rw-r--r--cmd/mirumd/main.go151+139 −12
-rw-r--r--cmd/mirumw/main.go195+179 −16
-rw-r--r--go.mod9+8 −1
-rw-r--r--go.sum14+14 −0
-rw-r--r--internal/protocol/hmac.go31+31 −0
-rw-r--r--internal/protocol/platform.go59+59 −0
-rw-r--r--internal/protocol/version.go44+44 −0
-rw-r--r--internal/supervisor/supervisor.go4+2 −2
-rw-r--r--internal/supervisor/systemd.go21+13 −8
-rw-r--r--pkg/mirumd.yaml3+2 −1
-rw-r--r--proto/mirum.proto170+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;
+}