Skip to content

Commit f66b908

Browse files
authored
Merge pull request #24 from buildkite/codex/tailscale-services-listen
feat(server): add Tailscale Services listen mode
2 parents 06a663e + 5fa9e7e commit f66b908

File tree

9 files changed

+590
-10
lines changed

9 files changed

+590
-10
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,19 @@ Then connect with:
5656
cleanroom exec --host http://cleanroom.tailnet.ts.net:7777 -c /path/to/repo -- "npm test"
5757
```
5858

59+
To expose the API as a Tailscale Service using the local `tailscaled` daemon:
60+
61+
```bash
62+
cleanroom serve --listen tssvc://cleanroom
63+
```
64+
65+
This configures `svc:cleanroom` with HTTPS on port 443 and advertises the
66+
service from this host. Connect from another tailnet device with:
67+
68+
```bash
69+
cleanroom exec --host https://cleanroom.<your-tailnet>.ts.net -- "npm test"
70+
```
71+
5972
### 3) Launch -> Run -> Terminate via API
6073

6174
```bash

internal/cli/cli.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ type ConsoleCommand struct {
8080
}
8181

8282
type ServeCommand struct {
83-
Listen string `help:"Listen endpoint for control API (defaults to runtime endpoint; supports tsnet://hostname[:port])"`
83+
Listen string `help:"Listen endpoint for control API (defaults to runtime endpoint; supports tsnet://hostname[:port] and tssvc://service[:local-port])"`
8484
LogLevel string `help:"Server log level (debug|info|warn|error)"`
8585
}
8686

