Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
build/
bin/
bin/
release/
11 changes: 11 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 24 additions & 5 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 == "" {
Expand Down
34 changes: 25 additions & 9 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,30 +304,40 @@ 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) {
cfg := Agent{
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
}
Expand All @@ -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")
}
})
}
}
Expand Down
51 changes: 51 additions & 0 deletions scripts/make-release.sh
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 4 additions & 0 deletions service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading