Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion cmd/task/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -3810,13 +3815,20 @@ 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)
}
},
}
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
Expand Down
85 changes: 85 additions & 0 deletions cmd/task/serve_addr.go
Original file line number Diff line number Diff line change
@@ -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
}
60 changes: 60 additions & 0 deletions cmd/task/serve_addr_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}