9 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 6753422f7a model: Site and Mapping carry optional tags
CI / test (push) Failing after 55s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 03:29:26 -06:00
VeillaxandClaude Sonnet 5.5 64101e00d2 secretbox: add AES-256-GCM sealing of small secrets
CI / test (push) Successful in 1m20s
Moved out of Hammerhead's internal package so Sandbar can use it too.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 23:21:56 -06:00
VeillaxandClaude Sonnet 5.5 a61c79fa69 selfupdate: report progress events; reports carry update stage, percent and log
CI / test (push) Successful in 1m11s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 17:56:55 -06:00
VeillaxandClaude Sonnet 5.5 ce4baacd47 model: update requests carry an ID so a stale result can't answer a new request
CI / test (push) Successful in 5m24s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:58:36 -06:00
VeillaxandClaude Sonnet 5.5 cb1243e4ed model: versions and update requests for edges and sites
CI / test (push) Successful in 1m22s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:56:34 -06:00
VeillaxandClaude Sonnet 5.5 122fade954 selfupdate: add Check to see the newest release without installing
CI / test (push) Successful in 1m41s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 15:59:26 -06:00
VeillaxandClaude Sonnet 5.5 416b921d71 Export selfupdate.Restart
CI / test (push) Successful in 5m31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:58:02 -06:00
VeillaxandClaude Sonnet 5.5 d1bbd28fb0 Add selfupdate: replace the running program with the newest Gitea release
CI / test (push) Successful in 1m56s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:57:23 -06:00
VeillaxandClaude Sonnet 5.5 b37b18c40d Add Gitea Actions CI and release workflows and deployment assets
CI / test (push) Successful in 1m13s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 03:05:26 -06:00
9 changed files with 820 additions and 1 deletions

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "These files need gofmt:"
echo "$unformatted"
exit 1
fi
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
# Reef has platform-specific files (Windows named pipes for Docker), so make
# sure it builds everywhere the programs that import it do.
- name: Builds on every platform
run: |
for t in linux/amd64 linux/arm64 linux/arm windows/amd64 darwin/amd64 darwin/arm64; do
echo "building for $t"
GOOS=${t%/*} GOARCH=${t#*/} GOARM=7 CGO_ENABLED=0 go build ./...
done
+2 -1
View File
@@ -1,6 +1,6 @@
# Reef # Reef
A shared library for Hammerhead, Blacktip, and Bamboo A shared library for Hammerhead, Blacktip, Bamboo and Sandbar
Shared types and building blocks for the sharks stack: [Hammerhead](https://git.veillax.com/Veillax/hammerhead) (control plane), [Blacktip](https://git.veillax.com/Veillax/blacktip) (edge) and [Bamboo](https://git.veillax.com/Veillax/bamboo) (site agent). Shared types and building blocks for the sharks stack: [Hammerhead](https://git.veillax.com/Veillax/hammerhead) (control plane), [Blacktip](https://git.veillax.com/Veillax/blacktip) (edge) and [Bamboo](https://git.veillax.com/Veillax/bamboo) (site agent).
@@ -15,6 +15,7 @@ Reef holds the code all three programs agree on, so each can be built and releas
| `netguard` | Refuses internal addresses for anything that connects out on a user's behalf (webhooks, mail delivery), and dials the address it checked | | `netguard` | Refuses internal addresses for anything that connects out on a user's behalf (webhooks, mail delivery), and dials the address it checked |
| `docker` | Lists running containers through the Docker Engine API, with no SDK | | `docker` | Lists running containers through the Docker Engine API, with no SDK |
| `dotenv` | Loads a `.env` file into the environment without overriding real variables | | `dotenv` | Loads a `.env` file into the environment without overriding real variables |
| `secretbox` | AES-256-GCM sealing of small secrets (`enc1:` values) before they are written to a database |
## Using it ## Using it
+35
View File
@@ -39,6 +39,13 @@ type Edge struct {
OrgIDs []string `json:"orgIds"` // orgs allowed to use this edge; empty = all OrgIDs []string `json:"orgIds"` // orgs allowed to use this edge; empty = all
IPs []string `json:"ips"` // addresses the edge reported it can bind IPs []string `json:"ips"` // addresses the edge reported it can bind
LastSeen time.Time `json:"lastSeen"` LastSeen time.Time `json:"lastSeen"`
// Version is the release the program reports (e.g. v0.1.5). AutoUpdate says whether it may update
// itself; only then will it act on an update request (UpdateID). UpdateMsg is the outcome of the last requested update.
Version string `json:"version,omitempty"`
AutoUpdate bool `json:"autoUpdate,omitempty"`
UpdateID string `json:"updateId,omitempty"` // set while an update requested by an administrator is pending
UpdateMsg string `json:"updateMsg,omitempty"`
} }
// Site is a bamboo agent: a private network that holds the real services. // Site is a bamboo agent: a private network that holds the real services.
@@ -54,8 +61,17 @@ type Site struct {
Local bool `json:"local"` Local bool `json:"local"`
EdgeID string `json:"edgeId,omitempty"` // the edge a local site belongs to EdgeID string `json:"edgeId,omitempty"` // the edge a local site belongs to
Tags []string `json:"tags,omitempty"` // labels for grouping and filtering in the dashboard
TunnelIP string `json:"tunnelIp,omitempty"` // the site's address inside the tunnel network TunnelIP string `json:"tunnelIp,omitempty"` // the site's address inside the tunnel network
PublicKey string `json:"publicKey,omitempty"` // WireGuard public key, reported by the agent PublicKey string `json:"publicKey,omitempty"` // WireGuard public key, reported by the agent
// Version is the release the program reports (e.g. v0.1.5). AutoUpdate says whether it may update
// itself; only then will it act on an update request (UpdateID). UpdateMsg is the outcome of the last requested update.
Version string `json:"version,omitempty"`
AutoUpdate bool `json:"autoUpdate,omitempty"`
UpdateID string `json:"updateId,omitempty"` // set while an update requested by an administrator is pending
UpdateMsg string `json:"updateMsg,omitempty"`
} }
// Domain is a base domain an org owns. Hostnames of web/tls mappings must sit under one. // Domain is a base domain an org owns. Hostnames of web/tls mappings must sit under one.
@@ -111,6 +127,7 @@ type Mapping struct {
Hostname string `json:"hostname,omitempty"` // web/tls only; "*.example.com" allowed Hostname string `json:"hostname,omitempty"` // web/tls only; "*.example.com" allowed
Targets []Target `json:"targets"` // udp uses only the first Targets []Target `json:"targets"` // udp uses only the first
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Tags []string `json:"tags,omitempty"` // labels for grouping and filtering in the dashboard
// ProxyProtocol ("v1" or "v2") makes the edge start each tcp/tls connection to the // ProxyProtocol ("v1" or "v2") makes the edge start each tcp/tls connection to the
// target with a PROXY protocol header carrying the real client address. // target with a PROXY protocol header carrying the real client address.
ProxyProtocol string `json:"proxyProtocol,omitempty"` ProxyProtocol string `json:"proxyProtocol,omitempty"`
@@ -216,6 +233,7 @@ type EdgeConfig struct {
Local []string `json:"local"` // site IDs the edge dials directly on its own machine Local []string `json:"local"` // site IDs the edge dials directly on its own machine
AcmeEmail string `json:"acmeEmail"` AcmeEmail string `json:"acmeEmail"`
AcmeStages bool `json:"acmeStaging"` AcmeStages bool `json:"acmeStaging"`
UpdateID string `json:"updateId,omitempty"` // an administrator asked this edge to update itself now; echoed back in its report
MailEgress []MailPolicy `json:"mailEgress"` MailEgress []MailPolicy `json:"mailEgress"`
} }
@@ -289,6 +307,14 @@ type EdgeReport struct {
Stats map[string]Stat `json:"stats"` // deltas since last report Stats map[string]Stat `json:"stats"` // deltas since last report
Containers []Container `json:"containers"` Containers []Container `json:"containers"`
MailChecks map[string]MailCheck `json:"mailChecks"` // by mail-egress ID MailChecks map[string]MailCheck `json:"mailChecks"` // by mail-egress ID
Version string `json:"version,omitempty"`
AutoUpdate bool `json:"autoUpdate,omitempty"`
UpdateMsg string `json:"updateMsg,omitempty"` // outcome of the update asked for by UpdateID
UpdateID string `json:"updateId,omitempty"`
// progress of that update, for the dashboard's progress bar and log
UpdateStage string `json:"updateStage,omitempty"`
UpdatePct int `json:"updatePct,omitempty"`
UpdateLog []string `json:"updateLog,omitempty"`
} }
// EdgeRef tells a site where to dial. // EdgeRef tells a site where to dial.
@@ -306,6 +332,7 @@ type SiteConfig struct {
SiteID string `json:"siteId"` SiteID string `json:"siteId"`
TunnelIP string `json:"tunnelIp"` // this site's WireGuard address TunnelIP string `json:"tunnelIp"` // this site's WireGuard address
Edges []EdgeRef `json:"edges"` Edges []EdgeRef `json:"edges"`
UpdateID string `json:"updateId,omitempty"` // an administrator asked this agent to update itself now; echoed back in its report
MailEgress []MailSpec `json:"mailEgress"` MailEgress []MailSpec `json:"mailEgress"`
} }
@@ -316,6 +343,14 @@ type SiteReport struct {
MailEgress map[string]string `json:"mailEgress"` // egress ID -> "" (listening) or an error MailEgress map[string]string `json:"mailEgress"` // egress ID -> "" (listening) or an error
Connected []string `json:"connected"` Connected []string `json:"connected"`
Containers []Container `json:"containers"` Containers []Container `json:"containers"`
Version string `json:"version,omitempty"`
AutoUpdate bool `json:"autoUpdate,omitempty"`
UpdateMsg string `json:"updateMsg,omitempty"`
UpdateID string `json:"updateId,omitempty"`
// progress of that update, for the dashboard's progress bar and log
UpdateStage string `json:"updateStage,omitempty"`
UpdatePct int `json:"updatePct,omitempty"`
UpdateLog []string `json:"updateLog,omitempty"`
} }
// HostMatches reports whether host matches an exact name or a "*.example.com" pattern. // HostMatches reports whether host matches an exact name or a "*.example.com" pattern.
+87
View File
@@ -0,0 +1,87 @@
// Package secretbox encrypts small secrets (tokens, passwords, private keys)
// before they are written to the database, using AES-256-GCM.
//
// Sealed values look like "enc1:<base64>". Opening a value that isn't sealed
// returns it unchanged, so data written before encryption was turned on is
// still readable and gets sealed the next time it is saved.
package secretbox
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"strings"
)
const prefix = "enc1:"
type Box struct{ aead cipher.AEAD }
// GenerateKey returns a new random key in the form New accepts.
func GenerateKey() string {
b := make([]byte, 32)
rand.Read(b)
return base64.StdEncoding.EncodeToString(b)
}
// New builds a Box from a key given as base64 or hex of 32 bytes. Any other
// text is treated as a passphrase and stretched with SHA-256.
func New(key string) (*Box, error) {
key = strings.TrimSpace(key)
if key == "" {
return nil, errors.New("empty key")
}
raw := decodeKey(key)
block, err := aes.NewCipher(raw)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &Box{aead: aead}, nil
}
func decodeKey(key string) []byte {
if b, err := base64.StdEncoding.DecodeString(key); err == nil && len(b) == 32 {
return b
}
if b, err := hex.DecodeString(key); err == nil && len(b) == 32 {
return b
}
sum := sha256.Sum256([]byte(key))
return sum[:]
}
// Seal encrypts s. The empty string stays empty so unset secrets stay visibly unset.
func (b *Box) Seal(s string) string {
if s == "" || strings.HasPrefix(s, prefix) {
return s
}
nonce := make([]byte, b.aead.NonceSize())
rand.Read(nonce)
return prefix + base64.StdEncoding.EncodeToString(b.aead.Seal(nonce, nonce, []byte(s), nil))
}
// Open decrypts a sealed value. A wrong key or damaged data is an error, so a
// bad key can never silently turn secrets into garbage.
func (b *Box) Open(s string) (string, error) {
if !strings.HasPrefix(s, prefix) {
return s, nil
}
raw, err := base64.StdEncoding.DecodeString(s[len(prefix):])
if err != nil || len(raw) < b.aead.NonceSize() {
return "", errors.New("damaged secret")
}
n := b.aead.NonceSize()
plain, err := b.aead.Open(nil, raw[:n], raw[n:], nil)
if err != nil {
return "", errors.New("can't decrypt a stored secret: is HAMMERHEAD_MASTER_KEY the key it was saved with?")
}
return string(plain), nil
}
+51
View File
@@ -0,0 +1,51 @@
package secretbox
import (
"strings"
"testing"
)
func TestSealOpenRoundTripAndKeys(t *testing.T) {
key := GenerateKey()
b, err := New(key)
if err != nil {
t.Fatal(err)
}
sealed := b.Seal("hunter2")
if !strings.HasPrefix(sealed, "enc1:") || strings.Contains(sealed, "hunter2") {
t.Fatalf("not sealed: %q", sealed)
}
if again := b.Seal("hunter2"); again == sealed {
t.Error("two seals of the same value must differ (random nonce)")
}
if b.Seal(sealed) != sealed {
t.Error("sealing an already sealed value must not double-encrypt")
}
if got, err := b.Open(sealed); err != nil || got != "hunter2" {
t.Errorf("Open = %q, %v", got, err)
}
if got, _ := b.Open("legacy plaintext"); got != "legacy plaintext" {
t.Errorf("unsealed legacy data should pass through, got %q", got)
}
if b.Seal("") != "" {
t.Error("empty stays empty")
}
other, _ := New(GenerateKey())
if _, err := other.Open(sealed); err == nil {
t.Error("the wrong key must fail loudly, not return garbage")
}
if _, err := b.Open(sealed[:len(sealed)-4] + "AAAA"); err == nil {
t.Error("tampered data must fail")
}
// a passphrase works as a key, and is stable
p1, _ := New("correct horse battery staple")
p2, _ := New("correct horse battery staple")
if got, err := p2.Open(p1.Seal("x")); err != nil || got != "x" {
t.Errorf("passphrase keys must be reproducible: %q %v", got, err)
}
if _, err := New(" "); err == nil {
t.Error("an empty key must be rejected")
}
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows
package selfupdate
import (
"os"
"syscall"
)
// Restart replaces this process with the new binary, with the same arguments and environment.
func Restart(exe string) error {
return syscall.Exec(exe, os.Args, os.Environ())
}
+7
View File
@@ -0,0 +1,7 @@
package selfupdate
import "errors"
func Restart(string) error {
return errors.New("restarting in place is not supported on Windows")
}
+388
View File
@@ -0,0 +1,388 @@
// Package selfupdate lets a program replace itself with the newest release published on a
// Gitea server: it checks the latest release, downloads the archive for this platform,
// verifies it against the release's SHA256SUMS, checks that the new binary runs, swaps it
// in and restarts into it.
package selfupdate
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
const maxArchive = 300 << 20
// Event is one step of an update, for showing progress: the stage ("checking", "downloading",
// "verifying", "installing", "restarting", "done" or "failed"), how far along it is (0-100, or -1 to
// leave the bar where it was) and, when there is something worth showing in a log, a line of text.
type Event struct {
Stage string
Pct int
Line string
}
// Config says what to update and where from.
type Config struct {
Server string // e.g. https://git.veillax.com
Repo string // owner/name, e.g. Veillax/blacktip
App string // binary and archive prefix, e.g. blacktip
Current string // the running version, e.g. v0.1.2; anything that isn't vX.Y.Z is never updated
// Interval between checks (default 6 hours).
Interval time.Duration
Logf func(format string, args ...any)
// Progress, if set, is told about every step of an update (it is called from the updating goroutine).
Progress func(Event)
// The fields below exist for tests and unusual setups.
HTTP *http.Client
GOOS string
GOARCH string
ExePath string // binary to replace (default: the running one)
// Verify runs on the downloaded binary before it replaces the old one. The default
// runs it with -version and checks that it reports the new tag.
Verify func(path, tag string) error
// Restart is called after the swap (default: re-exec the new binary in place, keeping the
// process ID and environment).
Restart func(exe string) error
}
func (c *Config) defaults() {
if c.Interval <= 0 {
c.Interval = 6 * time.Hour
}
if c.Logf == nil {
c.Logf = func(string, ...any) {}
}
if c.HTTP == nil {
c.HTTP = &http.Client{Timeout: 5 * time.Minute}
}
if c.GOOS == "" {
c.GOOS = runtime.GOOS
}
if c.GOARCH == "" {
c.GOARCH = runtime.GOARCH
}
if c.Verify == nil {
c.Verify = verifyRuns(c.App)
}
if c.Restart == nil {
c.Restart = Restart
}
c.Server = strings.TrimRight(c.Server, "/")
}
// Run checks shortly after start and then every Interval until ctx ends. When an update
// is installed, Restart takes over and Run normally does not return.
func Run(ctx context.Context, cfg Config) {
cfg.defaults()
if _, ok := parseVersion(cfg.Current); !ok {
cfg.Logf("auto-update: this is a development build (%q), so it will not update itself", cfg.Current)
return
}
if cfg.GOOS == "windows" {
cfg.Logf("auto-update: not supported on Windows, because a running program can't be replaced; update it by hand")
return
}
wait := time.Duration(30+rand.Intn(240)) * time.Second // spread a fleet's first checks out
for {
select {
case <-ctx.Done():
return
case <-time.After(wait):
}
if _, err := CheckOnce(ctx, cfg); err != nil {
cfg.Logf("auto-update: %v", err)
}
wait = cfg.Interval + time.Duration(rand.Int63n(int64(cfg.Interval/10)+1))
}
}
func (c Config) say(stage string, pct int, line string) {
if c.Progress != nil {
c.Progress(Event{Stage: stage, Pct: pct, Line: line})
}
}
// CheckOnce looks for a newer release and installs it. It returns the new tag when it
// installed one (and Restart returned, which only happens in tests or on failure).
func CheckOnce(ctx context.Context, cfg Config) (string, error) {
cfg.defaults()
tag, err := checkOnce(ctx, cfg)
if err != nil {
cfg.say("failed", -1, "Failed: "+err.Error())
}
return tag, err
}
func checkOnce(ctx context.Context, cfg Config) (string, error) {
cur, ok := parseVersion(cfg.Current)
if !ok {
return "", fmt.Errorf("%q is not a release version", cfg.Current)
}
cfg.say("checking", 2, "Checking "+cfg.Server+" for the newest release of "+cfg.App)
rel, err := latestRelease(ctx, cfg)
if err != nil {
return "", err
}
if rel.Prerelease || rel.Draft {
cfg.say("done", 100, "Already on the newest release ("+cfg.Current+")")
return "", nil
}
latest, ok := parseVersion(rel.Tag)
if !ok || !latest.newerThan(cur) {
cfg.say("done", 100, "Already on the newest release ("+cfg.Current+")")
return "", nil // up to date, or something we don't understand: never downgrade
}
if cfg.GOOS == "windows" {
return "", errors.New("auto-update is not supported on Windows")
}
archive := fmt.Sprintf("%s_%s_%s_%s.tar.gz", cfg.App, rel.Tag, cfg.GOOS, cfg.GOARCH)
var archiveURL, sumsURL string
for _, a := range rel.Assets {
switch a.Name {
case archive:
archiveURL = a.URL
case "SHA256SUMS":
sumsURL = a.URL
}
}
if archiveURL == "" || sumsURL == "" {
return "", fmt.Errorf("release %s has no %s with SHA256SUMS yet (it may still be building); will retry", rel.Tag, archive)
}
cfg.Logf("auto-update: %s is available (running %s); downloading %s", rel.Tag, cfg.Current, archive)
cfg.say("checking", 5, rel.Tag+" is available (running "+cfg.Current+")")
sums, err := get(ctx, cfg.HTTP, sumsURL, 1<<20, nil)
if err != nil {
return "", err
}
want := ""
for _, line := range strings.Split(string(sums), "\n") {
f := strings.Fields(line)
if len(f) == 2 && strings.TrimPrefix(f[1], "*") == archive {
want = strings.ToLower(f[0])
}
}
if want == "" {
return "", fmt.Errorf("SHA256SUMS does not list %s", archive)
}
cfg.say("downloading", 6, "Downloading "+archive)
lastQuarter := 0
data, err := get(ctx, cfg.HTTP, archiveURL, maxArchive, func(done, total int64) {
if total <= 0 {
cfg.say("downloading", -1, "")
return
}
pct := 6 + int(80*done/total)
line := ""
if q := int(100 * done / total / 25); q > lastQuarter {
lastQuarter = q
line = fmt.Sprintf("Downloaded %.1f of %.1f MB (%d%%)", float64(done)/1e6, float64(total)/1e6, 100*done/total)
}
cfg.say("downloading", pct, line)
})
if err != nil {
return "", err
}
cfg.say("verifying", 88, "Verifying the checksum")
got := sha256.Sum256(data)
if hex.EncodeToString(got[:]) != want {
return "", fmt.Errorf("checksum mismatch for %s: not installing", archive)
}
cfg.say("verifying", 90, "The checksum matches")
cfg.say("installing", 92, "Unpacking the new binary")
bin, err := extract(data, cfg.App)
if err != nil {
return "", err
}
exe := cfg.ExePath
if exe == "" {
if exe, err = os.Executable(); err != nil {
return "", err
}
if exe, err = filepath.EvalSymlinks(exe); err != nil {
return "", err
}
}
tmp := filepath.Join(filepath.Dir(exe), "."+filepath.Base(exe)+".new")
if err := os.WriteFile(tmp, bin, 0o755); err != nil {
return "", fmt.Errorf("can't write next to %s (is its folder writable by this user?): %w", exe, err)
}
cfg.say("installing", 94, "Checking that the new binary starts")
if err := cfg.Verify(tmp, rel.Tag); err != nil {
os.Remove(tmp)
return "", fmt.Errorf("the new binary failed its check, keeping the old one: %w", err)
}
cfg.say("installing", 97, "Replacing "+exe)
if err := os.Rename(tmp, exe); err != nil { // atomic; the running process keeps its old file
os.Remove(tmp)
return "", fmt.Errorf("replacing %s: %w", exe, err)
}
cfg.Logf("auto-update: installed %s, restarting", rel.Tag)
cfg.say("restarting", 100, "Installed "+rel.Tag+"; restarting into it")
if err := cfg.Restart(exe); err != nil {
return rel.Tag, fmt.Errorf("installed %s but could not restart into it (it will start with the next restart): %w", rel.Tag, err)
}
return rel.Tag, nil
}
type release struct {
Tag string `json:"tag_name"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
Assets []struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
} `json:"assets"`
}
func latestRelease(ctx context.Context, cfg Config) (release, error) {
var rel release
body, err := get(ctx, cfg.HTTP, cfg.Server+"/api/v1/repos/"+cfg.Repo+"/releases/latest", 1<<20, nil)
if err != nil {
return rel, fmt.Errorf("checking for a new release: %w", err)
}
if err := json.Unmarshal(body, &rel); err != nil {
return rel, fmt.Errorf("reading the latest release: %w", err)
}
return rel, nil
}
// Check reports the newest full release and whether it is newer than cfg.Current, without
// installing anything. A development build (a Current that isn't vX.Y.Z) is never "newer".
func Check(ctx context.Context, cfg Config) (latest string, newer bool, err error) {
cfg.defaults()
rel, err := latestRelease(ctx, cfg)
if err != nil {
return "", false, err
}
if rel.Prerelease || rel.Draft {
return "", false, nil
}
l, ok := parseVersion(rel.Tag)
if !ok {
return rel.Tag, false, nil
}
cur, ok := parseVersion(cfg.Current)
return rel.Tag, ok && l.newerThan(cur), nil
}
func get(ctx context.Context, c *http.Client, url string, max int64, onProgress func(done, total int64)) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "sharks-selfupdate")
resp, err := c.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("%s: %s", url, resp.Status)
}
var b []byte
buf := make([]byte, 64<<10)
for {
n, err := resp.Body.Read(buf)
b = append(b, buf[:n]...)
if int64(len(b)) > max {
return nil, fmt.Errorf("%s is larger than %d bytes", url, max)
}
if onProgress != nil && n > 0 {
onProgress(int64(len(b)), resp.ContentLength)
}
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
}
return b, nil
}
// extract finds the program inside a release archive (<pkg>/<app>).
func extract(data []byte, app string) ([]byte, error) {
zr, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
tr := tar.NewReader(zr)
for {
h, err := tr.Next()
if err == io.EOF {
return nil, fmt.Errorf("no %s inside the archive", app)
}
if err != nil {
return nil, err
}
if h.Typeflag == tar.TypeReg && path.Base(h.Name) == app && strings.Count(h.Name, "/") == 1 {
return io.ReadAll(io.LimitReader(tr, maxArchive))
}
}
}
func verifyRuns(app string) func(path, tag string) error {
return func(p, tag string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, p, "-version").Output()
if err != nil {
return err
}
if want := app + " " + tag; strings.TrimSpace(string(out)) != want {
return fmt.Errorf("it reports %q, expected %q", strings.TrimSpace(string(out)), want)
}
return nil
}
}
type version [3]int
func parseVersion(s string) (version, bool) {
var v version
if !strings.HasPrefix(s, "v") || strings.ContainsAny(s, "-+") {
return v, false // pre-releases and local builds are never part of auto-update
}
parts := strings.Split(s[1:], ".")
if len(parts) != 3 {
return v, false
}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return v, false
}
v[i] = n
}
return v, true
}
func (a version) newerThan(b version) bool {
for i := range a {
if a[i] != b[i] {
return a[i] > b[i]
}
}
return false
}
+197
View File
@@ -0,0 +1,197 @@
package selfupdate
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func makeArchive(t *testing.T, pkg, app, content string) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for name, body := range map[string]string{pkg + "/README.md": "hi", pkg + "/" + app: content} {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
tw.Write([]byte(body))
}
tw.Close()
gz.Close()
return buf.Bytes()
}
// fakeGitea serves one release with the given tag.
func fakeGitea(t *testing.T, tag string, prerelease bool, corrupt bool) *httptest.Server {
archive := fmt.Sprintf("demo_%s_linux_amd64.tar.gz", tag)
data := makeArchive(t, strings.TrimSuffix(archive, ".tar.gz"), "demo", "NEW-BINARY "+tag)
sum := sha256.Sum256(data)
sums := hex.EncodeToString(sum[:]) + " " + archive + "\n"
if corrupt {
sums = strings.Repeat("0", 64) + " " + archive + "\n"
}
var srv *httptest.Server
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/Veillax/demo/releases/latest", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"tag_name":%q,"prerelease":%v,"assets":[{"name":%q,"browser_download_url":"%s/dl/%s"},{"name":"SHA256SUMS","browser_download_url":"%s/dl/SHA256SUMS"}]}`,
tag, prerelease, archive, srv.URL, archive, srv.URL)
})
mux.HandleFunc("/dl/SHA256SUMS", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, sums) })
mux.HandleFunc("/dl/"+archive, func(w http.ResponseWriter, r *http.Request) { w.Write(data) })
srv = httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func setup(t *testing.T, srv *httptest.Server, current string) (Config, string, *string) {
exe := filepath.Join(t.TempDir(), "demo")
os.WriteFile(exe, []byte("OLD-BINARY"), 0o755)
restarted := new(string)
return Config{Server: srv.URL, Repo: "Veillax/demo", App: "demo", Current: current, GOOS: "linux", GOARCH: "amd64", ExePath: exe,
Verify: func(p, tag string) error { return nil },
Restart: func(e string) error { *restarted = e; return nil }}, exe, restarted
}
func TestInstallsANewerRelease(t *testing.T) {
cfg, exe, restarted := setup(t, fakeGitea(t, "v0.2.0", false, false), "v0.1.5")
tag, err := CheckOnce(context.Background(), cfg)
if err != nil || tag != "v0.2.0" {
t.Fatalf("tag=%q err=%v", tag, err)
}
if b, _ := os.ReadFile(exe); string(b) != "NEW-BINARY v0.2.0" {
t.Fatalf("binary is %q", b)
}
if *restarted != exe {
t.Fatalf("restart called with %q", *restarted)
}
if _, err := os.Stat(filepath.Join(filepath.Dir(exe), ".demo.new")); err == nil {
t.Fatal("temporary file left behind")
}
}
func TestLeavesThingsAloneWhenItShould(t *testing.T) {
for _, c := range []struct {
name, current, tag string
pre bool
}{
{"same version", "v0.2.0", "v0.2.0", false},
{"never downgrades", "v0.3.0", "v0.2.0", false},
{"pre-release is skipped", "v0.1.0", "v0.2.0", true},
{"dev build", "dev", "v0.2.0", false},
{"local build", "v0.2.0-local", "v0.9.0", false},
} {
cfg, exe, restarted := setup(t, fakeGitea(t, c.tag, c.pre, false), c.current)
tag, _ := CheckOnce(context.Background(), cfg)
if b, _ := os.ReadFile(exe); tag != "" || string(b) != "OLD-BINARY" || *restarted != "" {
t.Errorf("%s: changed things (tag=%q binary=%q)", c.name, tag, b)
}
}
}
func TestRefusesABadChecksum(t *testing.T) {
cfg, exe, restarted := setup(t, fakeGitea(t, "v0.2.0", false, true), "v0.1.0")
if _, err := CheckOnce(context.Background(), cfg); err == nil || !strings.Contains(err.Error(), "checksum") {
t.Fatalf("err=%v", err)
}
if b, _ := os.ReadFile(exe); string(b) != "OLD-BINARY" || *restarted != "" {
t.Fatal("installed a binary that failed its checksum")
}
}
func TestKeepsTheOldBinaryWhenTheNewOneFailsItsCheck(t *testing.T) {
cfg, exe, restarted := setup(t, fakeGitea(t, "v0.2.0", false, false), "v0.1.0")
cfg.Verify = func(p, tag string) error { return fmt.Errorf("segfault") }
if _, err := CheckOnce(context.Background(), cfg); err == nil {
t.Fatal("expected an error")
}
if b, _ := os.ReadFile(exe); string(b) != "OLD-BINARY" || *restarted != "" {
t.Fatal("replaced the binary despite a failed check")
}
if _, err := os.Stat(filepath.Join(filepath.Dir(exe), ".demo.new")); err == nil {
t.Fatal("temporary file left behind")
}
}
func TestVersionOrdering(t *testing.T) {
for _, c := range []struct {
a, b string
want bool
}{{"v0.1.10", "v0.1.9", true}, {"v1.0.0", "v0.99.99", true}, {"v0.1.2", "v0.1.2", false}, {"v0.1.1", "v0.2.0", false}} {
a, _ := parseVersion(c.a)
b, _ := parseVersion(c.b)
if a.newerThan(b) != c.want {
t.Errorf("%s newer than %s: want %v", c.a, c.b, c.want)
}
}
}
func TestCheckReportsWithoutInstalling(t *testing.T) {
srv := fakeGitea(t, "v0.2.0", false, false)
for _, c := range []struct {
current string
newer bool
}{{"v0.1.0", true}, {"v0.2.0", false}, {"v0.3.0", false}, {"dev", false}, {"v0.1.0-local", false}} {
cfg, exe, restarted := setup(t, srv, c.current)
tag, newer, err := Check(context.Background(), cfg)
if err != nil || tag != "v0.2.0" || newer != c.newer {
t.Errorf("%s: tag=%q newer=%v err=%v", c.current, tag, newer, err)
}
if b, _ := os.ReadFile(exe); string(b) != "OLD-BINARY" || *restarted != "" {
t.Errorf("%s: Check changed things", c.current)
}
}
}
func TestReportsProgressThroughTheWholeUpdate(t *testing.T) {
cfg, _, _ := setup(t, fakeGitea(t, "v0.2.0", false, false), "v0.1.0")
var events []Event
cfg.Progress = func(e Event) { events = append(events, e) }
if _, err := CheckOnce(context.Background(), cfg); err != nil {
t.Fatal(err)
}
var stages []string
last, lines := 0, ""
for _, e := range events {
if len(stages) == 0 || stages[len(stages)-1] != e.Stage {
stages = append(stages, e.Stage)
}
if e.Pct >= 0 {
if e.Pct < last {
t.Errorf("progress went backwards: %d after %d (%+v)", e.Pct, last, e)
}
last = e.Pct
}
lines += e.Line + "\n"
}
if got := strings.Join(stages, ","); got != "checking,downloading,verifying,installing,restarting" {
t.Errorf("stages: %s", got)
}
if last != 100 || !strings.Contains(lines, "v0.2.0 is available") || !strings.Contains(lines, "checksum matches") {
t.Errorf("progress ended at %d with log:\n%s", last, lines)
}
}
func TestProgressReportsFailureAndNothingToDo(t *testing.T) {
cfg, _, _ := setup(t, fakeGitea(t, "v0.2.0", false, true), "v0.1.0")
var last Event
cfg.Progress = func(e Event) { last = e }
if _, err := CheckOnce(context.Background(), cfg); err == nil || last.Stage != "failed" || !strings.Contains(last.Line, "checksum") {
t.Errorf("err=%v last=%+v", err, last)
}
cfg, _, _ = setup(t, fakeGitea(t, "v0.2.0", false, false), "v0.2.0")
cfg.Progress = func(e Event) { last = e }
if tag, err := CheckOnce(context.Background(), cfg); err != nil || tag != "" || last.Stage != "done" || last.Pct != 100 {
t.Errorf("tag=%q err=%v last=%+v", tag, err, last)
}
}