// Copyright (C) 2023 Marius Schellenberger package client import ( "bytes" "context" "errors" "fmt" "io" "net" "net/http" "net/http/httputil" neturl "net/url" "os" "strings" "time" "git.giftfish.de/ston1th/keyctl/pkg/api/types" ) type Client struct { endpoint string unix string httpClient *http.Client dialer *net.Dialer dialerFunc func(ctx context.Context, network, addr string) (net.Conn, error) debugWriter io.Writer //insecure bool } var ( ErrNotFound = errors.New("not found") ) type ClientOption func(*Client) func WithEndpoint(endpoint string) ClientOption { return func(client *Client) { u, err := neturl.Parse(endpoint) if err != nil { return } client.dialer = &net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, } if u.Scheme == "unix" { client.dialerFunc = func(ctx context.Context, network, addr string) (net.Conn, error) { return client.dialer.DialContext(ctx, "unix", u.Path) } client.endpoint = "http://unix" client.unix = u.Path return } client.dialerFunc = client.dialer.DialContext client.endpoint = strings.TrimRight(endpoint, "/") } } func WithDebugWriter(debugWriter io.Writer) ClientOption { return func(client *Client) { client.debugWriter = debugWriter } } func WithHTTPClient(httpClient *http.Client) ClientOption { return func(client *Client) { client.httpClient = httpClient } } //func WithInsecureClient(insecure bool) ClientOption { // return func(client *Client) { // client.insecure = insecure // } //} var ( ErrMissingEndpointEnv = errors.New("missing environment variable KEYCTL_ENDPOINT") ErrMissingEndpointInURL = errors.New("missing host in URL") ErrMissingIDInURL = errors.New("missing ID in URL") ErrUnsupportedScheme = errors.New("unsupported scheme") ErrMissingEndpoint = errors.New("missing Endpoint in config") ) type Config struct { Endpoint string `json:"endpoint"` //Insecure bool `json:"insecure"` } func ClientOptionsFromConfig(cfg Config) (options []ClientOption, err error) { if cfg.Endpoint == "" { err = ErrMissingEndpoint return } options = []ClientOption{ WithEndpoint(cfg.Endpoint), //WithInsecureClient(cfg.Insecure), } return } func ClientOptionsFromEnv() (options []ClientOption, err error) { endpoint := os.Getenv("KEYCTL_ENDPOINT") if endpoint == "" { err = ErrMissingEndpointEnv return } //insecure, _ := strconv.ParseBool(os.Getenv("HAPROXY_LB_INSECURE")) options = []ClientOption{ WithEndpoint(endpoint), //WithInsecureClient(insecure), } return } func ClientOptionsFromURL(url string) (options []ClientOption, err error) { u, err := neturl.Parse(url) if err != nil { return nil, err } if u.Scheme != "keyctl" { return nil, fmt.Errorf("%w: %s", ErrUnsupportedScheme, u.Scheme) } if u.Host == "" { err = ErrMissingEndpointInURL return } //insecure, _ := strconv.ParseBool(os.Getenv("HAPROXY_LB_INSECURE")) var endpoint string if u.Query().Get("tls") == "true" { endpoint = "https://" + u.Host } else { endpoint = "http://" + u.Host } options = []ClientOption{ WithEndpoint(endpoint), //WithInsecureClient(insecure), } return } func KeyFromURL(url string) (id, share string, err error) { u, err := neturl.Parse(url) if err != nil { return } if u.Scheme != "keyctl" { err = fmt.Errorf("%w: %s", ErrUnsupportedScheme, u.Scheme) return } q := u.Query() id = q.Get("id") if id == "" { err = ErrMissingIDInURL return } share = q.Get("share") return } func NewClient(options ...ClientOption) *Client { client := &Client{} for _, option := range options { option(client) } if client.httpClient == nil { client.httpClient = &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, Transport: &http.Transport{ DialContext: client.dialerFunc, //ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, //TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, //TLSClientConfig: &tls.Config{ // InsecureSkipVerify: client.insecure, //}, }, } } return client } func (c *Client) IsUnix() (socket string, ok bool) { return c.unix, c.unix != "" } func (c *Client) NewRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { url := c.endpoint + path req, err := http.NewRequest(method, url, body) if err != nil { return nil, err } req = req.WithContext(ctx) return req, nil } func (c *Client) Do(r *http.Request, v any) (resp *http.Response, err error) { if c.debugWriter != nil { dumpReq, err := httputil.DumpRequestOut(r, true) if err != nil { return &http.Response{}, err } fmt.Fprintf(c.debugWriter, "--- Request:\n%s\n\n", dumpReq) } resp, err = c.httpClient.Do(r) if err != nil { return } body, err := io.ReadAll(resp.Body) if err != nil { resp.Body.Close() return } resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(body)) if c.debugWriter != nil { dumpResp, err := httputil.DumpResponse(resp, true) if err != nil { return resp, err } fmt.Fprintf(c.debugWriter, "--- Response:\n%s\n\n", dumpResp) } err = decodeResp(resp, v) return } func (c *Client) FollowRedirect(r *http.Request, v any, delay time.Duration) (resp *http.Response, err error) { ctx := r.Context() rr := r.Clone(ctx) for { resp, err = c.Do(rr, v) if err == nil && resp.StatusCode == http.StatusOK { return } rdr, ok := err.(Redirect) if !ok { return } rr, err = c.NewRequest(ctx, "GET", rdr.Location, nil) if err != nil { return } time.Sleep(delay) } } func (c *Client) FollowRedirectLoop(r *http.Request, v any, delay time.Duration) (resp *http.Response, err error) { ctx := r.Context() rr := r.Clone(ctx) for { resp, err = c.Do(rr, v) rdr, ok := err.(Redirect) if err != nil && !ok { if errors.Is(err, types.ErrISE) || errors.Is(err, types.ErrKeyNotFound) || errors.Is(err, types.ErrAccessReqNotFound) || errors.Is(err, types.ErrAccessRequestRejected) { return } if errors.Is(err, types.ErrReqNotFound) || errors.Is(err, types.ErrKeyRequestRejected) { rr = r.Clone(ctx) } time.Sleep(delay) continue } if err == nil && resp.StatusCode == http.StatusOK { return } rr, err = c.NewRequest(ctx, "GET", rdr.Location, nil) if err != nil { return } time.Sleep(delay) } }