Skip to content

Commit 020401f

Browse files
committed
Add path-based routing for Cloudflare free SSL compatibility
Path-based routing allows tunnel URLs like tunnel.example.com/id/ instead of id.tunnel.example.com, which works with Cloudflare's free Universal SSL certificate. Changes: - Add EXIO_ROUTING_MODE config (path or subdomain, default: path) - Server extracts tunnel ID from path and rewrites request before forwarding - Client queries /_config endpoint to determine URL format - Add comprehensive tests for path extraction and rewriting - Update documentation with routing mode configuration
1 parent 5f8763e commit 020401f

7 files changed

Lines changed: 402 additions & 31 deletions

File tree

README.md

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Exio is a developer tool that creates secure tunnels from your local machine to
1616
- **PSK authentication** - Simple shared-secret authentication model
1717
- **Interactive TUI** - Real-time request inspection with `--tui` flag
1818
- **Cloudflare-ready** - Designed to sit behind Cloudflare Tunnel for production deployments
19+
- **Flexible routing** - Path-based (`tunnel.example.com/id/`) or subdomain-based (`id.tunnel.example.com`) routing
1920

2021
## Installation
2122

@@ -59,13 +60,17 @@ This interactive wizard will prompt you for your server URL and authentication t
5960
# Expose local port 3000
6061
exio http 3000
6162

62-
# Request a specific subdomain
63+
# Request a specific tunnel ID
6364
exio http 3000 --subdomain my-app
6465

6566
# With real-time request viewer
6667
exio http 3000 --tui
6768
```
6869

70+
Your service will be available at a URL like:
71+
- **Path mode (default)**: `https://tunnel.example.com/my-app/`
72+
- **Subdomain mode**: `https://my-app.tunnel.example.com`
73+
6974
### Manual Configuration
7075

7176
Alternatively, configure via environment variables:
@@ -152,10 +157,20 @@ sudo systemctl start exiod
152157

153158
### Cloudflare Tunnel Configuration
154159

160+
**For path-based routing (recommended):**
161+
1. Create a Cloudflare Tunnel in your Zero Trust dashboard
162+
2. Configure public hostname: `tunnel.example.com`
163+
3. Set service: `http://localhost:8080`
164+
4. Add a single DNS CNAME record for `tunnel` pointing to your tunnel
165+
166+
**For subdomain-based routing:**
155167
1. Create a Cloudflare Tunnel in your Zero Trust dashboard
156-
2. Configure public hostname: `*.dev.example.com`
168+
2. Configure public hostname: `*.tunnel.example.com`
157169
3. Set service: `http://localhost:8080`
158-
4. The tunnel handles SSL termination; Exio receives plain HTTP
170+
4. Add a wildcard DNS CNAME record for `*.tunnel`
171+
5. Requires Cloudflare Advanced Certificate Manager for SSL on `*.tunnel.example.com`
172+
173+
The tunnel handles SSL termination; Exio receives plain HTTP.
159174

160175
## Protocol Details
161176

@@ -167,8 +182,20 @@ The client establishes a WebSocket connection to `/_connect` with:
167182

168183
### Data Plane
169184

170-
1. Server receives HTTP request for `<subdomain>.dev.example.com`
171-
2. Server extracts subdomain from Host header
185+
**Path-based routing (default):**
186+
1. Server receives HTTP request for `tunnel.example.com/my-app/api/users`
187+
2. Server extracts tunnel ID (`my-app`) from the first path segment
188+
3. Server rewrites path to `/api/users` (strips tunnel ID prefix)
189+
4. Server looks up session in registry
190+
5. Server opens new Yamux stream to client
191+
6. Server writes modified HTTP request to stream
192+
7. Client reads request, forwards to local service
193+
8. Client writes response back to stream
194+
9. Server copies response to original HTTP response writer
195+
196+
**Subdomain-based routing:**
197+
1. Server receives HTTP request for `my-app.tunnel.example.com/api/users`
198+
2. Server extracts subdomain (`my-app`) from Host header
172199
3. Server looks up session in registry
173200
4. Server opens new Yamux stream to client
174201
5. Server writes raw HTTP request to stream
@@ -209,7 +236,15 @@ Use `--no-rewrite-host` to disable this behavior if your local service requires
209236
|------|-------------|-------------|
210237
| `--port, -p` | `EXIO_PORT` | Listening port (default: 8080) |
211238
| `--token, -t` | `EXIO_TOKEN` | Authentication token (required) |
212-
| `--domain, -d` | `EXIO_BASE_DOMAIN` | Base domain for subdomains (required) |
239+
| `--domain, -d` | `EXIO_BASE_DOMAIN` | Base domain for tunnel URLs (required) |
240+
| `--routing-mode, -r` | `EXIO_ROUTING_MODE` | Routing mode: `path` (default) or `subdomain` |
241+
242+
### Routing Modes
243+
244+
| Mode | URL Format | SSL Requirements |
245+
|------|------------|------------------|
246+
| `path` | `https://tunnel.example.com/my-app/` | Standard SSL (free Cloudflare) |
247+
| `subdomain` | `https://my-app.tunnel.example.com` | Wildcard SSL (Advanced Certificate Manager) |
213248

