219 lines
6.5 KiB
Go
219 lines
6.5 KiB
Go
// 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
|
|
}
|