package openbao import ( "bytes" "context" "crypto/tls" "crypto/x509" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" api "github.com/openbao/openbao/api/v2" "github.com/johnandersen777/socialweb-computer-kcp/internal/contract/vault" "github.com/johnandersen777/socialweb-computer-kcp/internal/kit/lease" ) var ( ErrNotFound = vault.ErrNotFound ErrUnauthorized = errors.New("openbao: the token is not accepted (HTTP 401)") ErrForbidden = errors.New("openbao: denied by policy (HTTP 403)") ErrTransport = errors.New("openbao: transport failure") ErrNoCA = errors.New("openbao: the supplied CA carries no CERTIFICATE PEM block") ErrBodyTooLarge = errors.New("openbao: the response body is too large") ) const ( IdleConnTimeout = 90 * time.Second maxBody = 32 << 20 ) type ResponseError struct { Method string Path string Status int Body string } func (e *ResponseError) Error() string { return fmt.Sprintf("openbao: %s /v1/%s -> HTTP %d: %s", e.Method, e.Path, e.Status, e.Body) } func (e *ResponseError) HTTPStatus() int { return e.Status } func (e *ResponseError) Is(target error) bool { switch e.Status { case http.StatusNotFound: return target == ErrNotFound case http.StatusUnauthorized: return target == ErrUnauthorized case http.StatusForbidden: return target == ErrForbidden default: return false } } func IsAuthError(err error) bool { var re *ResponseError return errors.As(err, &re) && (re.Status == http.StatusUnauthorized || re.Status == http.StatusForbidden) } type tokenSource interface { Get(ctx context.Context) (string, error) Invalidate() } type staticToken string func (t staticToken) Get(context.Context) (string, error) { return string(t), nil } func (staticToken) Invalidate() {} type Client struct { addr string http *http.Client tls *tls.Config timeout time.Duration api *api.Client token tokenSource renews bool err error } type Option func(*Client) func CertPoolFromPEM(caPEM []byte) (*x509.CertPool, error) { pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(caPEM) { return nil, ErrNoCA } return pool, nil } // ponytail: one trust root per client, no system pool; a caller that needs the public roots too can fold them in before WithCACertPool. func WithCACertPool(pool *x509.CertPool) Option { return func(c *Client) { c.tlsConfig().RootCAs = pool } } func WithClientCertificate(cert tls.Certificate) Option { return func(c *Client) { c.tlsConfig().Certificates = []tls.Certificate{cert} } } func WithHTTPClient(client *http.Client) Option { return func(c *Client) { if client != nil { c.http = client } } } func WithTimeout(timeout time.Duration) Option { return func(c *Client) { c.timeout = timeout } } func (c *Client) tlsConfig() *tls.Config { if c.tls == nil { c.tls = &tls.Config{MinVersion: tls.VersionTLS12} c.http = &http.Client{Transport: &http.Transport{TLSClientConfig: c.tls, IdleConnTimeout: IdleConnTimeout}} } return c.tls } func New(addr, token string, opts ...Option) *Client { c := &Client{addr: strings.TrimRight(strings.TrimSpace(addr), "/"), http: http.DefaultClient, token: staticToken(token)} for _, o := range opts { o(c) } c.api, c.err = newAPI(c.addr, c.http, c.timeout) return c } func newAPI(addr string, hc *http.Client, timeout time.Duration) (*api.Client, error) { own := *hc if own.Transport == nil { own.Transport = http.DefaultTransport } hc = &own client, err := api.NewClient(&api.Config{Address: addr, HttpClient: hc, MaxRetries: 0, Timeout: timeout}) if err != nil { return nil, fmt.Errorf("%w: the client for %s: %v", ErrTransport, addr, err) } client.ClearToken() client.ClearNamespace() return client, nil } func (c *Client) with(token tokenSource, renews bool) *Client { return &Client{addr: c.addr, http: c.http, tls: c.tls, timeout: c.timeout, api: c.api, err: c.err, token: token, renews: renews} } func (c *Client) anonymous() *Client { return c.with(staticToken(""), false) } func (c *Client) WithToken(token string) *Client { return c.with(staticToken(token), false) } func (c *Client) Address() string { return c.addr } func leaseUntil(lease time.Duration) time.Time { if lease <= 0 { return time.Time{} } return time.Now().Add(lease) } func (c *Client) leased(ctx context.Context, login func(context.Context) (loginAuth, error)) (*Client, error) { src := lease.New(func(ctx context.Context) (string, time.Time, error) { auth, err := login(ctx) if err != nil { return "", time.Time{}, err } return auth.token, leaseUntil(auth.lease), nil }) if _, err := src.Get(ctx); err != nil { return nil, err } return c.with(src, true), nil } func (c *Client) LoginCert(ctx context.Context, mount, role string) (*Client, error) { path := "auth/" + strings.Trim(mount, "/") + "/login" anonymous := c.anonymous() return c.leased(ctx, func(ctx context.Context) (loginAuth, error) { return anonymous.login(ctx, path, map[string]any{"name": role}) }) } func (c *Client) LoginAppRole(ctx context.Context, mount, roleID, secretID string) (*Client, error) { if mount = strings.Trim(mount, "/"); mount == "" { mount = "approle" } path := "auth/" + mount + "/login" anonymous := c.anonymous() return c.leased(ctx, func(ctx context.Context) (loginAuth, error) { return anonymous.login(ctx, path, map[string]any{"role_id": roleID, "secret_id": secretID}) }) } func (c *Client) LoginJWT(ctx context.Context, mount, role, jwt string) (*Client, error) { auth, err := c.anonymous().login(ctx, "auth/"+strings.Trim(mount, "/")+"/login", map[string]any{"role": role, "jwt": jwt}) if err != nil { return nil, err } return c.WithToken(auth.token), nil } type loginAuth struct { token string accessor string lease time.Duration renewable bool } func (c *Client) login(ctx context.Context, path string, body map[string]any) (loginAuth, error) { status, raw, err := c.send(ctx, http.MethodPost, path, body, "") if err != nil { return loginAuth{}, err } if status < 200 || status > 299 { return loginAuth{}, &ResponseError{Method: http.MethodPost, Path: path, Status: status, Body: strings.TrimSpace(string(raw))} } return decodeAuth(path, raw, true) } func decodeAuth(path string, raw []byte, needToken bool) (loginAuth, error) { var payload struct { Auth struct { ClientToken string `json:"client_token"` Accessor string `json:"accessor"` LeaseDuration int64 `json:"lease_duration"` Renewable bool `json:"renewable"` } `json:"auth"` } if err := json.Unmarshal(raw, &payload); err != nil { return loginAuth{}, fmt.Errorf("openbao: %s returned a body that is not JSON: %v", path, err) } if needToken && payload.Auth.ClientToken == "" { return loginAuth{}, fmt.Errorf("openbao: %s answered without a client token", path) } return loginAuth{token: payload.Auth.ClientToken, accessor: payload.Auth.Accessor, lease: time.Duration(payload.Auth.LeaseDuration) * time.Second, renewable: payload.Auth.Renewable}, nil } func (c *Client) RevokeSelf(ctx context.Context) error { _, err := c.topLevel(ctx, http.MethodPost, "auth/token/revoke-self", nil) c.token.Invalidate() return err } func (c *Client) do(ctx context.Context, method, path string, in any) (json.RawMessage, error) { status, body, err := c.request(ctx, method, path, in) if err != nil { return nil, err } if status < 200 || status > 299 { return nil, &ResponseError{Method: method, Path: path, Status: status, Body: strings.TrimSpace(string(body))} } if len(bytes.TrimSpace(body)) == 0 { return nil, nil } var envelope struct { Data json.RawMessage `json:"data"` } if err := json.Unmarshal(body, &envelope); err != nil { return nil, fmt.Errorf("openbao: %s /v1/%s returned a body that is not JSON: %v (%s)", method, path, err, strings.TrimSpace(string(body))) } return envelope.Data, nil } func (c *Client) topLevel(ctx context.Context, method, path string, in any) (json.RawMessage, error) { status, body, err := c.request(ctx, method, path, in) if err != nil { return nil, err } if status < 200 || status > 299 { return nil, &ResponseError{Method: method, Path: path, Status: status, Body: strings.TrimSpace(string(body))} } return json.RawMessage(bytes.TrimSpace(body)), nil } func (c *Client) request(ctx context.Context, method, path string, in any) (int, []byte, error) { token, err := c.token.Get(ctx) if err != nil { return 0, nil, fmt.Errorf("%w: the token for %s /v1/%s: %v", ErrTransport, method, path, err) } status, body, err := c.send(ctx, method, path, in, token) if err != nil || !c.renews || (status != http.StatusUnauthorized && status != http.StatusForbidden) { return status, body, err } c.token.Invalidate() fresh, ferr := c.token.Get(ctx) if ferr != nil || fresh == token { return status, body, nil } return c.send(ctx, method, path, in, fresh) } func (c *Client) send(ctx context.Context, method, path string, in any, token string) (int, []byte, error) { if c.err != nil { return 0, nil, c.err } clean, query, _ := strings.Cut(strings.TrimPrefix(path, "/"), "?") r := c.api.NewRequest(method, "/v1/"+clean) r.ClientToken = token r.WrapTTL = "" if query != "" { values, err := url.ParseQuery(query) if err != nil { return 0, nil, fmt.Errorf("%w: the query of %s /v1/%s: %v", ErrTransport, method, path, err) } r.Params = values } if in != nil { if err := r.SetJSONBody(in); err != nil { return 0, nil, fmt.Errorf("%w: encode %s /v1/%s: %v", ErrTransport, method, path, err) } } resp, err := c.api.RawRequestWithContext(ctx, r) if resp == nil || resp.Response == nil { if err == nil { err = errors.New("no response") } if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return 0, nil, err } return 0, nil, fmt.Errorf("%w: %s /v1/%s: %v", ErrTransport, method, path, err) } defer resp.Body.Close() body, rerr := io.ReadAll(io.LimitReader(resp.Body, maxBody+1)) if rerr != nil { return 0, nil, fmt.Errorf("%w: reading %s /v1/%s: %v", ErrTransport, method, path, rerr) } if len(body) > maxBody { return resp.StatusCode, nil, fmt.Errorf("%w: %s /v1/%s answered more than %d bytes", ErrBodyTooLarge, method, path, maxBody) } return resp.StatusCode, body, nil } func (c *Client) Raw(ctx context.Context, method, path string, body any) (int, []byte, error) { return c.request(ctx, method, path, body) }