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
32 changes: 32 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,38 @@ Apps run natively in the workspace (`bun run start:dev`) against it; reset with
workspace can open a TCP connection to this tenant's db container, and (b) the
db container is attached to exactly its own tenant network.

### Admin host SSH access (optional, per tenant)

Mark a tenant with `admin: true` in `tenants.yaml` to grant it SSH access to the
host from inside its workspace. This is **split-ownership**: vswarm carries the
flag and enforces invariants, but **vswarm NEVER touches host ssh config or
`authorized_keys`** — minting and delivering the key is the deployment layer's
job (for us, the `vswarm` Ansible role).

Contract the deployment layer implements:

- A dedicated ed25519 keypair per admin tenant (not the tenant's git key, so
revocation is independent and the sshd audit trail is clean).
- The **private** half is delivered to the well-known path `~/.ssh/vswarm-admin`
inside the tenant home (mode `0600`, uid `1000`).
- The **public** half goes into the host user's `authorized_keys`, source-pinned
to the tenant's own subnet (`from="172.31.<10+index>.0/24"`), so the key is
useless anywhere but that workspace. Revocation = flip `admin` off and
re-apply (the `authorized_keys` line is removed).

`vswarm doctor` gains two invariants:

- **(a)** no NON-admin tenant home contains a `~/.ssh/vswarm-admin` file — a
stranded admin key on a tenant that lost the flag fails the gate;
- **(b)** every admin tenant's `~/.ssh/vswarm-admin` exists with mode `0600`.

Usage from inside an admin workspace (the gateway is the tenant's own bridge
gateway, `172.31.<10+index>.1`, where index is the tenant's roster position):

```sh
ssh -i ~/.ssh/vswarm-admin ubuntu@172.31.10.1
```

## Commands the deployment layer runs

```bash
Expand Down
30 changes: 30 additions & 0 deletions cmd/vswarm/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ func cmdDoctor() error {
check("ssh perms 700 for "+t.Name, merr == nil && mode == 0o700, modeStr(mode, merr))
}

for _, t := range c.Tenants {
if t.Admin {
continue
}
_, err := os.Stat(adminKeyPath(t.Name))
check("no admin key in non-admin home: "+t.Name, os.IsNotExist(err), adminKeyDetail(err))
}

for _, t := range c.Tenants {
if !t.Admin {
continue
}
mode, merr := dirMode(adminKeyPath(t.Name))
check("admin key 0600 for "+t.Name, merr == nil && mode == 0o600, modeStr(mode, merr))
}

for _, t := range c.Tenants {
if !t.HasService("postgres") {
continue
Expand Down Expand Up @@ -153,6 +169,20 @@ func tokenFromLine(s string) string {
return fields[3]
}

func adminKeyPath(name string) string {
return filepath.Join("config", name, "home", ".ssh", "vswarm-admin")
}

func adminKeyDetail(err error) string {
if err == nil {
return "present"
}
if os.IsNotExist(err) {
return ""
}
return err.Error()
}

func dirMode(p string) (os.FileMode, error) {
fi, err := os.Stat(p)
if err != nil {
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type Tenant struct {
Email string
Name string
Services []string
Admin bool
}

func (t Tenant) HasService(name string) bool {
Expand Down Expand Up @@ -166,6 +167,8 @@ func applyTenant(t *Tenant, k, v string) error {
}
t.Services = append(t.Services, s)
}
case "admin":
t.Admin = parseBool(v)
}
return nil
}
Expand Down Expand Up @@ -251,6 +254,9 @@ func (c *Config) Save() error {
if len(t.Services) > 0 {
fmt.Fprintf(&b, " services: [%s]\n", strings.Join(t.Services, ", "))
}
if t.Admin {
b.WriteString(" admin: true\n")
}
}
return os.WriteFile(c.Path, []byte(b.String()), 0o644)
}
Expand Down
8 changes: 7 additions & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ tenants:
- email: alice@example.com
name: alice
services: [postgres]
admin: true
- email: bob@example.com
name: bob-dev
`
Expand Down Expand Up @@ -56,6 +57,9 @@ tenants:
if !got.Tenants[0].HasService("postgres") || got.Tenants[1].HasService("postgres") {
t.Fatalf("unexpected services: %#v", got.Tenants)
}
if !got.Tenants[0].Admin || got.Tenants[1].Admin {
t.Fatalf("unexpected admin flags: %#v", got.Tenants)
}

if err := got.Save(); err != nil {
t.Fatalf("Save() error = %v", err)
Expand All @@ -76,7 +80,9 @@ tenants:
roundTrip.ManageTunnel != got.ManageTunnel ||
roundTrip.EdgeExternal != got.EdgeExternal ||
len(roundTrip.Tenants) != len(got.Tenants) ||
!roundTrip.Tenants[0].HasService("postgres") {
!roundTrip.Tenants[0].HasService("postgres") ||
roundTrip.Tenants[0].Admin != got.Tenants[0].Admin ||
roundTrip.Tenants[1].Admin != got.Tenants[1].Admin {
t.Fatalf("round trip mismatch:\nwant %#v\ngot %#v", got, roundTrip)
}
}
Expand Down
5 changes: 5 additions & 0 deletions tenants.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,14 @@ token_ttl: 30d
# does not support block-style `-` lists). Only known services are accepted;
# an unknown name is rejected at parse time. `postgres` starts a dev sidecar
# and delivers ~/.pg.env into the tenant home (see docs/DEPLOYMENT.md).
# Optional `admin: true` marks a tenant for host SSH access. vswarm NEVER
# touches host ssh config or authorized_keys — the deployment layer mints the
# key at ~/.ssh/vswarm-admin; vswarm only carries the flag and enforces
# doctor invariants around the key file (see docs/DEPLOYMENT.md).
tenants:
- email: sarah@example.com
name: sarah
services: [postgres]
admin: true
- email: alex@example.com
name: alex