Skip to content

Commit 5586635

Browse files
committed
feat: support custom hosts with internal wildcard TLS
1 parent 5394d0c commit 5586635

8 files changed

Lines changed: 225 additions & 21 deletions

File tree

ARCHITECTURE.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
`devwrap` is a local development CLI that:
66

77
- Runs an app command with an assigned local app port.
8-
- Registers a host route like `myapp.localhost` in Caddy.
8+
- Registers a host route like `myapp.localhost` (or a custom `--host`) in Caddy.
99
- Uses Caddy as the reverse proxy and TLS terminator.
1010
- Reuses any existing Caddy Admin API when available.
1111
- Spawns its own embedded Caddy wrapper daemon only when needed.
@@ -14,6 +14,7 @@ Core user run shape:
1414

1515
```bash
1616
devwrap --name myapp -- <command...>
17+
devwrap --name myapp --host myapp.dev.test -- <command...>
1718
```
1819

1920
Example:
@@ -94,15 +95,16 @@ devwrap --name=<app> -- <cmd...>
9495
Flow:
9596

9697
1. Parse/validate app name (`[a-z0-9-]`, not leading/trailing `-`).
97-
2. Ensure Caddy Admin is available (unmanaged or managed).
98-
3. Acquire lease from file state and sync routes directly to Caddy Admin.
99-
4. Print HTTPS/HTTP URLs.
100-
5. Warn if Caddy local CA is not trusted.
101-
6. Run child command with:
98+
2. Resolve host (`--host` or default `<name>.localhost`) and validate hostname format.
99+
3. Ensure Caddy Admin is available (unmanaged or managed).
100+
4. Acquire lease from file state and sync routes directly to Caddy Admin.
101+
5. Print HTTPS/HTTP URLs.
102+
6. Warn if Caddy local CA is not trusted.
103+
7. Run child command with:
102104
- `PORT=<assigned-port>` in env
103105
- `DEVWRAP_APP=<name>` in env
104106
- `@PORT` token replacement in argv
105-
7. Forward signals to child; release lease on exit.
107+
8. Forward signals to child; release lease on exit.
106108

107109
### Proxy Commands
108110

@@ -179,7 +181,7 @@ Special handling:
179181
For each app, route created with:
180182

181183
- `@id: devwrap-<app-name>`
182-
- host match: `<app>.localhost`
184+
- host match: app host from state (`--host` override or `<app>.localhost`)
183185
- handler: reverse proxy to `127.0.0.1:<app-port>`
184186

185187
Route update behavior:
@@ -194,7 +196,7 @@ This preserves non-devwrap routes while replacing devwrap-managed entries.
194196

195197
## TLS + Trust
196198

197-
Embedded Caddy is configured with internal issuer for `localhost` and `*.localhost`.
199+
Embedded Caddy is configured with internal issuer for all subjects, so custom hosts still use devwrap's local CA.
198200

199201
For managed mode, embedded Caddy is configured with explicit file-system storage root so CA material is reusable:
200202

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# devwrap
22

3-
Run local app commands behind Caddy with friendly localhost hostnames.
3+
Run local app commands behind Caddy with friendly local hostnames.
44

55
## Install
66

