Files
zeekayandHanzo Dev 472238ddb0 cd: the API is /v1, not /api/v1 — no /api prefix anywhere
House rule: /v1/ is the API prefix, everywhere, and /api/ is never added on
top of it. The api.* subdomain names the API; a path prefix restating it is
noise. This applies it to the whole route surface:

  /api/v1/*      -> /v1/*        106 proto annotations, 109 gateway patterns
  /api/version   -> /v1/version
  /api/badge     -> /v1/badge
  /api/webhook   -> /v1/webhook  (server + applicationset controller)
  /api/dex/*     -> /v1/dex/*    (constants; dex is built, not deployed)

The grpc-gateway .pb.gw.go files are generated, and the plugin toolchain is
not on this machine, so they were rewritten by a script that applies exactly
the transform codegen would: for /api/v1 paths, drop the leading "api"
literal-push opcode pair and decrement every LitPush(2,N)/Capture(5,N) pool
index; for /api/version, rename the segment. ConcatN(4,N) operands are
counts, not pool indexes, and are untouched. The .proto annotations moved in
the same commit, so the next real codegen run is a no-op against these files.

MustPattern panics at package init on a malformed pattern, so every rewritten
pattern is validated by any import of pkg/apiclient — those tests pass.

Also moved: assets/swagger.json (82 paths), the UI's two roots (apiRoot()
'/v1', version-service '/v1/version') and its two relative composers (app
logs download, badge panel), the five audit-pattern regexes in server.go, and
the tests that asserted the old paths. terminal_test's request URL claimed
/api/v1/terminal, a route that never existed — the handler is mounted at
/terminal; the fixture now says so.

NOT touched, deliberately: /api/v3 in GitHub Enterprise base-url examples
(GitHub's URL shape, not ours), util/vendored/kubeclientmetrics (parses the
KUBERNETES API server's /api/v1 paths), and util/http's cookie-path fixture
(arbitrary string exercising slash normalization).

go build ./... clean. Pre-existing failures in badge/session/webhook/account
tests reproduce identically on the parent commit (rebrand fallout — badge
colors, cookie name length, dex issuer) and are not made worse here.

External consumers checked: universe has zero references to cd.hanzo.ai/api;
the CLI speaks gRPC, not the REST gateway; UI and server ship in one image so
they move together.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 12:01:34 -07:00

134 lines
3.7 KiB
Go

package dex
import (
"bytes"
"crypto/tls"
"crypto/x509"
stderrors "errors"
"io"
"net/http"
"net/http/httputil"
"net/url"
"path"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
"github.com/hanzoai/cd/common"
"github.com/hanzoai/cd/util/errors"
)
type DexTLSConfig struct {
DisableTLS bool
StrictValidation bool
RootCAs *x509.CertPool
Certificate []byte
}
func TLSConfig(tlsConfig *DexTLSConfig) *tls.Config {
if tlsConfig == nil || tlsConfig.DisableTLS {
return nil
}
if !tlsConfig.StrictValidation {
return &tls.Config{
InsecureSkipVerify: true,
}
}
return &tls.Config{
InsecureSkipVerify: false,
RootCAs: tlsConfig.RootCAs,
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if !bytes.Equal(rawCerts[0], tlsConfig.Certificate) {
return stderrors.New("dex server certificate does not match")
}
return nil
},
}
}
// NewDexHTTPReverseProxy returns a reverse proxy to the Dex server. Dex is assumed to be configured
// with the external issuer URL muxed to the same path configured in server.go. In other words, if
// Hanzo CD API server wants to proxy requests at /v1/dex, then the dex config yaml issuer URL should
// also be /v1/dex (e.g. issuer: https://cd.example.com/v1/dex)
func NewDexHTTPReverseProxy(serverAddr string, baseHRef string, tlsConfig *DexTLSConfig) func(writer http.ResponseWriter, request *http.Request) {
fullAddr := DexServerAddressWithProtocol(serverAddr, tlsConfig)
target, err := url.Parse(fullAddr)
errors.CheckError(err)
target.Path = baseHRef
proxy := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(target)
r.Out.Host = target.Host
},
ModifyResponse: func(resp *http.Response) error {
if resp.StatusCode == http.StatusInternalServerError {
b, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
err = resp.Body.Close()
if err != nil {
return err
}
log.WithFields(log.Fields{
common.SecurityField: common.SecurityMedium,
}).Errorf("received error from dex: %s", string(b))
resp.ContentLength = 0
resp.Header.Set("Content-Length", strconv.Itoa(0))
resp.Header.Set("Location", path.Join(baseHRef, "login")+"?has_sso_error=true")
resp.StatusCode = http.StatusSeeOther
resp.Body = io.NopCloser(bytes.NewReader(make([]byte, 0)))
return nil
}
return nil
},
}
if tlsConfig != nil && !tlsConfig.DisableTLS {
proxy.Transport = &http.Transport{
TLSClientConfig: TLSConfig(tlsConfig),
}
}
return func(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r)
}
}
// NewDexRewriteURLRoundTripper creates a new DexRewriteURLRoundTripper
func NewDexRewriteURLRoundTripper(dexServerAddr string, t http.RoundTripper) DexRewriteURLRoundTripper {
dexURL, _ := url.Parse(dexServerAddr)
return DexRewriteURLRoundTripper{
DexURL: dexURL,
T: t,
}
}
// DexRewriteURLRoundTripper is an HTTP RoundTripper to rewrite HTTP requests to the specified
// dex server address. This is used when reverse proxying Dex to avoid the API server from
// unnecessarily communicating to Hanzo CD through its externally facing load balancer, which is not
// always permitted in firewalled/air-gapped networks.
type DexRewriteURLRoundTripper struct {
DexURL *url.URL
T http.RoundTripper
}
func (s DexRewriteURLRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
r.URL.Host = s.DexURL.Host
r.URL.Scheme = s.DexURL.Scheme
r.Host = s.DexURL.Host
return s.T.RoundTrip(r)
}
func DexServerAddressWithProtocol(orig string, tlsConfig *DexTLSConfig) string {
if strings.Contains(orig, "://") {
return orig
}
if tlsConfig == nil || tlsConfig.DisableTLS {
return "http://" + orig
}
return "https://" + orig
}