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

+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
}