Skip to content

Commit 08c4927

Browse files
committed
Add sr server installer
1 parent 6a4fa58 commit 08c4927

18 files changed

Lines changed: 651 additions & 285 deletions

README.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ This repo sets `CGO_ENABLED=0` in `Makefile` because the local macOS Go 1.22 too
2222

2323
## Install
2424

25+
Install the released Go binary directly:
26+
27+
```bash
28+
curl -fsSL https://raw.githubusercontent.com/manaflow-ai/subrouter/main/install.sh | sh
29+
```
30+
31+
On a Linux server, install to `/usr/local/bin`:
32+
33+
```bash
34+
curl -fsSL https://raw.githubusercontent.com/manaflow-ai/subrouter/main/install.sh | sudo sh
35+
```
36+
2537
Install with npm:
2638

2739
```bash
@@ -34,7 +46,7 @@ Install with Python:
3446
pipx install subrouter
3547
```
3648

37-
Both packages install `subrouter`, `sr`, and `cx`. The wrappers download the matching Go release binary for macOS, Linux, Windows, FreeBSD, OpenBSD, or NetBSD on amd64, arm64, or supported 32-bit variants. Set `SUBROUTER_BIN` to use a local binary instead.
49+
All install paths provide `subrouter`, `sr`, and `cx`. The npm and Python wrappers download the matching Go release binary for macOS, Linux, Windows, FreeBSD, OpenBSD, or NetBSD on amd64, arm64, or supported 32-bit variants. Set `SUBROUTER_BIN` to use a local binary instead.
3850

3951
## Local macOS daemon
4052

@@ -51,7 +63,22 @@ This installs the binary to `~/bin/subrouter`, installs `~/bin/cx` as a symlink
5163
~/bin/subrouter serve --addr 127.0.0.1:31415 --transcripts ~/.subrouter/transcripts --cx-switch-interval 10m
5264
```
5365

54-
The 10 minute `cx` auto-switch interval is the default. Override it with `subrouter install-daemon --cx-switch-interval 5m`, or disable it with `--cx-switch-interval 0`. This command is macOS-specific; use a systemd unit or another supervisor on Linux.
66+
The 10 minute `cx` auto-switch interval is the default. Override it with `subrouter install-daemon --cx-switch-interval 5m`, or disable it with `--cx-switch-interval 0`.
67+
68+
## Linux systemd service
69+
70+
On a Linux server, install the binary and service:
71+
72+
```bash
73+
curl -fsSL https://raw.githubusercontent.com/manaflow-ai/subrouter/main/install.sh | sudo sh
74+
sudo sr install-systemd --addr 0.0.0.0:31415
75+
```
76+
77+
This creates a `subrouter` system user, stores state under `/var/lib/subrouter`, writes `/etc/systemd/system/subrouter.service`, installs `subrouter`, `sr`, and `cx` in `/usr/local/bin`, and starts:
78+
79+
```bash
80+
/usr/local/bin/subrouter serve --addr 0.0.0.0:31415 --sessions /var/lib/subrouter/sessions.json --transcripts /var/lib/subrouter/transcripts --cx-switch-interval 10m
81+
```
5582

5683
Useful endpoints:
5784

cmd/subrouter/cx.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Usage:
5353
5454
cx server Manage Subrouter servers
5555
cx server add <name> --url <url> --gcp-instance <name> --gcp-zone <zone> [--gcp-project <project>]
56+
cx server install <name>
5657
cx server login <name> [--device-auth]
5758
5859
cx admin-keys List stored OpenAI admin keys

cmd/subrouter/cx_server.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,13 @@ Usage:
2828
cx server add <name> --url <url> --gcp-instance <name> --gcp-zone <zone> [--gcp-project <project>]
2929
cx server remove <name>
3030
cx server status <name>
31+
cx server install <name> [--version latest]
3132
cx server login <name> [--device-auth]
3233
3334
`
3435

36+
const publicInstallScriptURL = "https://raw.githubusercontent.com/manaflow-ai/subrouter/main/install.sh"
37+
3538
type cxServerStore struct {
3639
Path string
3740
}
@@ -69,6 +72,8 @@ func (r cxRunner) server(ctx context.Context, args []string) error {
6972
return fmt.Errorf("usage: cx server status <name>")
7073
}
7174
return r.serverStatus(ctx, store, args[1])
75+
case "install":
76+
return r.serverInstall(ctx, store, args[1:])
7277
case "login", "add-account", "add-auth":
7378
return r.serverLogin(ctx, store, args[1:])
7479
case "help", "-h", "--help":
@@ -260,6 +265,61 @@ func (r cxRunner) serverStatus(ctx context.Context, store cxServerStore, name st
260265
return err
261266
}
262267