@@ -22,12 +22,20 @@ curl -fsSL https://raw.githubusercontent.com/iterate/devwrap/main/install.sh | b
2222
devwrap --name myapp -- pnpm dev
2323
```
2424

25+
Use a custom host when needed:
26+
27+
```bash
28+
devwrap --name web --host web.dev.test -- pnpm dev
29+
```
30+
2531
Use `@PORT` when your app expects a CLI flag instead of env vars:
2632

2733
```bash
2834
devwrap --name dev-server -- vite dev --port @PORT
2935
```
3036

37+
By default hosts are `<name>.localhost`.
38+
3139
`devwrap` also sets `PORT=<allocated port>`, `DEVWRAP_APP=<name>`, and `DEVWRAP_HOST=<https url>` for the child process.
3240

3341
## Proxy Modes

cmd/devwrap/cli.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@ func run(args []string) error {
2020

2121
func newRootCommand() *cobra.Command {
2222
var name string
23+
var host string
2324
var privileged bool
2425

2526
root := &cobra.Command{
2627
Use: "devwrap --name <name> -- <cmd...>",
2728
Short: "Local dev reverse proxy helper",
28-
Long: "Run local apps behind Caddy and map <name>.localhost. Use @PORT in your command arguments to inject the allocated app port.",
29-
Example: " devwrap --name myapp -- pnpm dev\n devwrap --name api -- uvicorn app:app --port @PORT\n devwrap -p",
29+
Long: "Run local apps behind Caddy and map routes to local app ports. Use @PORT in your command arguments to inject the allocated app port.",
30+
Example: " devwrap --name myapp -- pnpm dev\n devwrap --name api -- uvicorn app:app --port @PORT\n devwrap --name web --host web.dev.test -- pnpm dev\n devwrap -p",
3031
SilenceUsage: true,
3132
SilenceErrors: true,
3233
Args: cobra.ArbitraryArgs,
@@ -46,7 +47,7 @@ func newRootCommand() *cobra.Command {
4647
}
4748
return errors.New("missing command after '--'")
4849
}
49-
return runApp(name, args, privileged)
50+
return runApp(name, host, args, privileged)
5051
},
5152
}
5253

@@ -62,6 +63,7 @@ func newRootCommand() *cobra.Command {
6263
})
6364

6465
root.Flags().StringVar(&name, "name", "", "App route name (e.g. myapp)")
66+
root.Flags().StringVar(&host, "host", "", "Custom hostname (default: <name>.localhost)")
6567
root.Flags().BoolVarP(&privileged, "privileged", "p", false, "Use sudo to spawn proxy if Caddy is not already running")
6668
root.PersistentFlags().BoolVar(&outputJSON, "json", false, "Output JSON for scripting")
6769

@@ -143,16 +145,21 @@ func helpOnArgValidationError(next cobra.PositionalArgs) cobra.PositionalArgs {
143145
}
144146
}
145147

146-
func runApp(name string, cmdArgs []string, privileged bool) error {
148+
func runApp(name, host string, cmdArgs []string, privileged bool) error {
147149
if err := validateName(name); err != nil {
148150
return err
149151
}
150152

153+
resolvedHost, err := hostForApp(name, host)
154+
if err != nil {
155+
return err
156+
}
157+
151158
if err := ensureCaddyOrDaemon(privileged); err != nil {
152159
return err
153160
}
154161

155-
lease, err := acquireLease(name, os.Getpid())
162+
lease, err := acquireLease(name, resolvedHost, os.Getpid())
156163
if err != nil {
157164
if checkDaemonReachable() {
158165
if path, logErr := daemonLogPath(); logErr == nil {

cmd/devwrap/client.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ func apiClient() *http.Client {
3131
return adminHTTPClient
3232
}
3333

34-
func acquireLease(name string, pid int) (Lease, error) {
35-
return requestLeaseDirect(name, pid)
34+
func acquireLease(name, host string, pid int) (Lease, error) {
35+
return requestLeaseDirect(name, host, pid)
3636
}
3737

3838
func releaseLeaseSelected(name string, pid int) {

cmd/devwrap/host.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"strings"
6+
)
7+
8+
func hostForApp(name, customHost string) (string, error) {
9+
if customHost == "" {
10+
return name + ".localhost", nil
11+
}
12+
host, err := normalizeHost(customHost)
13+
if err != nil {
14+
return "", err
15+
}
16+
return host, nil
17+
}
18+
19+
func normalizeHost(raw string) (string, error) {
20+
host := strings.ToLower(strings.TrimSpace(raw))
21+
if host == "" {
22+
return "", errors.New("host cannot be empty")
23+
}
24+
if strings.Contains(host, "://") {
25+
return "", errors.New("host must be a hostname without scheme")
26+
}
27+
if strings.Contains(host, "/") {
28+
return "", errors.New("host must not include a path")
29+
}
30+
if strings.Contains(host, ":") {
31+
return "", errors.New("host must not include a port")
32+
}
33+
if strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") || strings.Contains(host, "..") {
34+
return "", errors.New("host format is invalid")
35+
}
36+
for _, label := range strings.Split(host, ".") {
37+
if label == "" {
38+
return "", errors.New("host format is invalid")
39+
}
40+
if label[0] == '-' || label[len(label)-1] == '-' {
41+
return "", errors.New("host labels cannot start or end with '-'")
42+
}
43+
for _, r := range label {
44+
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
45+
continue
46+
}
47+
return "", errors.New("host can use lowercase letters, numbers, dots, and dashes")
48+
}
49+
}
50+
return host, nil
51+
}

cmd/devwrap/local_state.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os"
99
"sort"
1010
"strconv"
11+
"strings"
1112
"time"
1213
)
1314

@@ -114,21 +115,30 @@ func localStatusFromFiles() (ProxyStatus, error) {
114115
return out, nil
115116
}
116117

117-
func requestLeaseDirect(name string, pid int) (Lease, error) {
118+
func requestLeaseDirect(name, host string, pid int) (Lease, error) {
118119
var lease Lease
119120
err := withStateLock(func() error {
120121
state, err := loadLocalState()
121122
if err != nil {
122123
return err
123124
}
125+
appHost, err := hostForApp(name, host)
126+
if err != nil {
127+
return err
128+
}
124129
for appName, app := range state.Apps {
125130
if !processAlive(app.PID) {
126131
delete(state.Apps, appName)
132+
continue
133+
}
134+
if appName != name && strings.EqualFold(app.Host, appHost) {
135+
return fmt.Errorf("host %q is already used by app %q", appHost, appName)
127136
}
128137
}
129138

130139
app, ok := state.Apps[name]
131140
if ok {
141+
app.Host = appHost
132142
app.PID = pid
133143
app.StartedAt = time.Now().UTC().Format(time.RFC3339)
134144
} else {
@@ -138,7 +148,7 @@ func requestLeaseDirect(name string, pid int) (Lease, error) {
138148
}
139149
app = App{
140150
Name: name,
141-
Host: name + ".localhost",
151+
Host: appHost,
142152
Port: port,
143153
PID: pid,
144154
StartedAt: time.Now().UTC().Format(time.RFC3339),

cmd/devwrap/proxy_caddy.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@ func startEmbeddedCaddy(httpPort, httpsPort int) error {
3838
"tls": map[string]any{
3939
"automation": map[string]any{
4040
"policies": []map[string]any{{
41-
"subjects": []string{"localhost", "*.localhost"},
42-
"issuers": []map[string]any{{"module": "internal"}},
41+
"issuers": []map[string]any{{"module": "internal"}},
4342
}},
4443
},
4544
},

0 commit comments

Comments
 (0)