@@ -205,6 +205,9 @@ func (e *ExecCommand) Run(ctx *runtimeContext) error {
205205
if err != nil {
206206
return err
207207
}
208+
if err := validateClientEndpoint(ep); err != nil {
209+
return err
210+
}
208211
var cwd string
209212
if ep.Scheme != "unix" {
210213
if e.Chdir == "" {
@@ -410,6 +413,9 @@ func (c *ConsoleCommand) Run(ctx *runtimeContext) error {
410413
if err != nil {
411414
return err
412415
}
416+
if err := validateClientEndpoint(ep); err != nil {
417+
return err
418+
}
413419
var cwd string
414420
if ep.Scheme != "unix" {
415421
if c.Chdir == "" {
@@ -728,6 +734,13 @@ func resolveBackendName(requested, configuredDefault string) string {
728734
return "firecracker"
729735
}
730736

737+
func validateClientEndpoint(ep endpoint.Endpoint) error {
738+
if ep.Scheme != "tssvc" {
739+
return nil
740+
}
741+
return errors.New("tssvc:// endpoints are listen-only; use https://<service>.<your-tailnet>.ts.net for --host")
742+
}
743+
731744
func mergeFirecrackerConfig(cwd string, e *ExecCommand, cfg runtimeconfig.Config) backend.FirecrackerConfig {
732745
out := backend.FirecrackerConfig{
733746
BinaryPath: cfg.Backends.Firecracker.BinaryPath,

internal/cli/console_integration_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,20 @@ func TestConsoleIntegrationInterruptCancelsExecution(t *testing.T) {
203203
t.Fatalf("unexpected console exit code: got %d want %d (err=%v)", got, want, outcome.err)
204204
}
205205
}
206+
207+
func TestConsoleRejectsTailscaleServiceListenEndpointAsHost(t *testing.T) {
208+
outcome := runConsoleWithCapture(ConsoleCommand{
209+
Host: "tssvc://cleanroom",
210+
}, "", runtimeContext{
211+
CWD: t.TempDir(),
212+
})
213+
if outcome.cause != nil {
214+
t.Fatalf("capture failure: %v", outcome.cause)
215+
}
216+
if outcome.err == nil {
217+
t.Fatal("expected host validation error")
218+
}
219+
if !strings.Contains(outcome.err.Error(), "listen-only") {
220+
t.Fatalf("expected listen-only host error, got %v", outcome.err)
221+
}
222+
}

internal/cli/exec_integration_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,3 +442,21 @@ func TestParseSandboxID(t *testing.T) {
442442
t.Fatalf("expected empty sandbox id for invalid input, got %q", got)
443443
}
444444
}
445+
446+
func TestExecRejectsTailscaleServiceListenEndpointAsHost(t *testing.T) {
447+
outcome := runExecWithCapture(ExecCommand{
448+
Host: "tssvc://cleanroom",
449+
Command: []string{"echo", "hi"},
450+
}, runtimeContext{
451+
CWD: t.TempDir(),
452+
})
453+
if outcome.cause != nil {
454+
t.Fatalf("capture failure: %v", outcome.cause)
455+
}
456+
if outcome.err == nil {
457+
t.Fatal("expected host validation error")
458+
}
459+
if !strings.Contains(outcome.err.Error(), "listen-only") {
460+
t.Fatalf("expected listen-only host error, got %v", outcome.err)
461+
}
462+
}

internal/controlserver/server.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,25 @@ type tsnetServer interface {
4040
Close() error
4141
}
4242

43-
var newTSNetServer = func(ep endpoint.Endpoint, stateDir string) tsnetServer {
43+
var newTSNetServer = func(ep endpoint.Endpoint, stateDir string, tsLogf func(format string, args ...any)) tsnetServer {
4444
return &tsnet.Server{
4545
Dir: stateDir,
4646
Hostname: ep.TSNetHostname,
47+
Logf: tsLogf,
48+
}
49+
}
50+
51+
func tsnetLogf(logger *log.Logger) func(format string, args ...any) {
52+
if logger == nil {
53+
return nil
54+
}
55+
tsLogger := logger.With("subsystem", "tsnet")
56+
return func(format string, args ...any) {
57+
msg := strings.TrimSpace(fmt.Sprintf(format, args...))
58+
if msg == "" {
59+
return
60+
}
61+
tsLogger.Debug(msg)
4762
}
4863
}
4964

@@ -606,7 +621,7 @@ func (s *Server) handleTerminateCleanroom(w http.ResponseWriter, r *http.Request
606621
}
607622

608623
func Serve(ctx context.Context, ep endpoint.Endpoint, handler http.Handler, logger *log.Logger) error {
609-
listener, cleanup, err := listen(ep)
624+
listener, cleanup, err := listen(ep, logger)
610625
if err != nil {
611626
return err
612627
}
@@ -653,7 +668,7 @@ func Serve(ctx context.Context, ep endpoint.Endpoint, handler http.Handler, logg
653668
}
654669
}
655670

656-
func listen(ep endpoint.Endpoint) (net.Listener, func() error, error) {
671+
func listen(ep endpoint.Endpoint, logger *log.Logger) (net.Listener, func() error, error) {
657672
if ep.Scheme == "unix" {
658673
if err := os.MkdirAll(filepath.Dir(ep.Address), 0o755); err != nil {
659674
return nil, nil, err
@@ -680,7 +695,7 @@ func listen(ep endpoint.Endpoint) (net.Listener, func() error, error) {
680695
if err := os.MkdirAll(stateDir, 0o700); err != nil {
681696
return nil, nil, fmt.Errorf("create tsnet state directory: %w", err)
682697
}
683-
server := newTSNetServer(ep, stateDir)
698+
server := newTSNetServer(ep, stateDir, tsnetLogf(logger))
684699
listener, err := server.Listen("tcp", ep.Address)
685700
if err != nil {
686701
_ = server.Close()
@@ -689,6 +704,20 @@ func listen(ep endpoint.Endpoint) (net.Listener, func() error, error) {
689704
return listener, server.Close, nil
690705
}
691706

707+
if ep.Scheme == "tssvc" {
708+
listener, err := net.Listen("tcp", ep.Address)
709+
if err != nil {
710+
return nil, nil, fmt.Errorf("start tailscale service listener for %q: %w", ep.Address, err)
711+
}
712+
setupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
713+
defer cancel()
714+
if err := configureTailscaleService(setupCtx, ep, listener.Addr().String()); err != nil {
715+
_ = listener.Close()
716+
return nil, nil, err
717+
}
718+
return listener, nil, nil
719+
}
720+
692721
if ep.Scheme == "https" {
693722
return nil, nil, errors.New("https listen endpoints are not supported yet: TLS configuration is not implemented")
694723
}

0 commit comments

Comments
 (0)