268+
func (r cxRunner) serverInstall(ctx context.Context, store cxServerStore, args []string) error {
269+
if len(args) == 0 {
270+
return fmt.Errorf("usage: cx server install <name> [--version latest]")
271+
}
272+
name := args[0]
273+
flags := flag.NewFlagSet("cx server install", flag.ContinueOnError)
274+
flags.SetOutput(r.errOut)
275+
version := flags.String("version", "latest", "Subrouter release version to install")
276+
addr := flags.String("addr", "0.0.0.0:31415", "server listen address")
277+
cxSwitchInterval := flags.String("cx-switch-interval", "10m", "cx auto-switch interval; 0 disables")
278+
extraArgs := flags.String("extra-args", "", "extra arguments appended to subrouter serve")
279+
tailscaleHostname := flags.String("tailscale-hostname", "", "hostname for tailscale up when TAILSCALE_AUTH_KEY is set")
280+
if err := flags.Parse(args[1:]); err != nil {
281+
return err
282+
}
283+
server, ok, err := store.find(name)
284+
if err != nil {
285+
return err
286+
}
287+
if !ok {
288+
return fmt.Errorf("server %q not found", name)
289+
}
290+
if server.GCPInstance == "" || server.GCPZone == "" {
291+
return fmt.Errorf("server %s has no GCP target", server.Name)
292+
}
293+
hostname := *tailscaleHostname
294+
if hostname == "" {
295+
hostname = server.GCPInstance
296+
}
297+
tailscaleAuthKey := strings.TrimSpace(os.Getenv("TAILSCALE_AUTH_KEY"))
298+
remoteCommand := strings.Join([]string{
299+
"set -eu",
300+
"tailscale_auth_key=''",
301+
"read -r tailscale_auth_key || true",
302+
"curl -fsSL " + shellQuote(publicInstallScriptURL) + " | sudo env SUBROUTER_VERSION=" + shellQuote(*version) + " sh",
303+
"sudo /usr/local/bin/sr install-systemd --addr " + shellQuote(*addr) + " --cx-switch-interval " + shellQuote(*cxSwitchInterval) + " --extra-args " + shellQuote(*extraArgs),
304+
"if [ -n \"$tailscale_auth_key\" ]; then sudo tailscale up --auth-key \"$tailscale_auth_key\" --hostname " + shellQuote(hostname) + " --ssh --accept-routes=false --accept-dns=false; fi",
305+
"curl -fsS http://127.0.0.1:31415/_subrouter/health >/dev/null",
306+
"/usr/local/bin/sr --help >/dev/null",
307+
}, "\n")
308+
sshArgs := []string{"compute", "ssh", server.GCPInstance, "--zone", server.GCPZone, "--command", remoteCommand}
309+
if server.GCPProject != "" {
310+
sshArgs = append(sshArgs, "--project", server.GCPProject)
311+
}
312+
stdin := strings.NewReader(tailscaleAuthKey + "\n")
313+
if err := r.commandRunner().Run(ctx, "gcloud", sshArgs, stdin, r.out, r.errOut); err != nil {
314+
return fmt.Errorf("install server: %w", err)
315+
}
316+
fmt.Fprintf(r.out, "Installed Subrouter server: %s\n", server.Name)
317+
if tailscaleAuthKey != "" {
318+
fmt.Fprintf(r.out, "Joined Tailscale as: %s\n", hostname)
319+
}
320+
return nil
321+
}
322+
263323
func (r cxRunner) serverLogin(ctx context.Context, store cxServerStore, args []string) error {
264324
if len(args) == 0 {
265325
return fmt.Errorf("usage: cx server login <name> [--device-auth]")

cmd/subrouter/cx_server_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,52 @@ func TestCXServerLoginUploadsFreshAuthAndRestoresLocalChain(t *testing.T) {
107107
}
108108
}
109109

110+
func TestCXServerInstallUsesPublicInstallerAndSystemdCommand(t *testing.T) {
111+
home := t.TempDir()
112+
t.Setenv("HOME", home)
113+
t.Setenv("TAILSCALE_AUTH_KEY", "tailscale-auth-test-secret")
114+
store := accounts.DefaultCodexStore()
115+
116+
var out bytes.Buffer
117+
fake := &recordingCXCommandRunner{}
118+
runner := cxRunner{store: store, out: &out, errOut: &out, cmd: fake}
119+
if err := runner.run(context.Background(), []string{
120+
"server", "add", "community",
121+
"--url", "http://100.64.0.1:31415",
122+
"--gcp-instance", "subrouter-community",
123+
"--gcp-zone", "us-central1-a",
124+
"--gcp-project", "example-project",
125+
}); err != nil {
126+
t.Fatal(err)
127+
}
128+
129+
if err := runner.run(context.Background(), []string{"server", "install", "community", "--version", "0.1.2"}); err != nil {
130+
t.Fatal(err)
131+
}
132+
133+
if !fake.hasCommandPrefix("gcloud", "compute", "ssh", "subrouter-community") {
134+
t.Fatalf("missing gcloud ssh install command: %#v", fake.commands)
135+
}
136+
joined := strings.Join(fake.commands[0], " ")
137+
if strings.Contains(joined, "tailscale-auth-test-secret") {
138+
t.Fatalf("tailscale auth key leaked into command: %s", joined)
139+
}
140+
installCommand := strings.Join(fake.commands[len(fake.commands)-1], " ")
141+
for _, want := range []string{
142+
publicInstallScriptURL,
143+
"SUBROUTER_VERSION='0.1.2'",
144+
"/usr/local/bin/sr install-systemd",
145+
"tailscale up",
146+
} {
147+
if !strings.Contains(installCommand, want) {
148+
t.Fatalf("install command missing %q:\n%s", want, installCommand)
149+
}
150+
}
151+
if !strings.Contains(out.String(), "Installed Subrouter server: community") {
152+
t.Fatalf("missing install message:\n%s", out.String())
153+
}
154+
}
155+
110156
type recordingCXCommandRunner struct {
111157
loginAuth accounts.CodexAuthFile
112158
commands [][]string

0 commit comments

Comments
 (0)