feat(tunnel): require explicit target schemes
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-05-15 13:16:49 -04:00
parent 9edea27148
commit 8dfb14f1e7
7 changed files with 78 additions and 19 deletions

View File

@@ -85,10 +85,10 @@ conduit tunnel \
--server https://conduit.example.com \ --server https://conduit.example.com \
--api_key your-secret-key \ --api_key your-secret-key \
--name my-service \ --name my-service \
--target localhost:5432 --target tcp://localhost:5432
``` ```
The local tunnel monitor is available at `http://localhost:8181` for HTTP tunnels. Targets must include an explicit `tcp://`, `http://`, or `https://` scheme. The local tunnel monitor is available at `http://localhost:8181` for HTTP tunnels.
## Configuration ## Configuration
@@ -111,7 +111,7 @@ All options can be set via CLI flags or environment variables (`CONDUIT_` prefix
| `--server` | `CONDUIT_SERVER` | `http://localhost:8080` | Conduit server address | | `--server` | `CONDUIT_SERVER` | `http://localhost:8080` | Conduit server address |
| `--api_key` | `CONDUIT_API_KEY` | — | API key (required) | | `--api_key` | `CONDUIT_API_KEY` | — | API key (required) |
| `--name` | `CONDUIT_NAME` | (auto-generated) | Tunnel subdomain name | | `--name` | `CONDUIT_NAME` | (auto-generated) | Tunnel subdomain name |
| `--target` | `CONDUIT_TARGET` | — | Local target address (required) | | `--target` | `CONDUIT_TARGET` | — | Local target address with `tcp://`, `http://`, or `https://` scheme (required) |
| `--log_level` | `CONDUIT_LOG_LEVEL` | `info` | Log level | | `--log_level` | `CONDUIT_LOG_LEVEL` | `info` | Log level |
| `--log_format` | `CONDUIT_LOG_FORMAT` | `text` | Log format (`text` or `json`) | | `--log_format` | `CONDUIT_LOG_FORMAT` | `text` | Log format (`text` or `json`) |

View File

@@ -16,7 +16,7 @@ import (
) )
var tunnelCmd = &cobra.Command{ var tunnelCmd = &cobra.Command{
Use: "tunnel <name> <host:port>", Use: "tunnel --target <scheme://host:port>",
Short: "Create a conduit tunnel", Short: "Create a conduit tunnel",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Get Client Config // Get Client Config

View File

@@ -53,7 +53,7 @@ type ServerConfig struct {
type ClientConfig struct { type ClientConfig struct {
BaseConfig BaseConfig
TunnelName string `json:"name" description:"Tunnel name"` TunnelName string `json:"name" description:"Tunnel name"`
TunnelTarget string `json:"target" description:"Tunnel target address"` TunnelTarget string `json:"target" description:"Tunnel target address (tcp://, http://, or https://)"`
} }
func (c *ClientConfig) Validate() error { func (c *ClientConfig) Validate() error {

View File

@@ -216,7 +216,7 @@ func sendHTTPViaTunnel(t *testing.T, serverAddr, tunnelName, method, path, body
req.Host = fmt.Sprintf("%s.%s", tunnelName, serverAddr) req.Host = fmt.Sprintf("%s.%s", tunnelName, serverAddr)
client := &http.Client{ client := &http.Client{
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
Transport: &http.Transport{DisableKeepAlives: true}, Transport: &http.Transport{DisableKeepAlives: true},
} }
resp, err := client.Do(req) resp, err := client.Do(req)
@@ -666,8 +666,8 @@ func TestTCPTunnelEcho(t *testing.T) {
serverAddr, stopServer := startConduitServer(t, apiKey) serverAddr, stopServer := startConduitServer(t, apiKey)
defer stopServer() defer stopServer()
// Connect TCP Tunnel (bare host:port — the realistic way users would specify it) // Connect TCP Tunnel
stopTunnel := connectTunnel(t, serverAddr, tcpAddr, "tcp-test", apiKey) stopTunnel := connectTunnel(t, serverAddr, fmt.Sprintf("tcp://%s", tcpAddr), "tcp-test", apiKey)
defer stopTunnel() defer stopTunnel()
// Send Raw HTTP Request Through Tunnel to TCP Echo // Send Raw HTTP Request Through Tunnel to TCP Echo
@@ -709,8 +709,8 @@ func TestTCPTunnelLargePayload(t *testing.T) {
serverAddr, stopServer := startConduitServer(t, apiKey) serverAddr, stopServer := startConduitServer(t, apiKey)
defer stopServer() defer stopServer()
// Connect TCP Tunnel (bare host:port) // Connect TCP Tunnel
stopTunnel := connectTunnel(t, serverAddr, tcpAddr, "tcp-large", apiKey) stopTunnel := connectTunnel(t, serverAddr, fmt.Sprintf("tcp://%s", tcpAddr), "tcp-large", apiKey)
defer stopTunnel() defer stopTunnel()
// Connect and Send Large Payload // Connect and Send Large Payload

View File

@@ -2,7 +2,9 @@ package tunnel
import ( import (
"context" "context"
"fmt"
"net/url" "net/url"
"strings"
"reichard.io/conduit/store" "reichard.io/conduit/store"
) )
@@ -21,15 +23,24 @@ type Forwarder interface {
} }
func NewForwarder(target string, tunnelStore store.TunnelStore) (Forwarder, error) { func NewForwarder(target string, tunnelStore store.TunnelStore) (Forwarder, error) {
// Only parse as URL for HTTP targets. Bare host:port (e.g., "127.0.0.1:5432") if !strings.Contains(target, "://") {
// is not a valid URL and should be treated as a raw TCP target. return nil, fmt.Errorf("target must include a scheme: tcp://, http://, or https://")
targetURL, err := url.Parse(target)
if err == nil {
switch targetURL.Scheme {
case "http", "https":
return newHTTPForwarder(targetURL, tunnelStore)
}
} }
return newTCPForwarder(target, tunnelStore), nil targetURL, err := url.Parse(target)
if err != nil {
return nil, fmt.Errorf("target is invalid: %w", err)
}
if targetURL.Host == "" {
return nil, fmt.Errorf("target must include a host")
}
switch targetURL.Scheme {
case "http", "https":
return newHTTPForwarder(targetURL, tunnelStore)
case "tcp":
return newTCPForwarder(targetURL.Host, tunnelStore), nil
default:
return nil, fmt.Errorf("unsupported target scheme %q: use tcp://, http://, or https://", targetURL.Scheme)
}
} }

45
tunnel/forwarder_test.go Normal file
View File

@@ -0,0 +1,45 @@
package tunnel
import (
"testing"
"reichard.io/conduit/store"
)
func TestNewForwarderRequiresExplicitScheme(t *testing.T) {
_, err := NewForwarder("localhost:8282", store.NewTunnelStore(1))
if err == nil {
t.Fatal("expected error for target without scheme")
}
}
func TestNewForwarderSupportsExplicitSchemes(t *testing.T) {
tests := []struct {
name string
target string
forwarderType ForwarderType
}{
{name: "http", target: "http://localhost:8282", forwarderType: ForwarderHTTP},
{name: "https", target: "https://localhost:8282", forwarderType: ForwarderHTTP},
{name: "tcp", target: "tcp://localhost:8282", forwarderType: ForwarderTCP},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
forwarder, err := NewForwarder(tt.target, store.NewTunnelStore(1))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if forwarder.Type() != tt.forwarderType {
t.Fatalf("expected forwarder type %v, got %v", tt.forwarderType, forwarder.Type())
}
})
}
}
func TestNewForwarderRejectsUnsupportedScheme(t *testing.T) {
_, err := NewForwarder("udp://localhost:8282", store.NewTunnelStore(1))
if err == nil {
t.Fatal("expected error for unsupported scheme")
}
}

View File

@@ -70,6 +70,9 @@ func (s *WebServer) handleStream(w http.ResponseWriter, r *http.Request) {
ch := s.store.Subscribe() ch := s.store.Subscribe()
done := r.Context().Done() done := r.Context().Done()
_, _ = fmt.Fprint(w, ": connected\n\n")
flusher.Flush()
for { for {
select { select {
case record, ok := <-ch: case record, ok := <-ch: