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

+272
View File
@@ -0,0 +1,272 @@
// Package e2e runs the shark CLI against a real Hammerhead, Blacktip and Bamboo in one process.
package e2e
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.veillax.com/Veillax/blacktip"
"git.veillax.com/Veillax/hammerhead"
"git.veillax.com/Veillax/shark-expose/internal/cli"
)
type stack struct {
url, key string
admin *http.Client
org string
}
func freeUDPPort(t *testing.T) int {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer pc.Close()
return pc.LocalAddr().(*net.UDPAddr).Port
}
func freePort(t *testing.T) int {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
func (s *stack) call(t *testing.T, method, path string, in any) []byte {
t.Helper()
var body io.Reader
if in != nil {
b, _ := json.Marshal(in)
body = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, s.url+path, body)
req.Header.Set("Content-Type", "application/json")
resp, err := s.admin.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
t.Fatalf("%s %s -> %d %s", method, path, resp.StatusCode, raw)
}
return raw
}
// newStack starts Hammerhead and one Blacktip edge with a domain, makes an API key, and points
// shark's settings at it.
func newStack(t *testing.T) *stack {
t.Helper()
s := &stack{}
var handler http.Handler
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler.ServeHTTP(w, r) }))
t.Cleanup(ts.Close)
hh, err := hammerhead.New(t.TempDir(), hammerhead.Options{AdminEmail: "admin@test.local", AdminPassword: "password123", DashboardURL: ts.URL})
if err != nil {
t.Fatal(err)
}
t.Cleanup(hh.Close)
handler = hh.Handler()
s.url = ts.URL
jar, _ := cookiejar.New(nil)
s.admin = &http.Client{Jar: jar, Timeout: 15 * time.Second}
s.call(t, "POST", "/api/login", map[string]string{"email": "admin@test.local", "password": "password123"})
var me struct{ Orgs []struct{ ID string } }
json.Unmarshal(s.call(t, "GET", "/api/me", nil), &me)
s.org = me.Orgs[0].ID
tunnel := freeUDPPort(t)
var edge struct{ ID, Token string }
json.Unmarshal(s.call(t, "POST", "/api/edges", map[string]any{"name": "edge", "tunnelAddr": "127.0.0.1:" + strconv.Itoa(tunnel)}), &edge)
s.call(t, "POST", "/api/orgs/"+s.org+"/domains", map[string]any{"name": "dev.test", "edgeId": edge.ID})
var key struct{ Key string }
json.Unmarshal(s.call(t, "POST", "/api/orgs/"+s.org+"/apikeys", map[string]string{"name": "shark"}), &key)
s.key = key.Key
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
bt, err := blacktip.New(blacktip.Config{Hammerhead: ts.URL, Token: edge.Token, TunnelAddr: "127.0.0.1:" + strconv.Itoa(tunnel), DataDir: t.TempDir(),
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return nil, fmt.Errorf("no tls in this test") }})
if err != nil {
t.Fatal(err)
}
go bt.Run(ctx)
t.Setenv("SHARK_CONFIG", t.TempDir()+"/config.json")
t.Setenv("SHARK_HAMMERHEAD", s.url)
t.Setenv("SHARK_TOKEN", s.key)
return s
}
// syncBuf is a buffer that can be read while shark is still writing to it.
type syncBuf struct {
mu sync.Mutex
b bytes.Buffer
}
func (s *syncBuf) Write(p []byte) (int, error) { s.mu.Lock(); defer s.mu.Unlock(); return s.b.Write(p) }
func (s *syncBuf) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() }
var addrLine = regexp.MustCompile(`shark (\S+)\s+->`)
// shark runs `shark <args>` and waits for it to print its public address. stop is Ctrl+C: it
// waits for shark to exit and returns the exit code.
func shark(t *testing.T, args ...string) (addr string, stop func() int) {
t.Helper()
out, errb := &syncBuf{}, &syncBuf{}
ctx, cancel := context.WithCancel(context.Background())
code := make(chan int, 1)
go func() { code <- cli.Run(ctx, args, out, errb) }()
var once sync.Once
var exit int
stop = func() int {
once.Do(func() {
cancel()
select {
case exit = <-code:
case <-time.After(20 * time.Second):
t.Error("shark did not exit after Ctrl+C")
}
})
return exit
}
t.Cleanup(func() { stop() })
for i := 0; i < 200; i++ {
if m := addrLine.FindStringSubmatch(out.String()); m != nil {
return m[1], stop
}
select {
case c := <-code:
t.Fatalf("shark exited early (%d): %s%s", c, out.String(), errb.String())
case <-time.After(100 * time.Millisecond):
}
}
t.Fatalf("no address printed: %s%s", out.String(), errb.String())
return
}
func (s *stack) exposures(t *testing.T) int {
t.Helper()
req, _ := http.NewRequest("GET", s.url+"/api/orgs/-/expose", nil)
req.Header.Set("Authorization", "Bearer "+s.key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var list []map[string]any
json.NewDecoder(resp.Body).Decode(&list)
return len(list)
}
func TestSharkTCP(t *testing.T) {
s := newStack(t)
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
go func() {
for {
c, err := l.Accept()
if err != nil {
return
}
go func() {
defer c.Close()
b := make([]byte, 64)
n, _ := c.Read(b)
fmt.Fprintf(c, "echo:%s", b[:n])
}()
}
}()
addr, stop := shark(t, "expose", "tcp", strconv.Itoa(l.Addr().(*net.TCPAddr).Port))
hostport := strings.TrimPrefix(addr, "tcp://")
var got string
for i := 0; i < 60 && got != "echo:hi"; i++ {
if c, err := net.DialTimeout("tcp", hostport, time.Second); err == nil {
c.SetDeadline(time.Now().Add(2 * time.Second))
fmt.Fprint(c, "hi")
b, _ := io.ReadAll(c)
c.Close()
got = string(b)
}
time.Sleep(150 * time.Millisecond)
}
if got != "echo:hi" {
t.Fatalf("through shark: %q", got)
}
if n := s.exposures(t); n != 1 {
t.Fatalf("want 1 exposure while running, have %d", n)
}
if code := stop(); code != 0 {
t.Errorf("exit code %d", code)
}
if n := s.exposures(t); n != 0 {
t.Errorf("exposure left behind after exit: %d", n)
}
closed := false
for i := 0; i < 30 && !closed; i++ {
c, err := net.DialTimeout("tcp", hostport, 300*time.Millisecond)
if err != nil {
closed = true
break
}
c.Close()
time.Sleep(200 * time.Millisecond)
}
if !closed {
t.Error("public port still open after exit")
}
}
func TestSharkHTTPWithPassword(t *testing.T) {
s := newStack(t)
app := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "hello from the app") }))
defer app.Close()
port := freePort(t)
addr, stop := shark(t, "expose", app.Listener.Addr().String(), "--name", "demo", "--port", strconv.Itoa(port), "--password", "hunter22")
host := strings.TrimPrefix(addr, "http://")
host = host[:strings.LastIndex(host, ":")]
if !strings.HasPrefix(host, "demo-") || !strings.HasSuffix(host, ".dev.test") {
t.Fatalf("public name %q", host)
}
// the edge is on loopback, so dial it directly while presenting the public host name
hc := &http.Client{Timeout: 5 * time.Second,
Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "tcp", "127.0.0.1:"+strconv.Itoa(port))
}},
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
var status int
var loc string
for i := 0; i < 60 && status != 302; i++ {
if resp, err := hc.Get(addr + "/"); err == nil {
resp.Body.Close()
status, loc = resp.StatusCode, resp.Header.Get("Location")
}
time.Sleep(150 * time.Millisecond)
}
if status != 302 || !strings.HasPrefix(loc, s.url+"/auth/r/") {
t.Fatalf("a protected site should redirect to the login, got %d %q", status, loc)
}
stop()
if n := s.exposures(t); n != 0 {
t.Errorf("exposure left behind: %d", n)
}
}
+40
View File
@@ -0,0 +1,40 @@
module git.veillax.com/Veillax/shark-expose/e2e
go 1.27.3
// The ephemeral-exposure API is on Hammerhead's feature/ephemeral-expose branch; until it is
// released, point this at a checkout of that branch.
replace git.veillax.com/Veillax/hammerhead => ../../hammerhead-expose
require (
git.veillax.com/Veillax/blacktip v0.1.9
git.veillax.com/Veillax/hammerhead v0.0.0
git.veillax.com/Veillax/shark-expose v0.0.0
)
require (
git.veillax.com/Veillax/bamboo v0.1.5 // indirect
git.veillax.com/Veillax/reef v0.1.10 // indirect
github.com/Microsoft/go-winio v0.6.4 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/emersion/go-msgauth v0.7.0 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.58.0 // indirect
golang.org/x/net v0.61.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
golang.org/x/time v0.7.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee // indirect
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
modernc.org/libc v1.79.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.61.0 // indirect
)
replace git.veillax.com/Veillax/shark-expose => ../
+76
View File
@@ -0,0 +1,76 @@
git.veillax.com/Veillax/bamboo v0.1.5 h1:i81c75/7wjayP9tOQ3tSnJWTBhvbjAwNBEPhT+3P2Xk=
git.veillax.com/Veillax/bamboo v0.1.5/go.mod h1:se4kkcP6c3uCGD4HQd+nDLoblS0lN8qa+eFWLo5Horw=
git.veillax.com/Veillax/blacktip v0.1.9 h1:MMkF8GCrN01IWwwii1WM5l+nqIbHsOznyrbG7OvDtoo=
git.veillax.com/Veillax/blacktip v0.1.9/go.mod h1:9mYwKMiKXN/P0dpPtdTzG7zf1IeHt/aISa0SQD9rlzY=
git.veillax.com/Veillax/reef v0.1.10 h1:b15oD1vyrGOriCHhipQtJ3j7Cy5ForYWvfJkqifLQPg=
git.veillax.com/Veillax/reef v0.1.10/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck=
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee h1:hByBPN/P/BVzJ591rJfvNhdoL94vmBtRbnVw77QJL20=
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=