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:
commit
61143d107c
15 files changed
+1538
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user