package main
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"flag"
"fmt"
"net"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/hetznercloud/hcloud-go/v2/hcloud"
"golang.org/x/crypto/ssh"
)
const stateFileName = ".hetzner-runner-state.json"
const selfDestructScript = `#cloud-config
write_files:
- path: /root/.hcloud-token
permissions: '0600'
content: '%s'
runcmd:
- [ bash, -c, "echo 'curl -sf -X DELETE -H \"Authorization: Bearer $(cat /root/.hcloud-token)\" https://api.hetzner.cloud/v1/servers/$(curl -sf http://169.254.169.254/hetzner/v1/metadata/instance-id)' | at now + %d hours" ]
`
type state struct {
ServerID int64 `json:"server_id"`
ServerName string `json:"server_name"`
SSHKeyID int64 `json:"ssh_key_id"`
IPv4 string `json:"ipv4"`
PrivKeyPath string `json:"priv_key_path"`
}
type config struct {
serverType string
image string
location string
namePrefix string
maxLifetime time.Duration
sshTimeout time.Duration
}
func defaultConfig() config {
return config{
serverType: envOr("HETZNER_SERVER_TYPE", "cpx51"),
image: envOr("HETZNER_IMAGE", "ubuntu-24.04"),
location: envOr("HETZNER_LOCATION", "hil"),
namePrefix: envOr("HETZNER_NAME_PREFIX", "chromium-build"),
maxLifetime: 10 * time.Hour,
sshTimeout: 3 * time.Minute,
}
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func dlog(format string, args ...any) {
path := filepath.Join(envOr("HOME", "."), "hetzner-runner-debug.log")
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
line := fmt.Sprintf(format, args...)
fmt.Fprintf(f, "%s pid=%d %s\n", time.Now().UTC().Format(time.RFC3339Nano), os.Getpid(), line)
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: hetzner-runner <up|down|run> [flags]")
os.Exit(2)
}
dlog("main: invoked with args=%v", os.Args[1:])
defer dlog("main: exiting normally, args=%v", os.Args[1:])
token := os.Getenv("HCLOUD_TOKEN")
if token == "" {
fatal("HCLOUD_TOKEN must be set in the environment")
}
client := hcloud.NewClient(hcloud.WithToken(token))
cfg := defaultConfig()
switch os.Args[1] {
case "up":
fs := flag.NewFlagSet("up", flag.ExitOnError)
bindConfig(fs, &cfg)
fs.Parse(os.Args[2:])
dlog("up: starting, type=%s location=%s", cfg.serverType, cfg.location)
st, err := up(context.Background(), client, token, cfg)
if err != nil {
dlog("up: FAILED: %v", err)
fatal("up: %v", err)
}
saveState(st)
dlog("up: succeeded, server_id=%d ip=%s", st.ServerID, st.IPv4)
fmt.Printf("server ready: id=%d ip=%s\n", st.ServerID, st.IPv4)
case "down":
st, err := loadState()
if err != nil {
fatal("down: %v (nothing to tear down, or run from the same directory as `up`)", err)
}
dlog("down: starting, server_id=%d", st.ServerID)
if err := down(context.Background(), client, st); err != nil {
dlog("down: FAILED: %v", err)
fatal("down: %v", err)
}
os.Remove(stateFileName)
dlog("down: succeeded, server_id=%d", st.ServerID)
fmt.Println("torn down")
case "run":
fs := flag.NewFlagSet("run", flag.ExitOnError)
bindConfig(fs, &cfg)
scriptPath, envNames := bindPayloadFlags(fs)
fs.Parse(os.Args[2:])
payload := buildPayload("run", scriptPath, envNames, fs.Args())
os.Exit(runLifecycle(client, token, cfg, payload))
case "exec":
fs := flag.NewFlagSet("exec", flag.ExitOnError)
scriptPath, envNames := bindPayloadFlags(fs)
uploads := &stringSlice{}
fs.Var(uploads, "upload", "local=remote file to copy to the box before running (repeatable)")
downloads := &stringSlice{}
fs.Var(downloads, "download", "remote=local file to copy from the box after running, only if it succeeded (repeatable)")
fs.Parse(os.Args[2:])
payload := buildPayload("exec", scriptPath, envNames, fs.Args())
st, err := loadState()
if err != nil {
fatal("exec: %v (run `up` first)", err)
}
os.Exit(runExecCommand(client, st, payload, *uploads, *downloads, scriptPath))
default:
fmt.Fprintln(os.Stderr, "usage: hetzner-runner <up|down|run> [flags]")
os.Exit(2)
}
}
func bindPayloadFlags(fs *flag.FlagSet) (scriptPath *string, envNames *stringSlice) {
scriptPath = fs.String("script", "", "local script to pipe to `bash -s` on the remote box, instead of a -- command")
envNames = &stringSlice{}
fs.Var(envNames, "env", "name of an env var to read from this process's environment and forward to the remote script (repeatable)")
return scriptPath, envNames
}
func buildPayload(subcmd string, scriptPath *string, envNames *stringSlice, remainder []string) remotePayload {
env := map[string]string{}
for _, name := range *envNames {
v, ok := os.LookupEnv(name)
if !ok {
fatal("%s: -env %s: not set in this process's environment", subcmd, name)
}
env[name] = v
}
if *scriptPath != "" {
script, err := os.ReadFile(*scriptPath)
if err != nil {
fatal("%s: reading -script %q: %v", subcmd, *scriptPath, err)
}
return remotePayload{script: string(script), env: env}
}
if len(remainder) == 0 {
fatal("%s: usage: hetzner-runner %s [flags] -- <remote command...> (or -script <path>)", subcmd, subcmd)
}
return remotePayload{command: strings.Join(remainder, " "), env: env}
}
func bindConfig(fs *flag.FlagSet, cfg *config) {
fs.StringVar(&cfg.serverType, "type", cfg.serverType, "Hetzner server type")
fs.StringVar(&cfg.image, "image", cfg.image, "OS image")
fs.StringVar(&cfg.location, "location", cfg.location, "Hetzner location")
fs.DurationVar(&cfg.maxLifetime, "max-lifetime", cfg.maxLifetime, "failsafe: server self-deletes after this long even if `down` never runs")
fs.DurationVar(&cfg.sshTimeout, "ssh-timeout", cfg.sshTimeout, "how long to wait for SSH to come up")
}
type remotePayload struct {
command string
script string
env map[string]string
}
type stringSlice []string
func (s *stringSlice) String() string { return strings.Join(*s, ",") }
func (s *stringSlice) Set(v string) error { *s = append(*s, v); return nil }
func withHeartbeat(label string, fn func() (int, error)) (int, error) {
stop := make(chan struct{})
defer close(stop)
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
dlog("heartbeat: still executing %s", label)
}
}
}()
return fn()
}
func runExecCommand(client *hcloud.Client, st *state, payload remotePayload, uploads, downloads stringSlice, scriptPath *string) (code int) {
succeeded := false
defer func() {
if succeeded {
return
}
r := recover()
dlog("exec: attempting cleanup after error (recovered_panic=%v)", r != nil)
downCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := down(downCtx, client, st); err != nil {
dlog("exec: cleanup-on-error teardown FAILED: %v", err)
fmt.Fprintf(os.Stderr, "WARNING: cleanup-on-error teardown failed, server %d may still be running and billing: %v\n", st.ServerID, err)
fmt.Fprintf(os.Stderr, " manual cleanup: hcloud server delete %d\n", st.ServerID)
} else {
os.Remove(stateFileName)
dlog("exec: cleanup-on-error teardown succeeded, server_id=%d", st.ServerID)
fmt.Fprintln(os.Stderr, "note: tore down the server after an error in this step")
}
if r != nil {
panic(r)
}
}()
payloadDesc := payload.command
if payload.script != "" {
payloadDesc = fmt.Sprintf("-script %s (%d bytes)", *scriptPath, len(payload.script))
}
dlog("exec: starting against server_id=%d: %s", st.ServerID, payloadDesc)
for _, u := range uploads {
local, remote, ok := strings.Cut(u, "=")
if !ok {
fmt.Fprintf(os.Stderr, "exec: -upload %q: expected local=remote\n", u)
return 2
}
dlog("exec: uploading %s -> %s", local, remote)
if err := uploadFile(st, local, remote); err != nil {
dlog("exec: upload FAILED (Go-level error, cleaning up): %v", err)
fmt.Fprintf(os.Stderr, "exec: uploading %s: %v\n", local, err)
return 1
}
}
var runErr error
code, runErr = withHeartbeat(payloadDesc, func() (int, error) {
if payload.script != "" {
return sshExecScript(st, payload.script, payload.env)
}
return sshExec(st, payload.command, payload.env)
})
if runErr != nil {
dlog("exec: Go-level FAILURE (cleaning up): %v", runErr)
fmt.Fprintf(os.Stderr, "remote command failed: %v\n", runErr)
return 1
}
if code == 0 {
for _, d := range downloads {
remote, local, ok := strings.Cut(d, "=")
if !ok {
fmt.Fprintf(os.Stderr, "exec: -download %q: expected remote=local\n", d)
return 2
}
dlog("exec: downloading %s -> %s", remote, local)
if err := downloadFile(st, remote, local); err != nil {
dlog("exec: download FAILED (Go-level error, cleaning up): %v", err)
fmt.Fprintf(os.Stderr, "exec: downloading %s: %v\n", remote, err)
return 1
}
}
}
succeeded = true
dlog("exec: finished, exit code=%d", code)
return code
}
func runLifecycle(client *hcloud.Client, token string, cfg config, payload remotePayload) (exitCode int) {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
go func() {
<-ctx.Done()
dlog("runLifecycle: context cancelled (signal received or normal exit racing this check)")
}()
dlog("runLifecycle: starting up, type=%s location=%s", cfg.serverType, cfg.location)
st, err := up(ctx, client, token, cfg)
if err != nil {
dlog("runLifecycle: up FAILED: %v", err)
fatal("up: %v", err)
}
saveState(st)
dlog("runLifecycle: up succeeded, server_id=%d ip=%s", st.ServerID, st.IPv4)
defer func() {
dlog("runLifecycle: tearing down server_id=%d", st.ServerID)
downCtx, downCancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer downCancel()
if err := down(downCtx, client, st); err != nil {
dlog("runLifecycle: teardown FAILED: %v", err)
fmt.Fprintf(os.Stderr, "WARNING: teardown failed, server %d (%s) may still be running and billing: %v\n", st.ServerID, st.IPv4, err)
fmt.Fprintf(os.Stderr, " manual cleanup: hcloud server delete %d\n", st.ServerID)
return
}
os.Remove(stateFileName)
dlog("runLifecycle: teardown succeeded, server_id=%d", st.ServerID)
fmt.Println("torn down")
}()
var code int
var label string
if payload.script != "" {
label = fmt.Sprintf("script (%d bytes)", len(payload.script))
dlog("runLifecycle: running script (%d bytes, %d env vars)", len(payload.script), len(payload.env))
fmt.Printf("server ready: id=%d ip=%s, running script (%d bytes, %d env vars forwarded)\n", st.ServerID, st.IPv4, len(payload.script), len(payload.env))
} else {
label = payload.command
dlog("runLifecycle: running command: %s", payload.command)
fmt.Printf("server ready: id=%d ip=%s, running: %s\n", st.ServerID, st.IPv4, payload.command)
}
code, err = withHeartbeat(label, func() (int, error) {
if payload.script != "" {
return sshExecScript(st, payload.script, payload.env)
}
return sshExec(st, payload.command, payload.env)
})
if err != nil {
dlog("runLifecycle: remote command FAILED: %v", err)
fmt.Fprintf(os.Stderr, "remote command failed: %v\n", err)
} else {
dlog("runLifecycle: remote command finished, exit code=%d", code)
}
return code
}
func up(ctx context.Context, client *hcloud.Client, token string, cfg config) (*state, error) {
pub, privPath, err := ensureEphemeralKey()
if err != nil {
return nil, fmt.Errorf("generating ephemeral ssh key: %w", err)
}
name := fmt.Sprintf("%s-%s", cfg.namePrefix, time.Now().Format("20060102-150405"))
keyResult, _, err := client.SSHKey.Create(ctx, hcloud.SSHKeyCreateOpts{
Name: name,
PublicKey: pub,
})
if err != nil {
return nil, fmt.Errorf("uploading ephemeral ssh key: %w", err)
}
serverType, _, err := client.ServerType.GetByName(ctx, cfg.serverType)
if err != nil || serverType == nil {
return nil, fmt.Errorf("looking up server type %q: %w", cfg.serverType, err)
}
image, _, err := client.Image.GetByNameAndArchitecture(ctx, cfg.image, serverType.Architecture)
if err != nil || image == nil {
return nil, fmt.Errorf("looking up image %q: %w", cfg.image, err)
}
location, _, err := client.Location.GetByName(ctx, cfg.location)
if err != nil || location == nil {
return nil, fmt.Errorf("looking up location %q: %w", cfg.location, err)
}
userData := fmt.Sprintf(selfDestructScript, token, int(cfg.maxLifetime.Hours()))
result, _, err := client.Server.Create(ctx, hcloud.ServerCreateOpts{
Name: name,
ServerType: serverType,
Image: image,
Location: location,
SSHKeys: []*hcloud.SSHKey{keyResult},
UserData: userData,
})
if err != nil {
return nil, fmt.Errorf("creating server: %w", err)
}
if err := waitForAction(ctx, client, result.Action); err != nil {
return nil, fmt.Errorf("waiting for server creation: %w", err)
}
st := &state{
ServerID: result.Server.ID,
ServerName: name,
SSHKeyID: keyResult.ID,
IPv4: result.Server.PublicNet.IPv4.IP.String(),
PrivKeyPath: privPath,
}
if err := waitForSSH(st, cfg.sshTimeout); err != nil {
_ = down(ctx, client, st)
return nil, fmt.Errorf("waiting for ssh: %w", err)
}
return st, nil
}
func down(ctx context.Context, client *hcloud.Client, st *state) error {
var errs []string
serverDeleted := true
if st.ServerID != 0 {
if _, _, err := client.Server.DeleteWithResult(ctx, &hcloud.Server{ID: st.ServerID}); err != nil {
errs = append(errs, fmt.Sprintf("delete server %d: %v", st.ServerID, err))
serverDeleted = false
}
}
if st.SSHKeyID != 0 {
if _, err := client.SSHKey.Delete(ctx, &hcloud.SSHKey{ID: st.SSHKeyID}); err != nil {
errs = append(errs, fmt.Sprintf("delete ssh key %d: %v", st.SSHKeyID, err))
}
}
if st.PrivKeyPath != "" && serverDeleted {
os.Remove(st.PrivKeyPath)
}
if len(errs) > 0 {
return fmt.Errorf("%s", strings.Join(errs, "; "))
}
return nil
}
func waitForAction(ctx context.Context, client *hcloud.Client, action *hcloud.Action) error {
progress, errs := client.Action.WatchProgress(ctx, action)
for {
select {
case <-progress:
case err, ok := <-errs:
if !ok {
return nil
}
if err != nil {
return err
}
}
}
}
func waitForSSH(st *state, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", net.JoinHostPort(st.IPv4, "22"), 5*time.Second)
if err == nil {
conn.Close()
time.Sleep(3 * time.Second)
if code, err := sshExec(st, "true", nil); err == nil && code == 0 {
return nil
} else {
lastErr = err
}
} else {
lastErr = err
}
time.Sleep(5 * time.Second)
}
return fmt.Errorf("timed out after %s, last error: %v", timeout, lastErr)
}
func sshExec(st *state, command string, env map[string]string) (int, error) {
session, client, err := sshSession(st, true)
if err != nil {
return -1, err
}
defer client.Close()
defer session.Close()
var buf strings.Builder
for k, v := range env {
fmt.Fprintf(&buf, "export %s=%s; ", k, shellQuote(v))
}
buf.WriteString(command)
session.Stdout = os.Stdout
session.Stderr = os.Stderr
return runSession(session, buf.String())
}
func uploadFile(st *state, localPath, remotePath string) error {
content, err := os.ReadFile(localPath)
if err != nil {
return err
}
return uploadBytes(st, content, remotePath)
}
func uploadBytes(st *state, content []byte, remotePath string) error {
session, client, err := sshSession(st, false)
if err != nil {
return err
}
defer client.Close()
defer session.Close()
stdin, err := session.StdinPipe()
if err != nil {
return err
}
var out bytes.Buffer
session.Stdout = &out
session.Stderr = &out
if err := session.Start(fmt.Sprintf("cat > %s", shellQuote(remotePath))); err != nil {
return err
}
if _, err := stdin.Write(content); err != nil {
return err
}
if err := stdin.Close(); err != nil {
return err
}
if err := session.Wait(); err != nil {
if exitErr, ok := err.(*ssh.ExitError); ok {
return fmt.Errorf("remote `cat > %s` exited %d: %s", remotePath, exitErr.ExitStatus(), out.String())
}
return err
}
return nil
}
func downloadFile(st *state, remotePath, localPath string) error {
session, client, err := sshSession(st, false)
if err != nil {
return err
}
defer client.Close()
defer session.Close()
if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil {
return fmt.Errorf("creating local directory for %s: %w", localPath, err)
}
f, err := os.Create(localPath)
if err != nil {
return err
}
defer f.Close()
var stderrBuf bytes.Buffer
session.Stdout = f
session.Stderr = &stderrBuf
if err := session.Run(fmt.Sprintf("cat %s", shellQuote(remotePath))); err != nil {
if exitErr, ok := err.(*ssh.ExitError); ok {
return fmt.Errorf("remote `cat %s` exited %d: %s", remotePath, exitErr.ExitStatus(), stderrBuf.String())
}
return err
}
return nil
}
func sshExecScript(st *state, script string, env map[string]string) (int, error) {
var buf strings.Builder
buf.WriteString("#!/usr/bin/env bash\n")
for k, v := range env {
fmt.Fprintf(&buf, "export %s=%s\n", k, shellQuote(v))
}
buf.WriteString(script)
remotePath := fmt.Sprintf("/root/.hetzner-runner-script-%d.sh", time.Now().UnixNano())
if err := uploadBytes(st, []byte(buf.String()), remotePath); err != nil {
return -1, fmt.Errorf("uploading script: %w", err)
}
code, err := sshExec(st, fmt.Sprintf("bash %s", shellQuote(remotePath)), nil)
if rmCode, rmErr := sshExec(st, fmt.Sprintf("rm -f %s", shellQuote(remotePath)), nil); rmErr == nil && rmCode != 0 {
fmt.Fprintf(os.Stderr, "warning: failed to remove remote script %s\n", remotePath)
}
return code, err
}
func shellQuote(v string) string {
return "'" + strings.ReplaceAll(v, "'", `'\''`) + "'"
}
func sshSession(st *state, wantPTY bool) (*ssh.Session, *ssh.Client, error) {
key, err := os.ReadFile(st.PrivKeyPath)
if err != nil {
return nil, nil, err
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return nil, nil, err
}
sshCfg := &ssh.ClientConfig{
User: "root",
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
client, err := ssh.Dial("tcp", net.JoinHostPort(st.IPv4, "22"), sshCfg)
if err != nil {
return nil, nil, err
}
session, err := client.NewSession()
if err != nil {
client.Close()
return nil, nil, err
}
if wantPTY {
modes := ssh.TerminalModes{
ssh.ECHO: 0,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
}
if err := session.RequestPty("xterm", 40, 200, modes); err != nil {
session.Close()
client.Close()
return nil, nil, fmt.Errorf("requesting pty: %w", err)
}
}
return session, client, nil
}
func runSession(session *ssh.Session, command string) (int, error) {
if err := session.Run(command); err != nil {
if exitErr, ok := err.(*ssh.ExitError); ok {
return exitErr.ExitStatus(), nil
}
return -1, err
}
return 0, nil
}
func ensureEphemeralKey() (pubAuthorizedKeys string, privPath string, err error) {
dir, err := os.MkdirTemp("", "hetzner-runner-key-*")
if err != nil {
return "", "", err
}
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return "", "", err
}
sshPub, err := ssh.NewPublicKey(pub)
if err != nil {
return "", "", err
}
pkcs8, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return "", "", err
}
privPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})
privPath = filepath.Join(dir, "id_ed25519")
if err := os.WriteFile(privPath, privPEM, 0600); err != nil {
return "", "", err
}
return string(ssh.MarshalAuthorizedKey(sshPub)), privPath, nil
}
func saveState(st *state) {
f, err := os.Create(stateFileName)
if err != nil {
fatal("writing state file: %v", err)
}
defer f.Close()
json.NewEncoder(f).Encode(st)
}
func loadState() (*state, error) {
f, err := os.Open(stateFileName)
if err != nil {
return nil, err
}
defer f.Close()
var st state
if err := json.NewDecoder(f).Decode(&st); err != nil {
return nil, err
}
return &st, nil
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...)
os.Exit(1)
}