Files
TZGyn 682a341010 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.
2026-10-25 11:52:45 +08:00

277 lines
9.9 KiB
Go

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)
}