// 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] [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] [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 }