From 5266fcf926bbe9e716c9a0f4971380e6cff1874e Mon Sep 17 00:00:00 2001 From: Aaron Gibson Date: Tue, 12 May 2026 10:48:23 +0200 Subject: [PATCH 1/2] Adds Dockerfile.miniooni and a scheduler tool scheduler runs the command miniooni with arguments passed to the container, and then runs it again after a sampled delay. environment variables INTERVAL sets the mean delay, MAX_MULTIPLIER clamps the maximum delay, and the minimum delay is 10% of INTERVAL (hard-coded). Consent (--yes) is assumed for headless operation. --- Dockerfile.miniooni | 43 ++++++ internal/cmd/scheduler/scheduler.go | 213 ++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 Dockerfile.miniooni create mode 100644 internal/cmd/scheduler/scheduler.go diff --git a/Dockerfile.miniooni b/Dockerfile.miniooni new file mode 100644 index 000000000..e5c28a88d --- /dev/null +++ b/Dockerfile.miniooni @@ -0,0 +1,43 @@ +# builder +FROM golang:1.24.12 AS builder +RUN apt update && apt install -y --no-install-recommends \ + build-essential \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download +COPY . . + +ENV GOBIN=/go/bin +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + go install -v -ldflags '-s -w' ./internal/cmd/miniooni +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + go install -v -ldflags '-s -w' ./internal/cmd/scheduler + +# runtime - minimal Debian slim +FROM debian:bookworm-slim +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + bash \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# copy binaries +COPY --from=builder /go/bin/miniooni /usr/local/bin/miniooni +COPY --from=builder /go/bin/scheduler /usr/local/bin/scheduler +RUN chmod +x /usr/local/bin/miniooni /usr/local/bin/scheduler + +# runtime env defaults (overridable) +ENV INTERVAL=86400 \ + MAX_MULTIPLIER=4 \ + COUNTDOWN=false + +# scheduler runs miniooni and consents by default (--yes) +ENTRYPOINT ["/usr/local/bin/scheduler", "/usr/local/bin/miniooni", "--yes"] diff --git a/internal/cmd/scheduler/scheduler.go b/internal/cmd/scheduler/scheduler.go new file mode 100644 index 000000000..9c2c186d0 --- /dev/null +++ b/internal/cmd/scheduler/scheduler.go @@ -0,0 +1,213 @@ +// scheduler.go +// +// Build: go build -o scheduler scheduler.go +// Usage: INTERVAL=86400 MAX_MULTIPLIER=10 COUNTDOWN=true ./scheduler /path/to/cmd arg1 arg2 +package main + +import ( + "context" + crand "crypto/rand" + "encoding/binary" + "errors" + "flag" + "fmt" + mathrand "math/rand" + "math" + "os" + "os/exec" + "os/signal" + "strconv" + "syscall" + "time" +) + +func getenvInt(name string, def int) int { + if v, ok := os.LookupEnv(name); ok { + if i, err := strconv.Atoi(v); err == nil && i >= 0 { + return i + } + } + return def +} + +func getenvBool(name string, def bool) bool { + if v, ok := os.LookupEnv(name); ok { + if v == "1" || v == "true" || v == "TRUE" { + return true + } + if v == "0" || v == "false" || v == "FALSE" { + return false + } + } + return def +} + +func seedMathRand() { + var b [8]byte + if _, err := crand.Read(b[:]); err == nil { + seed := int64(binary.LittleEndian.Uint64(b[:])) + mathrand.Seed(seed) + } else { + mathrand.Seed(time.Now().UnixNano()) + } +} + +func expDelaySeconds(mean float64, maxMultiplier int) (int, error) { + // drop outliers and sample again + maxAllowed := mean * float64(maxMultiplier) + minAllowed := mean * 0.1 + for attempts := 0; attempts < 1_000_000; attempts++ { + u := mathrand.Float64() // [0,1) + if u == 0 { + u = math.SmallestNonzeroFloat64 + } + d := -mean * math.Log(u) // (-inf, 1] + if d <= maxAllowed && d >= minAllowed { + return int(d), nil + } + } + return 0, errors.New("failed to draw delay within allowed range") +} + +func runOnce(ctx context.Context, args []string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "no command supplied") + return 2 + } + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "start error: %v\n", err) + return 1 + } + err := cmd.Wait() + if err == nil { + return 0 + } + if ee, ok := err.(*exec.ExitError); ok { + if ws, ok := ee.Sys().(syscall.WaitStatus); ok { + return ws.ExitStatus() + } + } + fmt.Fprintf(os.Stderr, "process error: %v\n", err) + return 1 +} + +// print countdown; return true if cancelled +func countdownLoop(ctx context.Context, seconds int) bool { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + end := time.Now().Add(time.Duration(seconds) * time.Second) + for { + select { + case <-ctx.Done(): + fmt.Print("\r") + return true + case now := <-ticker.C: + rem := int(end.Sub(now).Seconds()) + if rem < 0 { + rem = 0 + } + fmt.Printf("\rNext run in %4ds ", rem) + if rem == 0 { + fmt.Print("\r") + return false + } + } + } +} + +func main() { + flag.Parse() + args := flag.Args() + interval := getenvInt("INTERVAL", 86400) + maxMul := getenvInt("MAX_MULTIPLIER", 10) + showCountdown := getenvBool("COUNTDOWN", false) + + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "usage: INTERVAL=... MAX_MULTIPLIER=... COUNTDOWN=true ./scheduler /path/to/cmd [args...]") + os.Exit(2) + } + + seedMathRand() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + for { + // create cancellable context for this run + ctx, cancel := context.WithCancel(context.Background()) + + // per-run signal watcher: only goroutine reading sigCh while this run is active + go func() { + select { + case sig := <-sigCh: + // forward first signal to the process group + _ = syscall.Kill(0, sig.(syscall.Signal)) + // cancel the run so runOnce sees ctx.Done() + cancel() + // escalate on subsequent signals + go func() { + for range sigCh { + _ = syscall.Kill(0, syscall.SIGKILL) + } + }() + case <-ctx.Done(): + // run finished normally; stop watching signals + } + }() + + // run the command; returns when child exits (normally or due to cancellation) + exitCode := runOnce(ctx, args) + + // ensure context is cancelled and watcher exits + cancel() + + // if program exited uncleanly, exit loop + // XXX: halts on program crash. should this save an error log and restart? + if exitCode != 0 { + fmt.Fprintf(os.Stderr, "exit: %d", exitCode) + break + } + + // compute next delay... + next, err := expDelaySeconds(float64(interval), maxMul) + if err != nil { + fmt.Fprintf(os.Stderr, "delay draw error: %v\n", err) + break + } + + // watch for signals + cdCtx, cdCancel := context.WithCancel(context.Background()) + go func() { + select { + case <-sigCh: + // cancel timers + cdCancel() + case <-cdCtx.Done(): + } + }() + + // display countdown until next invocation + if showCountdown { + cancelled := countdownLoop(cdCtx, next) + cdCancel() + if cancelled { + break + } + } else { + // use a timer quietly + timer := time.NewTimer(time.Duration(next) * time.Second) + fmt.Printf("Next run in %ds\n", next) + select { + case <-timer.C: + cdCancel() + case <-cdCtx.Done(): + timer.Stop() + return + } + } + } +} From e36dc2c426f929b57d05688864e6b4e5843dec12 Mon Sep 17 00:00:00 2001 From: Aaron Gibson Date: Sat, 16 May 2026 09:32:57 +0200 Subject: [PATCH 2/2] fix non-constant format string use Error not Errorf --- internal/experiment/httphostheader/httphostheader.go | 2 +- internal/experiment/openvpn/endpoint_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/experiment/httphostheader/httphostheader.go b/internal/experiment/httphostheader/httphostheader.go index 9f401ed19..562f61049 100644 --- a/internal/experiment/httphostheader/httphostheader.go +++ b/internal/experiment/httphostheader/httphostheader.go @@ -63,7 +63,7 @@ func (m *Measurer) Run(ctx context.Context, args *model.ExperimentArgs) error { HTTPHost: string(measurement.Input), }, Session: sess, - Target: fmt.Sprintf(m.config.TestHelperURL), + Target: fmt.Sprintf("%s", m.config.TestHelperURL), } tk, _ := g.Get(ctx) measurement.TestKeys = &TestKeys{ diff --git a/internal/experiment/openvpn/endpoint_test.go b/internal/experiment/openvpn/endpoint_test.go index bfd3eb602..87f2d53ea 100644 --- a/internal/experiment/openvpn/endpoint_test.go +++ b/internal/experiment/openvpn/endpoint_test.go @@ -145,7 +145,7 @@ func Test_newEndpointFromInputString(t *testing.T) { return } if diff := cmp.Diff(got, tt.want); diff != "" { - t.Errorf(diff) + t.Error(diff) } }) }