214249
## License
215250

cmd/exiod/main.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,13 @@ func init() {
4545
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.exiod.yaml)")
4646
rootCmd.Flags().IntP("port", "p", 8080, "Server listening port")
4747
rootCmd.Flags().StringP("token", "t", "", "Authentication token")
48-
rootCmd.Flags().StringP("domain", "d", "", "Base domain for tunnel subdomains")
48+
rootCmd.Flags().StringP("domain", "d", "", "Base domain for tunnel URLs")
49+
rootCmd.Flags().StringP("routing-mode", "r", "path", "Routing mode: 'path' (tunnel.example.com/id/) or 'subdomain' (id.tunnel.example.com)")
4950

5051
viper.BindPFlag("port", rootCmd.Flags().Lookup("port"))
5152
viper.BindPFlag("token", rootCmd.Flags().Lookup("token"))
5253
viper.BindPFlag("domain", rootCmd.Flags().Lookup("domain"))
54+
viper.BindPFlag("routing-mode", rootCmd.Flags().Lookup("routing-mode"))
5355

5456
// Add version command
5557
rootCmd.AddCommand(&cobra.Command{
@@ -80,15 +82,22 @@ func initConfig() {
8082
viper.BindEnv("port", "EXIO_PORT")
8183
viper.BindEnv("token", "EXIO_TOKEN")
8284
viper.BindEnv("domain", "EXIO_BASE_DOMAIN")
85+
viper.BindEnv("routing-mode", "EXIO_ROUTING_MODE")
8386

8487
viper.ReadInConfig()
8588
}
8689

8790
func runServer(cmd *cobra.Command, args []string) error {
91+
routingMode := viper.GetString("routing-mode")
92+
if routingMode == "" {
93+
routingMode = "path" // Default to path-based routing
94+
}
95+
8896
config := &server.Config{
89-
Port: viper.GetInt("port"),
90-
Token: viper.GetString("token"),
91-
BaseDomain: viper.GetString("domain"),
97+
Port: viper.GetInt("port"),
98+
Token: viper.GetString("token"),
99+
BaseDomain: viper.GetString("domain"),
100+
RoutingMode: routingMode,
92101
}
93102

94103
if config.Token == "" {
@@ -99,6 +108,10 @@ func runServer(cmd *cobra.Command, args []string) error {
99108
return fmt.Errorf("base domain is required (set EXIO_BASE_DOMAIN or use --domain)")
100109
}
101110

111+
if routingMode != "path" && routingMode != "subdomain" {
112+
return fmt.Errorf("invalid routing mode '%s': must be 'path' or 'subdomain'", routingMode)
113+
}
114+
102115
srv, err := server.New(config)
103116
if err != nil {
104117
return fmt.Errorf("failed to create server: %w", err)

docs/DEPLOYMENT.md

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,13 @@ EXIO_PORT=8080
5252
# openssl rand -hex 32
5353
EXIO_TOKEN=your-secret-token-here
5454
55-
# Base domain for tunnel subdomains
56-
EXIO_BASE_DOMAIN=dev.example.com
55+
# Base domain for tunnel URLs
56+
EXIO_BASE_DOMAIN=tunnel.example.com
57+
58+
# Routing mode: "path" (default) or "subdomain"
59+
# - path: URLs like https://tunnel.example.com/my-app/
60+
# - subdomain: URLs like https://my-app.tunnel.example.com
61+
EXIO_ROUTING_MODE=path
5762
EOF
5863

5964
# Secure the config file
@@ -131,13 +136,32 @@ cloudflared tunnel create exio
131136

132137
Create `/etc/cloudflared/config.yml`:
133138

139+
**For path-based routing (recommended):**
140+
141+
```yaml
142+
tunnel: <TUNNEL_ID>
143+
credentials-file: /etc/cloudflared/<TUNNEL_ID>.json
144+
145+
ingress:
146+
# Single hostname for path-based routing
147+
- hostname: "tunnel.example.com"
148+
service: http://localhost:8080
149+
# Catch-all (required)
150+
- service: http_status:404
151+
```
152+
153+
**For subdomain-based routing:**
154+
134155
```yaml
135156
tunnel: <TUNNEL_ID>
136157
credentials-file: /etc/cloudflared/<TUNNEL_ID>.json
137158

138159
ingress:
139160
# Wildcard for all subdomains
140-
- hostname: "*.dev.example.com"
161+
- hostname: "*.tunnel.example.com"
162+
service: http://localhost:8080
163+
# Base domain for control plane
164+
- hostname: "tunnel.example.com"
141165
service: http://localhost:8080
142166
# Catch-all (required)
143167
- service: http_status:404
@@ -146,12 +170,23 @@ ingress:
146170
### 5. Configure DNS
147171
148172
In Cloudflare Dashboard:
173+
174+
**For path-based routing (recommended):**
149175
1. Go to DNS settings
150176
2. Add CNAME record:
151-
- Name: `*.dev` (or your subdomain)
177+
- Name: `tunnel`
152178
- Target: `<TUNNEL_ID>.cfargotunnel.com`
153179
- Proxy status: Proxied
154180

181+
This is covered by Cloudflare's free Universal SSL certificate.
182+
183+
**For subdomain-based routing:**
184+
1. Go to DNS settings
185+
2. Add CNAME records:
186+
- Name: `tunnel`, Target: `<TUNNEL_ID>.cfargotunnel.com`, Proxied
187+
- Name: `*.tunnel`, Target: `<TUNNEL_ID>.cfargotunnel.com`, Proxied
188+
3. Enable Advanced Certificate Manager in SSL/TLS settings to get a certificate for `*.tunnel.example.com`
189+
155190
### 6. Run cloudflared as Service
156191

157192
```bash

internal/client/client.go

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ package client
44
import (
55
"bufio"
66
"context"
7+
"encoding/json"
78
"fmt"
9+
"io"
810
"log"
911
"net"
1012
"net/http"
@@ -30,9 +32,16 @@ type Config struct {
3032
RewriteHost bool
3133
}
3234

35+
// ServerConfig holds the server's configuration returned from /_config endpoint.
36+
type ServerConfig struct {
37+
RoutingMode string `json:"routing_mode"`
38+
BaseDomain string `json:"base_domain"`
39+
}
40+
3341
// Client is the Exio tunneling client (exio).
3442
type Client struct {
3543
config *Config
44+
serverConfig *ServerConfig
3645
authenticator *auth.Authenticator
3746
session *transport.Session
3847
logger *log.Logger
@@ -74,8 +83,64 @@ func New(config *Config) (*Client, error) {
7483
}, nil
7584
}
7685

86+
// fetchServerConfig queries the server's /_config endpoint to get routing mode.
87+
func (c *Client) fetchServerConfig(ctx context.Context) error {
88+
configURL, err := url.Parse(c.config.ServerURL)
89+
if err != nil {
90+
return fmt.Errorf("invalid server URL: %w", err)
91+
}
92+
93+
configURL.Path = "/_config"
94+
95+
req, err := http.NewRequestWithContext(ctx, "GET", configURL.String(), nil)
96+
if err != nil {
97+
return fmt.Errorf("failed to create config request: %w", err)
98+
}
99+
100+
client := &http.Client{Timeout: 10 * time.Second}
101+
resp, err := client.Do(req)
102+
if err != nil {
103+
// If we can't reach the config endpoint, assume subdomain mode for backward compatibility
104+
c.logger.Printf("Warning: Could not fetch server config, assuming subdomain mode: %v", err)
105+
c.serverConfig = &ServerConfig{
106+
RoutingMode: protocol.RoutingModeSubdomain,
107+
BaseDomain: extractBaseDomain(c.config.ServerURL),
108+
}
109+
return nil
110+
}
111+
defer resp.Body.Close()
112+
113+
if resp.StatusCode != http.StatusOK {
114+
// Older servers may not have /_config endpoint
115+
c.logger.Printf("Warning: Server config endpoint returned %d, assuming subdomain mode", resp.StatusCode)
116+
c.serverConfig = &ServerConfig{
117+
RoutingMode: protocol.RoutingModeSubdomain,
118+
BaseDomain: extractBaseDomain(c.config.ServerURL),
119+
}
120+
return nil
121+
}
122+
123+
body, err := io.ReadAll(resp.Body)
124+
if err != nil {
125+
return fmt.Errorf("failed to read config response: %w", err)
126+
}
127+
128+
var serverConfig ServerConfig
129+
if err := json.Unmarshal(body, &serverConfig); err != nil {
130+
return fmt.Errorf("failed to parse config response: %w", err)
131+
}
132+
133+
c.serverConfig = &serverConfig
134+
return nil
135+
}
136+
77137
// Connect establishes a tunnel connection to the server.
78138
func (c *Client) Connect(ctx context.Context) error {
139+
// First, fetch server configuration to determine routing mode
140+
if err := c.fetchServerConfig(ctx); err != nil {
141+
return fmt.Errorf("failed to fetch server config: %w", err)
142+
}
143+
79144
// Build the WebSocket URL
80145
serverURL, err := url.Parse(c.config.ServerURL)
81146
if err != nil {
@@ -154,9 +219,12 @@ func (c *Client) Connect(ctx context.Context) error {
154219
c.connectedAt = time.Now()
155220
c.mu.Unlock()
156221

157-
// Build public URL (assume https and the server's base domain)
158-
// The actual URL depends on the server configuration
159-
c.publicURL = fmt.Sprintf("https://%s.%s", c.config.Subdomain, extractBaseDomain(c.config.ServerURL))
222+
// Build public URL based on server's routing mode
223+
if c.serverConfig.RoutingMode == protocol.RoutingModePath {
224+
c.publicURL = fmt.Sprintf("https://%s/%s/", c.serverConfig.BaseDomain, c.config.Subdomain)
225+
} else {
226+
c.publicURL = fmt.Sprintf("https://%s.%s", c.config.Subdomain, c.serverConfig.BaseDomain)
227+
}
160228

161229
c.logger.Printf("Tunnel established!")
162230
c.logger.Printf("Public URL: %s", c.publicURL)

0 commit comments

Comments
 (0)