Tunnels now get a per-tunnel certificate from the configured ACME CA (Let's Encrypt by default) with DNS-01 through Porkbun, keeping the client's CSR and key. Issuance is asynchronous (issuing/ready/failed); the account persists in the store, renewals run in a 30-day window with retries, and a failed renewal keeps the old certificate serving. Switching from the local CA reissues existing tunnels automatically, and the admin dashboard shows the issuer and certificate state. Verified against Pebble and Let's Encrypt staging.
474 lines
14 KiB
Go
474 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"mime"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// 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
|
|
|
|
AdminToken string // bearer token for /api/admin/*; empty disables it
|
|
StartedAt time.Time // for the uptime shown on the dashboard
|
|
Version string
|
|
|
|
Issuer Issuer // certificate issuer; defaults to the local CA
|
|
IssuerName string // "local" or the ACME directory URL
|
|
|
|
issueMu sync.Mutex
|
|
issuing map[string]bool
|
|
}
|
|
|
|
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 == "/" || path == "/admin" || strings.HasPrefix(path, "/assets/")) && r.Method == http.MethodGet:
|
|
a.dashboard(w, r)
|
|
case path == "/api/admin/overview" && r.Method == http.MethodGet:
|
|
a.adminOverviewHandler(w, r)
|
|
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 state;
|
|
// a different CSR re-issues. A failed issuance for the same CSR retries.
|
|
cert := tunnel.Certificate
|
|
if cert != nil && cert.CSR == csr {
|
|
if certReady(cert) {
|
|
a.writeCertificate(w, http.StatusAccepted, cert)
|
|
return
|
|
}
|
|
if cert.State != "failed" {
|
|
// Same CSR, issuance incomplete: resume it (for example after a
|
|
// restart) and answer with its current state.
|
|
a.startIssue(tunnel.ID, tunnel.Hostname, csr, cert.ID)
|
|
a.writeCertificate(w, http.StatusAccepted, cert)
|
|
return
|
|
}
|
|
}
|
|
if (cert != nil && cert.State == "issuing") || a.isIssuing(id) {
|
|
writeJSON(w, http.StatusConflict, errTaggedID{
|
|
Tag: "CertificateInProgressError", TunnelID: id,
|
|
Message: "Certificate issuance is already in progress",
|
|
})
|
|
return
|
|
}
|
|
if _, err := parseCSR(csr, tunnel.Hostname); 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
|
|
}
|
|
fresh := storedCert{ID: certID(), CSR: csr, State: "issuing", Issuer: a.issuerName()}
|
|
if err := a.Store.bindCertificate(id, fresh); err != nil {
|
|
notFoundEmpty(w)
|
|
return
|
|
}
|
|
a.startIssue(tunnel.ID, tunnel.Hostname, csr, fresh.ID)
|
|
writeJSON(w, http.StatusAccepted, CertificateInfo{ID: fresh.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(cert)})
|
|
}
|
|
|
|
// certState maps a stored certificate to the wire state.
|
|
func certState(cert *storedCert) CertState {
|
|
switch {
|
|
case cert == nil:
|
|
return CertState{Type: "failed", Reason: "no certificate"}
|
|
case certReady(cert):
|
|
return CertState{Type: "ready", Certificate: cert.Certificate, Chain: cert.Chain, Expiry: cert.Expiry}
|
|
case cert.State == "failed":
|
|
return CertState{Type: "failed", Reason: cert.Reason}
|
|
default:
|
|
return CertState{Type: "issuing"}
|
|
}
|
|
}
|
|
|
|
// issuer returns the configured certificate issuer (the local CA by default).
|
|
func (a *API) issuer() Issuer {
|
|
if a.Issuer != nil {
|
|
return a.Issuer
|
|
}
|
|
return NewLocalIssuer(a.Store)
|
|
}
|
|
|
|
// issuerName identifies the current issuer, so certificates from a previous
|
|
// issuer (say the local CA) are re-issued once a real one is configured.
|
|
func (a *API) issuerName() string {
|
|
if a.IssuerName != "" {
|
|
return a.IssuerName
|
|
}
|
|
return "local"
|
|
}
|
|
|
|
// runIssue issues one certificate and records the outcome. At most one
|
|
// issuance runs per tunnel.
|
|
func (a *API) runIssue(tunnelID, hostname, csrPEM, certID string) {
|
|
if !a.beginIssue(tunnelID) {
|
|
return
|
|
}
|
|
defer a.endIssue(tunnelID)
|
|
|
|
// Drop an issuance whose bind was replaced before this run started (a
|
|
// renewal racing a new bind).
|
|
current, ok := a.Store.get(tunnelID)
|
|
if !ok || current.Certificate == nil || current.Certificate.ID != certID || current.Certificate.CSR != csrPEM {
|
|
log.Printf("skipping certificate issuance for %s: certificate %s is no longer current", hostname, certID)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
|
defer cancel()
|
|
log.Printf("issuing certificate for %s via %s", hostname, a.issuerName())
|
|
result, err := a.issuer().Issue(ctx, csrPEM, hostname)
|
|
|
|
// The tunnel may have been deleted, or bound a different CSR, while the
|
|
// order ran. Storing this result would clobber the newer certificate.
|
|
current, ok = a.Store.get(tunnelID)
|
|
if !ok || current.Certificate == nil || current.Certificate.ID != certID || current.Certificate.CSR != csrPEM {
|
|
log.Printf("discarding certificate outcome for %s: certificate %s is no longer current", hostname, certID)
|
|
return
|
|
}
|
|
|
|
cert := storedCert{ID: certID, CSR: csrPEM, Issuer: a.issuerName()}
|
|
if err != nil {
|
|
cert.State = "failed"
|
|
cert.Reason = err.Error()
|
|
if certReady(current.Certificate) {
|
|
// A renewal failed but the old certificate is still valid and
|
|
// serving: keep it, and let the renewal loop retry.
|
|
kept := *current.Certificate
|
|
kept.State = "failed"
|
|
kept.Reason = cert.Reason
|
|
cert = kept
|
|
}
|
|
log.Printf("certificate for %s failed: %v", hostname, err)
|
|
} else {
|
|
cert.State = "ready"
|
|
cert.Certificate = result.Certificate
|
|
cert.Chain = result.Chain
|
|
cert.Expiry = expiryString(result.Expiry)
|
|
log.Printf("certificate for %s ready (expires %s)", hostname, cert.Expiry)
|
|
}
|
|
if err := a.Store.bindCertificate(tunnelID, cert); err != nil {
|
|
log.Printf("storing certificate for %s: %v", hostname, err)
|
|
}
|
|
}
|
|
|
|
func (a *API) beginIssue(tunnelID string) bool {
|
|
a.issueMu.Lock()
|
|
defer a.issueMu.Unlock()
|
|
if a.issuing == nil {
|
|
a.issuing = map[string]bool{}
|
|
}
|
|
if a.issuing[tunnelID] {
|
|
return false
|
|
}
|
|
a.issuing[tunnelID] = true
|
|
return true
|
|
}
|
|
|
|
// startIssue runs issuance in the background for ACME, and synchronously for
|
|
// the local CA so the next GET already answers ready.
|
|
func (a *API) startIssue(tunnelID, hostname, csrPEM, certID string) {
|
|
if a.issuer().Instant() {
|
|
a.runIssue(tunnelID, hostname, csrPEM, certID)
|
|
return
|
|
}
|
|
go a.runIssue(tunnelID, hostname, csrPEM, certID)
|
|
}
|
|
|
|
func (a *API) isIssuing(tunnelID string) bool {
|
|
a.issueMu.Lock()
|
|
defer a.issueMu.Unlock()
|
|
return a.issuing[tunnelID]
|
|
}
|
|
|
|
func (a *API) endIssue(tunnelID string) {
|
|
a.issueMu.Lock()
|
|
defer a.issueMu.Unlock()
|
|
delete(a.issuing, tunnelID)
|
|
}
|
|
|
|
// ---- 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)
|
|
})
|
|
}
|