diff --git a/.gitignore b/.gitignore index 4e9c87a..6dff540 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ build/ -bin/ \ No newline at end of file +bin/ +release/ \ No newline at end of file diff --git a/Makefile b/Makefile index c79997c..1a2d688 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,17 @@ build-static: ## Build garm-agent statically test: go test -race -count=1 -v ./... +##@ Release +.PHONY: create-release-files release +create-release-files: + ./scripts/make-release.sh + +release: build-static create-release-files ## Create a release + +.PHONY: clean +clean: ## Clean up build artifacts + @rm -rf ./bin ./build ./release + ##@ Build Dependencies ## Location to install dependencies to diff --git a/config/config.go b/config/config.go index cdd92cd..6ccb9c2 100644 --- a/config/config.go +++ b/config/config.go @@ -72,6 +72,12 @@ type Agent struct { // Typically whis will be https://garm.example.com/agent. ServerURL string `toml:"server_url"` Token string `toml:"token"` + // ForceInsecure allows connecting to GARM over a plaintext ws:// / http:// + // URL. This sends the agent token in cleartext on every (re)connect and is + // intended only for local development/testing. A plaintext transport + // force-disables the remote shell (a shell must never be exposed over + // cleartext); force_insecure with an https URL still allows the shell. + ForceInsecure bool `toml:"force_insecure"` // WorkDir is the folder in which the runner will execute workflows. // In the case of github, it is expected that the WorkDir is also the installation // dir of the runner. @@ -113,17 +119,30 @@ func (a *Agent) Validate() error { return fmt.Errorf("invalid server_url: %w", err) } // The agent authenticates to GARM with a bearer JWT sent on the websocket - // handshake, so the transport MUST be TLS. Accept only https/wss and reject - // cleartext ws/http. Canonicalize wss->https: the websocket client only - // upgrades the dial to TLS when the scheme is "https" (a bare "wss" would be - // dialed as cleartext ws), and the shell gate keys off this scheme too. + // handshake, so by default the transport MUST be TLS. Accept only https/wss + // and reject cleartext ws/http unless force_insecure is set. Canonicalize + // wss->https: the websocket client only upgrades the dial to TLS when the + // scheme is "https" (a bare "wss" would be dialed as cleartext ws), and the + // shell gate keys off this scheme too. switch parsedURL.Scheme { case "https": case "wss": parsedURL.Scheme = "https" a.ServerURL = parsedURL.String() + case "ws", "http": + if !a.ForceInsecure { + return fmt.Errorf("server_url must use https or wss to protect the agent token, got %q; set force_insecure to allow plaintext transport", parsedURL.Scheme) + } default: - return fmt.Errorf("server_url must use https or wss to protect the agent token, got %q", parsedURL.Scheme) + return fmt.Errorf("unsupported server_url scheme %q (use https/wss, or ws/http with force_insecure)", parsedURL.Scheme) + } + + // A shell must never be exposed over cleartext. Disable it when the + // (effective) transport is not TLS — reachable only via force_insecure — + // rather than failing the whole config. https/wss both resolve to https, so + // force_insecure with an https URL still allows the shell. + if parsedURL.Scheme != "https" { + a.EnableShell = false } if a.Token == "" { diff --git a/config/config_test.go b/config/config_test.go index f6c3268..41d3f39 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -304,15 +304,23 @@ func TestNewConfigFileNotFound(t *testing.T) { func TestServerURLTLSEnforcement(t *testing.T) { const agentToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc19hZ2VudCI6dHJ1ZSwiZm9yZ2VfdHlwZSI6ImdpdGh1YiJ9.dummysignature" tests := []struct { - name string - serverURL string - wantErr bool - wantURL string // expected normalized ServerURL when no error + name string + serverURL string + forceInsecure bool + enableShell bool + wantErr string // substring to match; "" means expect no error + wantURL string // expected normalized ServerURL when no error + shellForcedOff bool // when true, EnableShell must be false after Validate }{ {name: "https accepted unchanged", serverURL: "https://garm.example.com/agent", wantURL: "https://garm.example.com/agent"}, {name: "wss normalized to https", serverURL: "wss://garm.example.com/agent", wantURL: "https://garm.example.com/agent"}, - {name: "ws rejected", serverURL: "ws://garm.example.com/agent", wantErr: true}, - {name: "http rejected", serverURL: "http://garm.example.com/agent", wantErr: true}, + {name: "ws rejected without force_insecure", serverURL: "ws://garm.example.com/agent", wantErr: "https or wss"}, + {name: "http rejected without force_insecure", serverURL: "http://garm.example.com/agent", wantErr: "https or wss"}, + {name: "ws allowed with force_insecure", serverURL: "ws://garm.example.com/agent", forceInsecure: true, wantURL: "ws://garm.example.com/agent"}, + {name: "http allowed with force_insecure", serverURL: "http://garm.example.com/agent", forceInsecure: true, wantURL: "http://garm.example.com/agent"}, + {name: "force_insecure over plaintext disables enable_shell", serverURL: "ws://garm.example.com/agent", forceInsecure: true, enableShell: true, wantURL: "ws://garm.example.com/agent", shellForcedOff: true}, + {name: "force_insecure over https keeps enable_shell", serverURL: "https://garm.example.com/agent", forceInsecure: true, enableShell: true, wantURL: "https://garm.example.com/agent"}, + {name: "unsupported scheme rejected", serverURL: "ftp://garm.example.com/agent", wantErr: "unsupported server_url scheme"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -320,14 +328,16 @@ func TestServerURLTLSEnforcement(t *testing.T) { ServerURL: tt.serverURL, Token: agentToken, RunnerExecArgs: []string{"/opt/runner/run.sh"}, + ForceInsecure: tt.forceInsecure, + EnableShell: tt.enableShell, } err := cfg.Validate() - if tt.wantErr { + if tt.wantErr != "" { if err == nil { t.Fatalf("expected Validate to reject %q, got nil", tt.serverURL) } - if !contains(err.Error(), "https or wss") { - t.Errorf("expected TLS-required error, got %q", err.Error()) + if !contains(err.Error(), tt.wantErr) { + t.Errorf("expected error containing %q, got %q", tt.wantErr, err.Error()) } return } @@ -337,6 +347,12 @@ func TestServerURLTLSEnforcement(t *testing.T) { if cfg.ServerURL != tt.wantURL { t.Errorf("expected normalized ServerURL %q, got %q", tt.wantURL, cfg.ServerURL) } + if tt.shellForcedOff && cfg.EnableShell { + t.Errorf("expected EnableShell to be forced off over plaintext") + } + if !tt.shellForcedOff && tt.enableShell && !cfg.EnableShell { + t.Errorf("expected EnableShell to remain enabled over TLS") + } }) } } diff --git a/scripts/make-release.sh b/scripts/make-release.sh new file mode 100755 index 0000000..b2507fd --- /dev/null +++ b/scripts/make-release.sh @@ -0,0 +1,51 @@ +#!/bin/bash +set -e + +VERSION=$(git describe --tags --match='v[0-9]*' --dirty --always) +RELEASE="$PWD/release" + +mkdir -p "$RELEASE" + +if [ -n "${GARM_REF:-}" ]; then + VERSION=$(git describe --tags --match='v[0-9]*' --always "$GARM_REF") +fi + +BUILD_DIR="build/$VERSION" +if [ ! -d "$BUILD_DIR" ]; then + # The container build computes its own VERSION (e.g. a clean checkout inside + # the build image drops the -dirty suffix the host tree carries), so the + # recomputed path may not exist. Fall back to the sole build output dir when + # there is exactly one, and adopt its version for the artifact names. + dirs=(build/*/) + if [ ${#dirs[@]} -eq 1 ] && [ -d "${dirs[0]}" ]; then + BUILD_DIR="${dirs[0]%/}" + VERSION="$(basename "$BUILD_DIR")" + else + echo "missing $BUILD_DIR (run 'make build-static' first)" + exit 1 + fi +fi + +echo "Packaging garm-agent release $VERSION" + +ARCHES=("amd64" "arm64") + +# Ship the raw binaries (uncompressed) so they can be downloaded and run +# directly, each alongside a .sha256 checksum. +emit() { + local src="$1" name="$2" + if [ ! -f "$src" ]; then + echo "missing $src" + exit 1 + fi + cp "$src" "$RELEASE/$name" + (cd "$RELEASE" && sha256sum "$name" > "$name.sha256") +} + +for arch in "${ARCHES[@]}"; do + emit "$BUILD_DIR/linux/$arch/garm-agent" "garm-agent-linux-$arch-$VERSION" + emit "$BUILD_DIR/windows/$arch/garm-agent.exe" "garm-agent-windows-$arch-$VERSION.exe" +done + +echo "Release artifacts written to $RELEASE:" +ls -1 "$RELEASE" diff --git a/service/service.go b/service/service.go index a932d77..f8ac48f 100644 --- a/service/service.go +++ b/service/service.go @@ -52,6 +52,10 @@ func NewService(ctx context.Context, cfg *config.Agent) (*Service, error) { } } + if parsed, _ := url.Parse(cfg.ServerURL); parsed != nil && parsed.Scheme != "https" { + slog.WarnContext(ctx, "connecting to GARM over plaintext transport (force_insecure): the agent token is sent in cleartext", "server_url", cfg.ServerURL) + } + return &Service{ ctx: ctx, cfg: cfg,