shark expose: put a local port on the internet through Hammerhead

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 17:00:53 -06:00
commit 61143d107c
15 files changed
+1538

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
// Package api is the small slice of Hammerhead's HTTP API that `shark expose` uses.
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Client talks to Hammerhead with an API key (the "user token" from Access in the dashboard).
type Client struct {
Base string
Token string
HTTP *http.Client
}
func New(base, token string) *Client {
return &Client{Base: strings.TrimRight(base, "/"), Token: token, HTTP: &http.Client{Timeout: 20 * time.Second}}
}
// Error is an error Hammerhead answered with.
type Error struct {
Status int
Message string
}
func (e *Error) Error() string { return e.Message }
// IsStatus reports whether err is a Hammerhead error with this HTTP status.
func IsStatus(err error, status int) bool {
var e *Error
return errors.As(err, &e) && e.Status == status
}
// Request is what to expose. Zero values mean "let Hammerhead choose".
type Request struct {
Proto string `json:"proto"` // http, https or tcp
Name string `json:"name,omitempty"`
TargetHost string `json:"targetHost,omitempty"`
TargetPort int `json:"targetPort"`
TTLSeconds int `json:"ttlSeconds,omitempty"`
Port int `json:"port,omitempty"`
Hostname string `json:"hostname,omitempty"`
EdgeID string `json:"edgeId,omitempty"`
Auth *Auth `json:"auth,omitempty"`
}
// Auth is the login in front of a website.
type Auth struct {
Password string `json:"password,omitempty"`
Pin string `json:"pin,omitempty"`
SSO bool `json:"sso,omitempty"`
}
// Exposure is an ephemeral site and port as Hammerhead describes it.
type Exposure struct {
ID string `json:"id"`
Proto string `json:"proto"`
URL string `json:"url"`
Host string `json:"host"`
Port int `json:"port"`
Hostname string `json:"hostname"`
SiteToken string `json:"siteToken"`
Hammerhead string `json:"hammerhead"`
Protected bool `json:"protected"`
ExpiresAt time.Time `json:"expiresAt"`
Live bool `json:"live"`
Error string `json:"error"`
}
// Address is what visitors use: the URL for websites, host:port for tcp.
func (e Exposure) Address() string {
if e.URL != "" {
return e.URL
}
return fmt.Sprintf("tcp://%s:%d", e.Host, e.Port)
}
func (c *Client) do(ctx context.Context, method, path string, in, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewReader(b)
}
u, err := url.JoinPath(c.Base, path)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, method, u, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
var e struct{ Error string }
json.Unmarshal(raw, &e)
if e.Error == "" {
e.Error = fmt.Sprintf("%s %s: %s", method, path, resp.Status)
}
if resp.StatusCode == http.StatusUnauthorized {
e.Error = "Hammerhead did not accept the token (" + e.Error + ")"
}
return &Error{Status: resp.StatusCode, Message: e.Error}
}
if out != nil {
return json.Unmarshal(raw, out)
}
return nil
}
func (c *Client) Create(ctx context.Context, r Request) (Exposure, error) {
var e Exposure
err := c.do(ctx, "POST", "/api/orgs/-/expose", r, &e)
return e, err
}
// Renew pushes the deadline out by ttl (0 = the server's default).
func (c *Client) Renew(ctx context.Context, id string, ttl time.Duration) (Exposure, error) {
var e Exposure
err := c.do(ctx, "POST", "/api/orgs/-/expose/"+id+"/renew", map[string]int{"ttlSeconds": int(ttl.Seconds())}, &e)
return e, err
}
func (c *Client) Get(ctx context.Context, id string) (Exposure, error) {
var e Exposure
err := c.do(ctx, "GET", "/api/orgs/-/expose/"+id, nil, &e)
return e, err
}
func (c *Client) List(ctx context.Context) ([]Exposure, error) {
var e []Exposure
err := c.do(ctx, "GET", "/api/orgs/-/expose", nil, &e)
return e, err
}
func (c *Client) Delete(ctx context.Context, id string) error {
return c.do(ctx, "DELETE", "/api/orgs/-/expose/"+id, nil, nil)
}
+218
View File
@@ -0,0 +1,218 @@
// Package cli is the shark command line.
package cli
import (
"context"
"errors"
"flag"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"time"
"git.veillax.com/Veillax/shark-expose/internal/api"
"git.veillax.com/Veillax/shark-expose/internal/config"
"git.veillax.com/Veillax/shark-expose/internal/expose"
)
// Version is set at build time by cmd/shark.
var Version = "dev"
const usage = `shark exposes a local port through your own Hammerhead.
Usage:
shark expose [http|https|tcp] <port | host:port> [flags]
shark login --hammerhead URL --token KEY
shark ls
shark version
Examples:
shark expose 3000 website on a random name under one of your domains
shark expose 3000 --password s3cret ...behind a shared password
shark expose tcp 25565 raw TCP on a free public port
Run "shark expose -h" for all flags.
`
// Run executes one command line and returns the exit code. Cancelling ctx is Ctrl+C.
func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprint(stderr, usage)
return 2
}
var err error
switch args[0] {
case "expose":
err = cmdExpose(ctx, args[1:], stdout, stderr)
case "login":
err = cmdLogin(args[1:], stdout)
case "ls", "list":
err = cmdList(context.Background(), stdout)
case "version", "-v", "--version":
fmt.Fprintln(stdout, "shark", Version)
case "help", "-h", "--help":
fmt.Fprint(stdout, usage)
default:
fmt.Fprintf(stderr, "unknown command %q\n\n%s", args[0], usage)
return 2
}
if err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
fmt.Fprintln(stderr, "shark:", err)
return 1
}
return 0
}
func client() (*api.Client, error) {
cfg, err := config.Load()
if err != nil {
return nil, err
}
if cfg.Hammerhead == "" || cfg.Token == "" {
return nil, errors.New("not signed in: run `shark login --hammerhead https://your-hammerhead --token hhk_...` (make a key under Access in the dashboard), or set SHARK_HAMMERHEAD and SHARK_TOKEN")
}
return api.New(cfg.Hammerhead, cfg.Token), nil
}
func cmdLogin(args []string, out io.Writer) error {
fs := flag.NewFlagSet("login", flag.ContinueOnError)
hh := fs.String("hammerhead", "", "URL of your Hammerhead")
tok := fs.String("token", os.Getenv("SHARK_TOKEN"), "API key from Access in the dashboard (or set SHARK_TOKEN)")
if err := fs.Parse(args); err != nil {
return err
}
if *hh == "" || *tok == "" {
return errors.New("login needs --hammerhead and --token")
}
c := api.New(*hh, *tok)
if _, err := c.List(context.Background()); err != nil {
return fmt.Errorf("could not sign in: %w", err)
}
p, err := config.Save(config.Config{Hammerhead: c.Base, Token: *tok})
if err != nil {
return err
}
fmt.Fprintf(out, "Signed in to %s. Saved to %s\n", c.Base, p)
return nil
}
func cmdList(ctx context.Context, out io.Writer) error {
c, err := client()
if err != nil {
return err
}
list, err := c.List(ctx)
if err != nil {
return err
}
if len(list) == 0 {
fmt.Fprintln(out, "Nothing is exposed.")
}
for _, e := range list {
fmt.Fprintf(out, "%s %-5s %s (expires %s)\n", e.ID, e.Proto, e.Address(), time.Until(e.ExpiresAt).Round(time.Second))
}
return nil
}
// target parses "3000", ":3000" or "host:3000".
func target(s string) (string, int, error) {
host := "127.0.0.1"
if i := strings.LastIndex(s, ":"); i >= 0 {
if h := s[:i]; h != "" {
host = h
}
s = s[i+1:]
}
n, err := strconv.Atoi(s)
if err != nil || n < 1 || n > 65535 {
return "", 0, fmt.Errorf("%q is not a port between 1 and 65535", s)
}
return strings.Trim(host, "[]"), n, nil
}
// splitArgs lets flags come before or after the positional arguments.
func splitArgs(fs *flag.FlagSet, args []string) ([]string, error) {
var pos []string
for {
if err := fs.Parse(args); err != nil {
return nil, err
}
if fs.NArg() == 0 {
return pos, nil
}
pos = append(pos, fs.Arg(0))
args = fs.Args()[1:]
}
}
func cmdExpose(ctx context.Context, args []string, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("expose", flag.ContinueOnError)
fs.SetOutput(stderr)
name := fs.String("name", "", "label used in the public name and the dashboard")
ttl := fs.Duration("ttl", 5*time.Minute, "how long Hammerhead keeps the exposure if shark stops talking to it (30s-24h)")
hostname := fs.String("hostname", "", "website: the full public name (must be under one of your domains)")
port := fs.Int("port", 0, "public port (default: 80/443 for websites, a free one for tcp)")
edge := fs.String("edge", "", "edge ID to use (default: any online edge)")
password := fs.String("password", os.Getenv("SHARK_PASSWORD"), "website: require this password (6+ characters; or set SHARK_PASSWORD)")
pin := fs.String("pin", "", "website: require this 4-12 digit PIN")
sso := fs.Bool("sso", false, "website: require a Hammerhead account")
verbose := fs.Bool("verbose", false, "show the tunnel agent's log")
fs.Usage = func() {
fmt.Fprint(stderr, "Usage: shark expose [http|https|tcp] <port | host:port> [flags]\n\n")
fs.PrintDefaults()
}
pos, err := splitArgs(fs, args)
if err != nil {
return err
}
proto := "http"
if len(pos) == 2 {
proto, pos = strings.ToLower(pos[0]), pos[1:]
}
if len(pos) != 1 {
fs.Usage()
return errors.New("expected a port to expose")
}
if proto != "http" && proto != "https" && proto != "tcp" {
return fmt.Errorf("protocol must be http, https or tcp, not %q", proto)
}
host, tport, err := target(pos[0])
if err != nil {
return err
}
req := api.Request{Proto: proto, Name: *name, TargetHost: host, TargetPort: tport, Port: *port, Hostname: *hostname, EdgeID: *edge}
if *password != "" || *pin != "" || *sso {
if proto == "tcp" {
return errors.New("login protection works for websites, not tcp")
}
req.Auth = &api.Auth{Password: *password, Pin: *pin, SSO: *sso}
}
c, err := client()
if err != nil {
return err
}
if !expose.Listening(host, tport) {
fmt.Fprintf(stderr, "warning: nothing is listening on %s yet\n", net.JoinHostPort(host, strconv.Itoa(tport)))
}
o := expose.Options{Request: req, TTL: *ttl, Out: stderr, Verbose: *verbose}
err = expose.Run(ctx, c, o, func(e api.Exposure) {
fmt.Fprintf(stdout, "\nshark %s -> %s\n\n", e.Address(), net.JoinHostPort(host, strconv.Itoa(tport)))
if e.Protected {
fmt.Fprintln(stdout, "Visitors must sign in.")
}
if !e.Live {
fmt.Fprintln(stdout, "The edge has not confirmed the port yet; it may take a moment.")
}
fmt.Fprintln(stdout, "Press Ctrl+C to stop.")
})
if ctx.Err() != nil && err == nil {
fmt.Fprintln(stdout, "\nStopped. Removed from Hammerhead.")
}
return err
}
+47
View File
@@ -0,0 +1,47 @@
package cli
import (
"bytes"
"context"
"fmt"
"strings"
"testing"
)
func TestTarget(t *testing.T) {
for in, want := range map[string]string{"3000": "127.0.0.1:3000", ":8080": "127.0.0.1:8080", "db.lan:5432": "db.lan:5432", "[::1]:80": "::1:80"} {
h, p, err := target(in)
if err != nil || fmt.Sprintf("%s:%d", h, p) != want {
t.Errorf("%q -> %s:%d, %v", in, h, p, err)
}
}
for _, bad := range []string{"", "abc", "0", "70000", "host:"} {
if _, _, err := target(bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
}
func TestUsageErrors(t *testing.T) {
t.Setenv("SHARK_CONFIG", t.TempDir()+"/none.json")
t.Setenv("SHARK_HAMMERHEAD", "")
t.Setenv("SHARK_TOKEN", "")
for name, args := range map[string][]string{
"no command": {},
"unknown": {"fly"},
"no port": {"expose"},
"bad port": {"expose", "abc"},
"bad proto": {"expose", "udp", "5"},
"tcp password": {"expose", "tcp", "5", "--password", "secret1"},
"not signed in": {"expose", "3000"},
} {
var out, errb bytes.Buffer
if code := Run(context.Background(), args, &out, &errb); code == 0 {
t.Errorf("%s: exit 0", name)
}
}
var out, errb bytes.Buffer
if code := Run(context.Background(), []string{"expose", "3000"}, &out, &errb); code == 0 || !strings.Contains(errb.String(), "shark login") {
t.Errorf("signed-out hint missing: %q", errb.String())
}
}
+66
View File
@@ -0,0 +1,66 @@
// Package config keeps the Hammerhead address and token between runs.
package config
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
)
type Config struct {
Hammerhead string `json:"hammerhead"`
Token string `json:"token"`
}
// Path is where `shark login` saves the settings; SHARK_CONFIG overrides it.
func Path() (string, error) {
if p := os.Getenv("SHARK_CONFIG"); p != "" {
return p, nil
}
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "shark", "config.json"), nil
}
// Load reads the saved settings, then lets SHARK_HAMMERHEAD and SHARK_TOKEN override them.
// A missing file is not an error.
func Load() (Config, error) {
var c Config
p, err := Path()
if err == nil {
b, rerr := os.ReadFile(p)
switch {
case rerr == nil:
if err := json.Unmarshal(b, &c); err != nil {
return c, errors.New(p + " is not valid JSON: " + err.Error())
}
case !errors.Is(rerr, os.ErrNotExist):
return c, rerr
}
}
if v := os.Getenv("SHARK_HAMMERHEAD"); v != "" {
c.Hammerhead = v
}
if v := os.Getenv("SHARK_TOKEN"); v != "" {
c.Token = v
}
c.Hammerhead = strings.TrimRight(c.Hammerhead, "/")
return c, nil
}
// Save writes the settings so that only the user can read them (the token is a credential).
func Save(c Config) (string, error) {
p, err := Path()
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
return "", err
}
b, _ := json.MarshalIndent(c, "", " ")
return p, os.WriteFile(p, append(b, '\n'), 0o600)
}
+146
View File
@@ -0,0 +1,146 @@
// Package expose runs one exposure: ask Hammerhead for it, bring up the tunnel, keep it alive,
// and take everything down again when told to stop.
package expose
import (
"context"
"fmt"
"io"
"log"
"net"
"os"
"strconv"
"time"
"git.veillax.com/Veillax/bamboo"
"git.veillax.com/Veillax/shark-expose/internal/api"
)
type Options struct {
Request api.Request
TTL time.Duration
// LiveWait is how long to wait for the edge to start listening before giving up on a "live" confirmation.
LiveWait time.Duration
Out io.Writer // where status lines go; nil = discard
Verbose bool // let the tunnel agent log
}
func (o *Options) say(format string, a ...any) {
if o.Out != nil {
fmt.Fprintf(o.Out, format+"\n", a...)
}
}
// Run blocks until ctx is cancelled or the exposure is lost, and always removes what it created.
// onReady is called once with the exposure after the edge is listening (or LiveWait has passed).
func Run(ctx context.Context, c *api.Client, o Options, onReady func(api.Exposure)) error {
if o.TTL <= 0 {
o.TTL = 5 * time.Minute
}
if o.LiveWait <= 0 {
o.LiveWait = 20 * time.Second
}
o.Request.TTLSeconds = int(o.TTL.Seconds())
e, err := c.Create(ctx, o.Request)
if err != nil {
return err
}
// Whatever happens next, the exposure must not outlive us. The caller's ctx is probably
// cancelled by then, so cleanup gets its own.
defer func() {
cctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
if err := c.Delete(cctx, e.ID); err != nil {
o.say("could not remove the exposure (it will expire by itself): %v", err)
}
}()
dir, err := os.MkdirTemp("", "shark-")
if err != nil {
return err
}
defer os.RemoveAll(dir)
if !o.Verbose {
log.SetOutput(io.Discard)
}
agent, err := bamboo.New(bamboo.Config{Hammerhead: c.Base, Token: e.SiteToken, DataDir: dir})
if err != nil {
return err
}
actx, stopAgent := context.WithCancel(ctx)
agentDone := make(chan struct{})
go func() { defer close(agentDone); agent.Run(actx) }()
defer func() {
stopAgent()
select {
case <-agentDone:
case <-time.After(5 * time.Second):
}
}()
// Hammerhead tells us when the edge has the port open.
deadline := time.Now().Add(o.LiveWait)
for !e.Live && time.Now().Before(deadline) {
select {
case <-ctx.Done():
return nil
case <-time.After(300 * time.Millisecond):
}
if got, err := c.Get(ctx, e.ID); err == nil {
e.Live, e.Error, e.ExpiresAt = got.Live, got.Error, got.ExpiresAt
}
}
if e.Error != "" {
return fmt.Errorf("the edge could not open the port: %s", e.Error)
}
if onReady != nil {
onReady(e)
}
return keepAlive(ctx, c, e.ID, o)
}
// keepAlive renews the exposure until ctx ends. It gives up once Hammerhead has forgotten the
// exposure, or when renewals have been failing for longer than the TTL (it has expired by then).
func keepAlive(ctx context.Context, c *api.Client, id string, o Options) error {
every := o.TTL / 3
if every < 5*time.Second {
every = 5 * time.Second
}
if every > time.Minute {
every = time.Minute
}
t := time.NewTicker(every)
defer t.Stop()
lastOK := time.Now()
for {
select {
case <-ctx.Done():
return nil
case <-t.C:
}
_, err := c.Renew(ctx, id, o.TTL)
switch {
case err == nil:
lastOK = time.Now()
case ctx.Err() != nil:
return nil
case api.IsStatus(err, 404):
return fmt.Errorf("Hammerhead no longer has this exposure (it expired or was removed)")
case time.Since(lastOK) > o.TTL:
return fmt.Errorf("lost contact with Hammerhead: %v", err)
default:
o.say("could not renew, will retry: %v", err)
}
}
}
// Listening reports whether something accepts connections at host:port.
func Listening(host string, port int) bool {
c, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), time.Second)
if err != nil {
return false
}
c.Close()
return true
}
+142
View File
@@ -0,0 +1,142 @@
package expose
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"git.veillax.com/Veillax/shark-expose/internal/api"
)
// fakeHammerhead implements just enough of the expose API to watch what the client does.
type fakeHammerhead struct {
mu sync.Mutex
calls []string
renewErr int // status to answer renewals with; 0 = ok
gone atomic.Bool
srv *httptest.Server
created api.Request
}
func newFake(t *testing.T) *fakeHammerhead {
f := &fakeHammerhead{}
mux := http.NewServeMux()
reply := func(w http.ResponseWriter, code int, v any) {
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
exp := api.Exposure{ID: "abc", Proto: "tcp", Host: "edge.test", Port: 20001, SiteToken: "tok", Live: true}
mux.HandleFunc("POST /api/orgs/-/expose", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, "create")
if r.Header.Get("Authorization") != "Bearer hhk_x_y" {
reply(w, 401, map[string]string{"error": "Sign in to continue."})
return
}
json.NewDecoder(r.Body).Decode(&f.created)
reply(w, 200, exp)
})
mux.HandleFunc("GET /api/orgs/-/expose/abc", func(w http.ResponseWriter, r *http.Request) { reply(w, 200, exp) })
mux.HandleFunc("POST /api/orgs/-/expose/abc/renew", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, "renew")
if f.renewErr != 0 {
reply(w, f.renewErr, map[string]string{"error": "no such exposure"})
return
}
reply(w, 200, exp)
})
mux.HandleFunc("DELETE /api/orgs/-/expose/abc", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
f.calls = append(f.calls, "delete")
f.mu.Unlock()
f.gone.Store(true)
reply(w, 200, map[string]bool{"ok": true})
})
// the tunnel agent long-polls this; there is nothing for it to do
mux.HandleFunc("/site/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })
f.srv = httptest.NewServer(mux)
t.Cleanup(f.srv.Close)
return f
}
func (f *fakeHammerhead) count(call string) int {
f.mu.Lock()
defer f.mu.Unlock()
n := 0
for _, c := range f.calls {
if c == call {
n++
}
}
return n
}
func TestRunRenewsAndDeletesOnStop(t *testing.T) {
f := newFake(t)
c := api.New(f.srv.URL, "hhk_x_y")
ctx, cancel := context.WithCancel(context.Background())
ready := make(chan api.Exposure, 1)
done := make(chan error, 1)
go func() {
done <- Run(ctx, c, Options{Request: api.Request{Proto: "tcp", TargetPort: 3000}, TTL: 15 * time.Second}, func(e api.Exposure) { ready <- e })
}()
e := <-ready
if e.Address() != "tcp://edge.test:20001" {
t.Errorf("address %q", e.Address())
}
if f.created.TTLSeconds != 15 || f.created.TargetPort != 3000 {
t.Errorf("request %+v", f.created)
}
cancel()
if err := <-done; err != nil {
t.Fatalf("clean stop returned %v", err)
}
if !f.gone.Load() {
t.Fatal("exposure was not deleted on stop")
}
}
func TestRunStopsWhenHammerheadForgetsTheExposure(t *testing.T) {
f := newFake(t)
f.renewErr = 404
c := api.New(f.srv.URL, "hhk_x_y")
// renewals are never sooner than 5s; a short TTL is enough to reach one
err := Run(context.Background(), c, Options{Request: api.Request{Proto: "tcp", TargetPort: 1}, TTL: 3 * time.Second}, nil)
if err == nil {
t.Fatal("expected an error once the exposure expired")
}
if !f.gone.Load() {
t.Error("cleanup should still ask Hammerhead to delete")
}
}
func TestRunRejectedToken(t *testing.T) {
f := newFake(t)
err := Run(context.Background(), api.New(f.srv.URL, "wrong"), Options{Request: api.Request{Proto: "tcp", TargetPort: 1}}, nil)
if !api.IsStatus(err, 401) {
t.Fatalf("got %v", err)
}
if f.count("delete") != 0 {
t.Error("deleted something that was never created")
}
}
func TestRunLoopSurvivesFlakyRenewals(t *testing.T) {
f := newFake(t)
f.renewErr = 500
c := api.New(f.srv.URL, "hhk_x_y")
ctx, cancel := context.WithTimeout(context.Background(), 7*time.Second)
defer cancel()
err := keepAlive(ctx, c, "abc", Options{TTL: time.Minute})
if err != nil {
t.Fatalf("a failing renewal inside the TTL should not end the run: %v", err)
}
}