M1: single-node OpenTunnel-compatible server
HTTP API per the recorded contract, bridge WebSocket, SNI relay with pass-through tunnel TLS, local CA issuance, JSON store. Verified end-to-end with the official opentunnel CLI.
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testAPI returns an API against an in-memory store, on the domain
|
||||
// "opentunnel.test" like the recorded contract.
|
||||
func testAPI(t *testing.T) (*API, *httptest.Server) {
|
||||
t.Helper()
|
||||
store, err := OpenStore("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
api := &API{Store: store, Hub: NewHub(store), Domain: "opentunnel.test"}
|
||||
return api, httptest.NewServer(api)
|
||||
}
|
||||
|
||||
type createdTunnel struct {
|
||||
ID string
|
||||
Hostname string
|
||||
Token string
|
||||
}
|
||||
|
||||
func createTunnel(t *testing.T, srv *httptest.Server) createdTunnel {
|
||||
t.Helper()
|
||||
resp, err := http.Post(srv.URL+"/api/tunnel", "application/json", strings.NewReader("{}"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 201 {
|
||||
t.Fatalf("create: status %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Tunnel TunnelInfo `json:"tunnel"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(body.Tunnel.ID) != 12 || body.Tunnel.Hostname != body.Tunnel.ID+".opentunnel.test" {
|
||||
t.Fatalf("create: odd tunnel %+v", body.Tunnel)
|
||||
}
|
||||
if body.Tunnel.State != "offline" {
|
||||
t.Fatalf("create: state %q", body.Tunnel.State)
|
||||
}
|
||||
if !strings.HasPrefix(body.Token, "rly_") {
|
||||
t.Fatalf("create: token %q", body.Token)
|
||||
}
|
||||
return createdTunnel{ID: body.Tunnel.ID, Hostname: body.Tunnel.Hostname, Token: body.Token}
|
||||
}
|
||||
|
||||
func request(t *testing.T, method, url, token, contentType, body string) *http.Response {
|
||||
t.Helper()
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, url, reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func bodyString(t *testing.T, resp *http.Response) string {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// makeCSR builds a PEM PKCS#10 CSR for cn with the given SANs, as the CLI does.
|
||||
func makeCSR(t *testing.T, cn string, sans ...string) string {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
der, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
DNSNames: sans,
|
||||
}, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der}))
|
||||
}
|
||||
|
||||
func TestAPIContract(t *testing.T) {
|
||||
_, srv := testAPI(t)
|
||||
defer srv.Close()
|
||||
base := srv.URL
|
||||
|
||||
// create, and the malformed variants the contract pins
|
||||
resp := request(t, "POST", base+"/api/tunnel", "", "application/json", "{}")
|
||||
if resp.StatusCode != 201 {
|
||||
t.Fatalf("create: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp = request(t, "POST", base+"/api/tunnel", "", "application/json", `{"name":"x"}`)
|
||||
if got := bodyString(t, resp); resp.StatusCode != 400 ||
|
||||
got != `{"_tag":"InvalidRequestError","message":"Custom tunnel names are not supported; omit name to get a random hostname"}` {
|
||||
t.Errorf("create with name: %d %q", resp.StatusCode, got)
|
||||
}
|
||||
resp = request(t, "POST", base+"/api/tunnel", "", "application/json", "{")
|
||||
if got := bodyString(t, resp); resp.StatusCode != 400 || got != "" {
|
||||
t.Errorf("create with invalid json: %d %q", resp.StatusCode, got)
|
||||
}
|
||||
resp = request(t, "POST", base+"/api/tunnel", "", "text/plain", "{}")
|
||||
if got := bodyString(t, resp); resp.StatusCode != 415 || got != "Unsupported content-type: text/plain" {
|
||||
t.Errorf("create with text body: %d %q", resp.StatusCode, got)
|
||||
}
|
||||
resp = request(t, "POST", base+"/api/tunnel", "", "application/json", `{"name":null}`)
|
||||
if resp.StatusCode != 201 {
|
||||
t.Errorf("create with null name: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
tunnel := createTunnel(t, srv)
|
||||
|
||||
// get: auth behavior and the unknown-tunnel 404
|
||||
resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID, "", "", "")
|
||||
if got := bodyString(t, resp); resp.StatusCode != 401 ||
|
||||
got != `{"_tag":"UnauthorizedError","message":"Invalid bearer token"}` {
|
||||
t.Errorf("get without auth: %d %q", resp.StatusCode, got)
|
||||
}
|
||||
resp = request(t, "GET", base+"/api/tunnel/zzzzzzzzzzzz", "", "", "")
|
||||
if got := bodyString(t, resp); resp.StatusCode != 404 ||
|
||||
got != `{"_tag":"TunnelNotFoundError","tunnelID":"zzzzzzzzzzzz","message":"Tunnel not found"}` {
|
||||
t.Errorf("get unknown: %d %q", resp.StatusCode, got)
|
||||
}
|
||||
resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID, tunnel.Token, "", "")
|
||||
want := `{"id":"` + tunnel.ID + `","hostname":"` + tunnel.Hostname + `","state":"offline"}`
|
||||
if got := bodyString(t, resp); resp.StatusCode != 200 || got != want {
|
||||
t.Errorf("get: %d %q, want %q", resp.StatusCode, got, want)
|
||||
}
|
||||
// lowercase bearer, double slash, trailing slash
|
||||
req, _ := http.NewRequest("GET", base+"/api//tunnel/"+tunnel.ID+"/", nil)
|
||||
req.Header.Set("Authorization", "bearer "+tunnel.Token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := bodyString(t, resp); resp.StatusCode != 200 || got != want {
|
||||
t.Errorf("get double slash: %d %q", resp.StatusCode, got)
|
||||
}
|
||||
|
||||
// certificate flow
|
||||
resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "", "")
|
||||
if got := bodyString(t, resp); resp.StatusCode != 404 ||
|
||||
got != `{"_tag":"CertificateNotFoundError","tunnelID":"`+tunnel.ID+`","message":"Certificate not found"}` {
|
||||
t.Errorf("certificate before bind: %d %q", resp.StatusCode, got)
|
||||
}
|
||||
resp = request(t, "POST", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "application/json", `{"csr":"garbage"}`)
|
||||
if got := bodyString(t, resp); resp.StatusCode != 400 ||
|
||||
got != `{"_tag":"InvalidRequestError","message":"Failed to parse CSR"}` {
|
||||
t.Errorf("bind garbage: %d %q", resp.StatusCode, got)
|
||||
}
|
||||
wrong := makeCSR(t, "other.opentunnel.test", "other.opentunnel.test")
|
||||
resp = request(t, "POST", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "application/json",
|
||||
`{"csr":`+jsonString(wrong)+`}`)
|
||||
wantWrong := `{"_tag":"InvalidHostnameError","provided":"other.opentunnel.test","expected":"` +
|
||||
tunnel.Hostname + `","message":"CSR hostname does not match tunnel hostname"}`
|
||||
if got := bodyString(t, resp); resp.StatusCode != 400 || got != wantWrong {
|
||||
t.Errorf("bind wrong hostname: %d %q, want %q", resp.StatusCode, got, wantWrong)
|
||||
}
|
||||
|
||||
csr := makeCSR(t, tunnel.Hostname, tunnel.Hostname, "*."+tunnel.Hostname)
|
||||
resp = request(t, "POST", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "application/json",
|
||||
`{"csr":`+jsonString(csr)+`}`)
|
||||
body := bodyString(t, resp)
|
||||
if resp.StatusCode != 202 || !strings.Contains(body, `"type":"issuing"`) || !strings.Contains(body, `"id":"cert_`) {
|
||||
t.Errorf("bind: %d %q", resp.StatusCode, body)
|
||||
}
|
||||
resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "", "")
|
||||
var cert CertificateInfo
|
||||
if err := json.Unmarshal([]byte(bodyString(t, resp)), &cert); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cert.State.Type != "ready" || cert.State.Certificate == "" || cert.State.Chain == "" || cert.State.Expiry == "" {
|
||||
t.Errorf("certificate after bind: %+v", cert.State)
|
||||
}
|
||||
// rebinding the same CSR answers with the current certificate
|
||||
resp = request(t, "POST", base+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "application/json",
|
||||
`{"csr":`+jsonString(csr)+`}`)
|
||||
if got := bodyString(t, resp); resp.StatusCode != 202 || !strings.Contains(got, `"type":"ready"`) {
|
||||
t.Errorf("rebind: %d %q", resp.StatusCode, got)
|
||||
}
|
||||
// get now carries certificateID
|
||||
resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID, tunnel.Token, "", "")
|
||||
if got := bodyString(t, resp); !strings.Contains(got, `"certificateID":"cert_`) {
|
||||
t.Errorf("get after bind: %q", got)
|
||||
}
|
||||
|
||||
// connect: the pinned pre-upgrade answers
|
||||
resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID+"/connect", "", "", "")
|
||||
if got := bodyString(t, resp); resp.StatusCode != 426 || got != "Expected WebSocket upgrade" {
|
||||
t.Errorf("connect without upgrade: %d %q", resp.StatusCode, got)
|
||||
}
|
||||
req, _ = http.NewRequest("GET", base+"/api/tunnel/"+tunnel.ID+"/connect", nil)
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := bodyString(t, resp); resp.StatusCode != 426 || got != "Expected opentunnel WebSocket subprotocol" {
|
||||
t.Errorf("connect without subprotocol: %d %q", resp.StatusCode, got)
|
||||
}
|
||||
req, _ = http.NewRequest("GET", base+"/api/tunnel/zzzzzzzzzzzz/connect", nil)
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := bodyString(t, resp); resp.StatusCode != 404 || got != "Tunnel not found" {
|
||||
t.Errorf("connect unknown: %d %q", resp.StatusCode, got)
|
||||
}
|
||||
|
||||
// unknown paths and methods are an empty 404; health answers
|
||||
resp = request(t, "PUT", base+"/api/tunnel", "", "", "")
|
||||
if got := bodyString(t, resp); resp.StatusCode != 404 || got != "" {
|
||||
t.Errorf("put: %d %q", resp.StatusCode, got)
|
||||
}
|
||||
resp = request(t, "GET", base+"/api/nope", "", "", "")
|
||||
if got := bodyString(t, resp); resp.StatusCode != 404 || got != "" {
|
||||
t.Errorf("unknown path: %d %q", resp.StatusCode, got)
|
||||
}
|
||||
resp = request(t, "GET", base+"/health", "", "", "")
|
||||
if got := bodyString(t, resp); resp.StatusCode != 200 || got != `{"ok":true}` {
|
||||
t.Errorf("health: %d %q", resp.StatusCode, got)
|
||||
}
|
||||
|
||||
// delete
|
||||
resp = request(t, "DELETE", base+"/api/tunnel/"+tunnel.ID, tunnel.Token, "", "")
|
||||
if resp.StatusCode != 204 {
|
||||
t.Errorf("delete: %d", resp.StatusCode)
|
||||
}
|
||||
resp = request(t, "GET", base+"/api/tunnel/"+tunnel.ID, tunnel.Token, "", "")
|
||||
if resp.StatusCode != 404 {
|
||||
t.Errorf("get after delete: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// jsonString encodes s as a JSON string literal.
|
||||
func jsonString(s string) string {
|
||||
raw, _ := json.Marshal(s)
|
||||
return string(raw)
|
||||
}
|
||||
Reference in New Issue
Block a user