Files
tunnel/internal/server/api.go
T
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

342 lines
10 KiB
Go

package server
import (
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"strings"
)
// API is the HTTP control plane. Its answers match the recorded contract in
// worker-contract.json: paths, status codes, bodies, and JSON key order.
type API struct {
Store *Store
Hub *Hub
Domain string
}
func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// The reference tolerates duplicate slashes and a trailing slash.
path := "/" + strings.Join(strings.FieldsFunc(r.URL.Path, func(c rune) bool { return c == '/' }), "/")
switch {
case path == "/health" && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json;charset=utf-8")
w.Write([]byte(`{"ok":true}`))
case path == "/openapi.json" && r.Method == http.MethodGet:
writeJSON(w, http.StatusOK, json.RawMessage(openapiStub))
case path == "/api/tunnel" && r.Method == http.MethodPost:
a.createTunnel(w, r)
default:
a.tunnelRoutes(w, r, strings.Split(strings.TrimPrefix(path, "/"), "/"))
}
}
// tunnelRoutes routes /api/tunnel/{id}[/certificate|connect].
func (a *API) tunnelRoutes(w http.ResponseWriter, r *http.Request, parts []string) {
if len(parts) < 2 || parts[0] != "api" || parts[1] != "tunnel" {
notFoundEmpty(w)
return
}
switch len(parts) {
case 3:
id := parts[2]
switch r.Method {
case http.MethodGet, http.MethodHead:
a.getTunnel(w, r, id)
case http.MethodDelete:
a.deleteTunnel(w, r, id)
default:
notFoundEmpty(w)
}
case 4:
id := parts[2]
switch parts[3] {
case "certificate":
switch r.Method {
case http.MethodPost:
a.bindCertificate(w, r, id)
case http.MethodGet, http.MethodHead:
a.getCertificate(w, r, id)
default:
notFoundEmpty(w)
}
case "connect":
if r.Method != http.MethodGet {
notFoundEmpty(w)
return
}
a.connect(w, r, id)
default:
notFoundEmpty(w)
}
default:
notFoundEmpty(w)
}
}
// ---- POST /api/tunnel
func (a *API) createTunnel(w http.ResponseWriter, r *http.Request) {
body, ok := readJSONBody(w, r)
if !ok {
return
}
if name, present := body["name"]; present && name != nil {
if _, isString := name.(string); !isString {
w.WriteHeader(http.StatusBadRequest) // wrong type: 400, empty body
return
}
writeJSON(w, http.StatusBadRequest, errInvalidRequest{
Tag: "InvalidRequestError",
Message: "Custom tunnel names are not supported; omit name to get a random hostname",
})
return
}
tunnel, token, err := a.Store.createTunnel(a.Domain)
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, errInvalidRequest{
Tag: "ServiceUnavailableError", Message: "storage unavailable",
})
return
}
writeJSON(w, http.StatusCreated, createResponse{
Tunnel: TunnelInfo{ID: tunnel.ID, Hostname: tunnel.Hostname, State: "offline"},
Token: token,
})
}
// ---- GET /api/tunnel/{id}
func (a *API) getTunnel(w http.ResponseWriter, r *http.Request, id string) {
tunnel, ok := a.lookupTunnel(w, id)
if !ok || !a.authorized(w, r, tunnel) {
return
}
info := TunnelInfo{ID: tunnel.ID, Hostname: tunnel.Hostname, State: "offline"}
if a.Hub.online(tunnel.ID) {
info.State = "online"
}
if tunnel.Certificate != nil {
info.CertificateID = tunnel.Certificate.ID
}
writeJSON(w, http.StatusOK, info)
}
// ---- DELETE /api/tunnel/{id}
func (a *API) deleteTunnel(w http.ResponseWriter, r *http.Request, id string) {
tunnel, ok := a.lookupTunnel(w, id)
if !ok || !a.authorized(w, r, tunnel) {
return
}
a.Store.remove(id)
a.Hub.closeTunnel(id)
w.WriteHeader(http.StatusNoContent)
}
// ---- POST /api/tunnel/{id}/certificate
func (a *API) bindCertificate(w http.ResponseWriter, r *http.Request, id string) {
body, ok := readJSONBody(w, r)
if !ok {
return
}
tunnel, ok := a.lookupTunnel(w, id)
if !ok || !a.authorized(w, r, tunnel) {
return
}
csrValue, present := body["csr"]
if !present {
w.WriteHeader(http.StatusBadRequest)
return
}
csr, isString := csrValue.(string)
if !isString {
w.WriteHeader(http.StatusBadRequest)
return
}
// Binding the CSR the tunnel already has answers with its current
// certificate; a different one re-issues (with the local CA, at once).
if tunnel.Certificate != nil && tunnel.Certificate.CSR == csr {
a.writeCertificate(w, http.StatusAccepted, tunnel.Certificate)
return
}
ca, err := a.Store.ca()
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, errInvalidRequest{
Tag: "ServiceUnavailableError", Message: "storage unavailable",
})
return
}
certPEM, chainPEM, expiry, err := ca.signCSR(csr, tunnel.Hostname)
if err != nil {
var hostErr errCSRHostname
if errors.As(err, &hostErr) {
writeJSON(w, http.StatusBadRequest, errInvalidHostname{
Tag: "InvalidHostnameError",
Provided: hostErr.provided,
Expected: hostErr.expected,
Message: "CSR hostname does not match tunnel hostname",
})
return
}
writeJSON(w, http.StatusBadRequest, errInvalidRequest{
Tag: "InvalidRequestError", Message: "Failed to parse CSR",
})
return
}
cert := storedCert{
ID: certID(),
CSR: csr,
Certificate: string(certPEM),
Chain: string(chainPEM),
Expiry: expiryString(expiry),
}
if err := a.Store.bindCertificate(id, cert); err != nil {
notFoundEmpty(w)
return
}
// The contract's bind response is 202 with state "issuing"; with the local
// CA the very next GET already answers "ready".
writeJSON(w, http.StatusAccepted, CertificateInfo{ID: cert.ID, State: CertState{Type: "issuing"}})
}
// ---- GET /api/tunnel/{id}/certificate
func (a *API) getCertificate(w http.ResponseWriter, r *http.Request, id string) {
tunnel, ok := a.lookupTunnel(w, id)
if !ok || !a.authorized(w, r, tunnel) {
return
}
if tunnel.Certificate == nil {
writeJSON(w, http.StatusNotFound, errTaggedID{
Tag: "CertificateNotFoundError", TunnelID: id, Message: "Certificate not found",
})
return
}
a.writeCertificate(w, http.StatusOK, tunnel.Certificate)
}
func (a *API) writeCertificate(w http.ResponseWriter, status int, cert *storedCert) {
writeJSON(w, status, CertificateInfo{
ID: cert.ID,
State: CertState{
Type: "ready",
Certificate: cert.Certificate,
Chain: cert.Chain,
Expiry: cert.Expiry,
},
})
}
// ---- GET /api/tunnel/{id}/connect
func (a *API) connect(w http.ResponseWriter, r *http.Request, id string) {
tunnel, ok := a.Store.get(id)
if !ok {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Tunnel not found"))
return
}
if !upgradeRequested(r) {
w.WriteHeader(http.StatusUpgradeRequired)
w.Write([]byte("Expected WebSocket upgrade"))
return
}
if !subprotocolOffered(r) {
w.WriteHeader(http.StatusUpgradeRequired)
w.Write([]byte("Expected opentunnel WebSocket subprotocol"))
return
}
a.Hub.attach(w, r, tunnel)
}
// ---- helpers
// lookupTunnel answers 404 TunnelNotFoundError when id is unknown. Existence
// is checked before authorization, as the contract records.
func (a *API) lookupTunnel(w http.ResponseWriter, id string) (storedTunnel, bool) {
tunnel, ok := a.Store.get(id)
if !ok {
writeJSON(w, http.StatusNotFound, errTaggedID{
Tag: "TunnelNotFoundError", TunnelID: id, Message: "Tunnel not found",
})
return tunnel, false
}
return tunnel, true
}
// authorized answers 401 UnauthorizedError unless the request carries the
// tunnel's bearer token (the scheme is case-insensitive).
func (a *API) authorized(w http.ResponseWriter, r *http.Request, tunnel storedTunnel) bool {
scheme, token, _ := strings.Cut(r.Header.Get("Authorization"), " ")
if strings.EqualFold(scheme, "bearer") && token != "" && tokenMatches(token, tunnel.TokenHash) {
return true
}
writeJSON(w, http.StatusUnauthorized, errUnauthorized{
Tag: "UnauthorizedError", Message: "Invalid bearer token",
})
return false
}
// readJSONBody enforces the contract's body rules: a non-JSON content type is
// 415, a missing, empty, or malformed body is 400 with an empty body.
func readJSONBody(w http.ResponseWriter, r *http.Request) (map[string]any, bool) {
if ct := r.Header.Get("Content-Type"); ct != "" {
mediaType, _, err := mime.ParseMediaType(ct)
if err != nil || !strings.EqualFold(mediaType, "application/json") {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusUnsupportedMediaType)
w.Write([]byte("Unsupported content-type: " + ct))
return nil, false
}
}
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil || len(raw) == 0 {
w.WriteHeader(http.StatusBadRequest)
return nil, false
}
var body map[string]any
if err := json.Unmarshal(raw, &body); err != nil {
w.WriteHeader(http.StatusBadRequest)
return nil, false
}
return body, true
}
func writeJSON(w http.ResponseWriter, status int, body any) {
raw, err := json.Marshal(body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write(raw)
}
// notFoundEmpty is the 404 with no body the contract records for unknown
// paths and methods.
func notFoundEmpty(w http.ResponseWriter) {
w.WriteHeader(http.StatusNotFound)
}
const openapiStub = `{"openapi":"3.1.0","info":{"title":"OpenTunnel API","version":"0.1.0","description":"Create and manage blind TLS tunnels."},"paths":{"/api/tunnel":{"post":{"responses":{"201":{"description":"Success"}}}},"/api/tunnel/{id}":{"get":{"responses":{"200":{"description":"Tunnel/Info"}}},"delete":{"responses":{"204":{"description":"Deleted"}}}},"/api/tunnel/{id}/certificate":{"post":{"responses":{"202":{"description":"Accepted"}}},"get":{"responses":{"200":{"description":"Certificate"}}}},"/api/tunnel/{id}/connect":{"get":{"responses":{"101":{"description":"WebSocket upgrade"}}}}}}`
// redirectHandler is HTTP_MODE=redirect: /health answers, everything else
// permanently redirects to the API domain.
func RedirectHandler(domain string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/health" {
w.Header().Set("Content-Type", "application/json;charset=utf-8")
w.Write([]byte(`{"ok":true}`))
return
}
target := "https://" + domain + r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusMovedPermanently)
})
}