Phases 11, 12: mcrctl CLI tool and mcr-web UI
Phase 11 implements the admin CLI with dual REST/gRPC transport, global flags (--server, --grpc, --token, --ca-cert, --json), and all commands: status, repo list/delete, policy CRUD, audit tail, gc trigger/status/reconcile, and snapshot. Phase 12 implements the HTMX web UI with chi router, session-based auth (HttpOnly/Secure/SameSite=Strict cookies), CSRF protection (HMAC-SHA256 signed double-submit), and pages for dashboard, repositories, manifest detail, policy management, and audit log. Security: CSRF via signed double-submit cookie, session cookies with HttpOnly/Secure/SameSite=Strict, TLS 1.3 minimum on all connections, form body size limits via http.MaxBytesReader. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
145
cmd/mcrctl/client.go
Normal file
145
cmd/mcrctl/client.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
|
||||
mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
||||
)
|
||||
|
||||
// apiClient wraps both REST and gRPC transports. When grpcAddr is set
|
||||
// the gRPC service clients are used; otherwise requests go via REST.
|
||||
type apiClient struct {
|
||||
serverURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
|
||||
// gRPC (nil when --grpc is not set).
|
||||
grpcConn *grpc.ClientConn
|
||||
registry mcrv1.RegistryServiceClient
|
||||
policy mcrv1.PolicyServiceClient
|
||||
audit mcrv1.AuditServiceClient
|
||||
admin mcrv1.AdminServiceClient
|
||||
}
|
||||
|
||||
// newClient builds an apiClient from the resolved flags.
|
||||
func newClient(serverURL, grpcAddr, token, caCertFile string) (*apiClient, error) {
|
||||
tlsCfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
|
||||
if caCertFile != "" {
|
||||
pem, err := os.ReadFile(caCertFile) //nolint:gosec // CA cert path is operator-supplied
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading CA cert: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("ca-cert file contains no valid certificates")
|
||||
}
|
||||
tlsCfg.RootCAs = pool
|
||||
}
|
||||
|
||||
c := &apiClient{
|
||||
serverURL: serverURL,
|
||||
token: token,
|
||||
httpClient: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: tlsCfg,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if grpcAddr != "" {
|
||||
creds := credentials.NewTLS(tlsCfg)
|
||||
cc, err := grpc.NewClient(grpcAddr,
|
||||
grpc.WithTransportCredentials(creds),
|
||||
grpc.WithDefaultCallOptions(grpc.ForceCodecV2(mcrv1.JSONCodec{})),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("grpc dial: %w", err)
|
||||
}
|
||||
c.grpcConn = cc
|
||||
c.registry = mcrv1.NewRegistryServiceClient(cc)
|
||||
c.policy = mcrv1.NewPolicyServiceClient(cc)
|
||||
c.audit = mcrv1.NewAuditServiceClient(cc)
|
||||
c.admin = mcrv1.NewAdminServiceClient(cc)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// close shuts down the gRPC connection if open.
|
||||
func (c *apiClient) close() {
|
||||
if c.grpcConn != nil {
|
||||
_ = c.grpcConn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// useGRPC returns true when the client should use gRPC transport.
|
||||
func (c *apiClient) useGRPC() bool {
|
||||
return c.grpcConn != nil
|
||||
}
|
||||
|
||||
// apiError is the JSON error envelope returned by the REST API.
|
||||
type apiError struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// restDo performs an HTTP request and returns the response body. If the
|
||||
// response status is >= 400 it reads the JSON error body and returns a
|
||||
// descriptive error.
|
||||
func (c *apiClient) restDo(method, path string, body any) ([]byte, error) {
|
||||
url := c.serverURL + path
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), method, url, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http %s %s: %w", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
var ae apiError
|
||||
if json.Unmarshal(data, &ae) == nil && ae.Error != "" {
|
||||
return nil, fmt.Errorf("server error (%d): %s", resp.StatusCode, ae.Error)
|
||||
}
|
||||
return nil, fmt.Errorf("server error (%d): %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
Reference in New Issue
Block a user