From c4c9ecafffd6c79143607325351435ec2fd41dc3 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Mon, 31 Aug 2026 14:32:00 -0500 Subject: [PATCH] feat(serve): add --host flag to bind ty serve to a specific address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ty serve hardcoded ":port", listening on every interface — including whatever untrusted network the machine happens to be on. The board can create and execute tasks, so that reachability matters. --host sets the bind address, so the board can be pinned to a tailnet address (ty serve --host 100.x.y.z) or to loopback. An empty host keeps today's ":port" behaviour exactly, so the Tauri sidecar and ty-chrome (both localhost) are unaffected. Invalid hosts fail fast with a clear message instead of a confusing net.Listen error, and IPv6 literals are bracketed via net.JoinHostPort. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VyWdPGetDrC7KeZXeg2jWd --- cmd/task/main.go | 14 +++++- cmd/task/serve_addr.go | 85 +++++++++++++++++++++++++++++++++++++ cmd/task/serve_addr_test.go | 60 ++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 cmd/task/serve_addr.go create mode 100644 cmd/task/serve_addr_test.go diff --git a/cmd/task/main.go b/cmd/task/main.go index 736482e5..15c15a24 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -3777,7 +3777,12 @@ External frontends (like ty-web) can build on top of this API. The server shares the same SQLite database the daemon writes to (WAL mode).`, Run: func(cmd *cobra.Command, args []string) { port, _ := cmd.Flags().GetInt("port") - addr := fmt.Sprintf(":%d", port) + host, _ := cmd.Flags().GetString("host") + addr, err := serveListenAddr(host, port) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } dbPath := db.DefaultPath() database, err := openTaskDB(dbPath) @@ -3810,6 +3815,12 @@ The server shares the same SQLite database the daemon writes to (WAL mode).`, srv.Shutdown(ctx) }() + if host == "" { + fmt.Printf("Binding %s (all interfaces - reachable from your local network)\n", addr) + } else { + fmt.Printf("Binding %s\n", addr) + } + if err := srv.Start(); err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Server error: "+err.Error())) os.Exit(1) @@ -3817,6 +3828,7 @@ The server shares the same SQLite database the daemon writes to (WAL mode).`, }, } serveCmd.Flags().Int("port", 8080, "Port to listen on") + serveCmd.Flags().String("host", "", "Address to bind to (e.g. 127.0.0.1 or a Tailscale IP). Empty binds all interfaces") rootCmd.AddCommand(serveCmd) // Bulk operations diff --git a/cmd/task/serve_addr.go b/cmd/task/serve_addr.go new file mode 100644 index 00000000..c86f82f2 --- /dev/null +++ b/cmd/task/serve_addr.go @@ -0,0 +1,85 @@ +package main + +import ( + "fmt" + "net" + "strconv" + "strings" +) + +// serveListenAddr builds the address `ty serve` listens on from the --host and +// --port flags. +// +// An empty host preserves the historical behaviour of binding every interface +// (":8080"). A host is bracketed when needed, so IPv6 literals such as "::1" +// become "[::1]:8080". Hosts that net.Listen would only reject later with a +// confusing error are rejected here instead. +func serveListenAddr(host string, port int) (string, error) { + if port < 1 || port > 65535 { + return "", fmt.Errorf("invalid port %d: must be between 1 and 65535", port) + } + + host = strings.TrimSpace(host) + if host == "" { + return fmt.Sprintf(":%d", port), nil + } + + // Accept a bracketed IPv6 literal ("[::1]") as well as a bare one. + unbracketed := host + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + unbracketed = host[1 : len(host)-1] + } + + if err := validateServeHost(unbracketed); err != nil { + return "", err + } + + return net.JoinHostPort(unbracketed, strconv.Itoa(port)), nil +} + +// validateServeHost accepts an IP literal (with an optional zone) or a DNS +// hostname, and rejects anything else with an actionable message. +func validateServeHost(host string) error { + if ip, _, found := strings.Cut(host, "%"); found { + // IPv6 zone, e.g. "fe80::1%en0" — the address itself must still parse. + if net.ParseIP(ip) == nil { + return fmt.Errorf("invalid host %q: not a valid IP address or hostname", host) + } + return nil + } + + if net.ParseIP(host) != nil { + return nil + } + + if isHostname(host) { + return nil + } + + return fmt.Errorf("invalid host %q: not a valid IP address or hostname", host) +} + +// isHostname reports whether s looks like a DNS name (RFC 1123 labels). +func isHostname(s string) bool { + if s == "" || len(s) > 253 { + return false + } + s = strings.TrimSuffix(s, ".") + for _, label := range strings.Split(s, ".") { + if label == "" || len(label) > 63 { + return false + } + if label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for i := 0; i < len(label); i++ { + c := label[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-': + default: + return false + } + } + } + return true +} diff --git a/cmd/task/serve_addr_test.go b/cmd/task/serve_addr_test.go new file mode 100644 index 00000000..023c13b2 --- /dev/null +++ b/cmd/task/serve_addr_test.go @@ -0,0 +1,60 @@ +package main + +import "testing" + +func TestServeListenAddr(t *testing.T) { + tests := []struct { + name string + host string + port int + want string + }{ + {"empty host binds all interfaces", "", 8080, ":8080"}, + {"whitespace host binds all interfaces", " ", 8080, ":8080"}, + {"ipv4 host", "127.0.0.1", 8080, "127.0.0.1:8080"}, + {"tailscale ip", "100.107.3.120", 8899, "100.107.3.120:8899"}, + {"hostname", "my-box.tail1234.ts.net", 8080, "my-box.tail1234.ts.net:8080"}, + {"ipv6 literal is bracketed", "::1", 8080, "[::1]:8080"}, + {"bracketed ipv6 stays single-bracketed", "[::1]", 8080, "[::1]:8080"}, + {"ipv6 unspecified", "::", 8080, "[::]:8080"}, + {"ipv6 with zone", "fe80::1%en0", 8080, "[fe80::1%en0]:8080"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := serveListenAddr(tt.host, tt.port) + if err != nil { + t.Fatalf("serveListenAddr(%q, %d) returned error: %v", tt.host, tt.port, err) + } + if got != tt.want { + t.Errorf("serveListenAddr(%q, %d) = %q, want %q", tt.host, tt.port, got, tt.want) + } + }) + } +} + +func TestServeListenAddrInvalid(t *testing.T) { + tests := []struct { + name string + host string + port int + }{ + {"host with scheme", "http://127.0.0.1", 8080}, + {"host with port", "127.0.0.1:8080", 8080}, + {"host with path", "127.0.0.1/board", 8080}, + {"host with space", "not a host", 8080}, + {"underscore label", "bad_host", 8080}, + {"leading dash label", "-nope", 8080}, + {"port zero", "127.0.0.1", 0}, + {"port too large", "127.0.0.1", 70000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := serveListenAddr(tt.host, tt.port) + if err == nil { + t.Fatalf("serveListenAddr(%q, %d) = %q, want error", tt.host, tt.port, got) + } + }) + } +}