Compare commits

...
1 Commits
Author SHA1 Message Date
zeekayandhanzo-dev 36c52d1b5f git: SSH transport, ZAP transport, and client-less REST push
Three native-git additions to clients/git, all over ONE core per operation
(one implementation, N transports — no gRPC, luxfi/zap only):

DRY refactor first — extract the transport-agnostic layer so every transport
is a thin adapter:
- core.go: coreCreate/coreList/coreGet/coreDelete/coreUsage — the control-plane
  business logic, once. REST handlers (git.go) and ZAP procedures (zap.go) are
  thin adapters over these, mapping typed sentinels (errBadInput/errConflict/
  errNotFound) to each transport's own status vocabulary.
- pack.go: the ONE Git pack code path. smart-HTTP and SSH both drive
  clone/fetch/push through serve*/ssh* funcs over io.Reader/io.Writer, so there
  is one place that decodes a request, runs the go-git session, records usage,
  and fires the push-to-deploy hook.

Git over SSH (ssh.go, keystore.go, keys.go):
- `git clone git@git.hanzo.ai:<org>/<repo>.git` alongside smart-HTTP.
- golang.org/x/crypto/ssh (already a direct dep — no new heavy dep).
- Host key from KMS/env (CLOUD_GIT_SSH_HOST_KEY) or an on-disk ed25519 key
  generated + persisted 0600 on first boot. Never hardcoded, never logged.
- Per-user keys via POST/GET/DELETE /v1/git/keys, stored in a global registry
  keyed by SHA256 fingerprint (the auth lookup). A key belongs to exactly one
  org. PublicKeyCallback resolves the presented key → (org,user) and fails
  CLOSED on any unknown key.
- Session handler accepts only git-upload-pack / git-receive-pack, enforces
  path-org == key-bound-org (no cross-tenant), and drives the shared pack path.
- Listen addr CLOUD_GIT_SSH_ADDR (default :2222; :22 fronted by a k8s TCP LB).
  Started + gracefully stopped from Mount/Shutdown.

ZAP transport (zap.go):
- git's control plane is reachable over the shared zapface /zap WebSocket plane
  as procedures git/zap/{createRepo,listRepos,getRepo,deleteRepo,usage} — thin
  envelope adapters over the SAME core funcs. Documents THE STANDARD the next
  service copies: no per-service ZAP server, no gRPC.

Client-less REST push (push.go):
- POST /v1/git/repos/:name/push builds a tree+commit from posted files (utf-8
  or base64), merged onto the branch tip, advances the ref, and fires the SAME
  git-push-to-deploy hook a real receive-pack fires. Creates the repo on first
  push. Returns commit + cloneUrl + sshUrl. Tenant-scoped like every route.

Repos now expose sshUrl (git@<sshHost>:<org>/<repo>.git) alongside cloneUrl.

Tests (go test ./clients/git/... — 16 pass): SSH key register accept/reject,
cross-tenant reject, and an end-to-end SSH clone+push round-trip over an
in-process listener; a zapface WS round-trip for createRepo+listRepos proving
the ZAP path hits the same core as REST; REST push create/update/build-hook,
first-push provisioning, validation, and cross-tenant isolation.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-11 23:45:20 -07:00
14 changed files with 2510 additions and 179 deletions
+161
View File
@@ -0,0 +1,161 @@
package git
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
// core.go is the transport-agnostic business logic for git's control plane —
// the ONE implementation each of create / list / get / delete / usage. The REST
// handlers (git.go) and the ZAP procedures (zap.go) are both THIN ADAPTERS over
// these funcs: a transport resolves the tenant + decodes its own wire shape,
// calls the core, and maps the returned error to its own status vocabulary.
//
// This is the "one and only one way" rule made concrete: one core func, two
// transports. The business rules (name validation, project sub-scope, conflict
// vs not-found, storage provisioning, usage measurement) live here once and can
// never drift between the HTTP and ZAP paths.
//
// Errors are typed sentinels so each transport maps them independently:
// - errBadInput → 400 (HTTP) / dispatch error (ZAP)
// - errConflict → 409
// - errNotFound → 404
// A wrapped errBadInput carries a human message for the client.
// errBadInput marks a caller-supplied validation failure (bad name, oversized
// description, malformed project). Transports answer 400. errConflict /
// errNotFound are defined in store.go and reused verbatim.
var errBadInput = errors.New("git: invalid input")
// badInput wraps a message as an errBadInput so a transport can surface the
// reason while still matching errors.Is(err, errBadInput).
func badInput(format string, a ...any) error {
return fmt.Errorf("%w: %s", errBadInput, fmt.Sprintf(format, a...))
}
// coreCreate provisions a repo for (org, project) and returns its view. org is
// the validated tenant; project defaults to the caller's sub-scope. It is the
// ONE create path — REST POST /v1/git/repos and the ZAP createRepo procedure
// both call it. Maps to errBadInput / errConflict for the transport.
func (s *svc) coreCreate(ctx context.Context, org, headerProject string, in createReq) (repoView, error) {
store, err := s.storeFor(org)
if err != nil {
return repoView{}, fmt.Errorf("open store: %w", err)
}
name := normalizeName(in.Name)
if name == "" {
return repoView{}, badInput("name is required")
}
if !nameRE.MatchString(name) {
return repoView{}, badInput("name must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
}
// Project sub-scope: an explicit body value wins, else the transport's
// header/context sub-scope.
project := strings.TrimSpace(in.Project)
if project == "" {
project = headerProject
} else if !projectRE.MatchString(project) {
return repoView{}, badInput("project must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
}
if len(in.Description) > 4096 {
return repoView{}, badInput("description too large (max 4KiB)")
}
id, err := genID("repo")
if err != nil {
return repoView{}, fmt.Errorf("rng: %w", err)
}
now := time.Now().Unix()
r := Repo{
ID: id, Org: org, Project: project, Name: name,
Description: strings.TrimSpace(in.Description), DefaultBranch: defaultBranchName,
CreatedAt: now, UpdatedAt: now,
}
if err := s.provision(ctx, store, r); err != nil {
if errors.Is(err, errConflict) {
return repoView{}, errConflict
}
return repoView{}, fmt.Errorf("provision: %w", err)
}
r.SizeBytes = s.recordUsage(ctx, org, project, name)
return s.toView(r, nil, ""), nil
}
// coreList returns the repos for (org, project), most-recently-updated first.
func (s *svc) coreList(ctx context.Context, org, project string) ([]repoView, error) {
store, err := s.storeFor(org)
if err != nil {
return nil, fmt.Errorf("open store: %w", err)
}
rows, err := store.List(ctx, org, project)
if err != nil {
return nil, fmt.Errorf("list: %w", err)
}
out := make([]repoView, 0, len(rows))
for _, r := range rows {
out = append(out, s.toView(r, nil, ""))
}
return out, nil
}
// coreGet returns one repo's detail view (branches + resolved HEAD), or
// errNotFound.
func (s *svc) coreGet(ctx context.Context, org, project, name string) (repoView, error) {
store, err := s.storeFor(org)
if err != nil {
return repoView{}, fmt.Errorf("open store: %w", err)
}
name = normalizeName(name)
r, err := store.Get(ctx, org, project, name)
if errors.Is(err, errNotFound) {
return repoView{}, errNotFound
}
if err != nil {
return repoView{}, fmt.Errorf("get: %w", err)
}
branches, head := s.refState(org, project, name)
return s.toView(r, branches, head), nil
}
// coreDelete removes a repo + purges its storage. Returns errNotFound when no
// row went. Storage purge failure is logged, never fatal (metadata is the
// source of truth for existence).
func (s *svc) coreDelete(ctx context.Context, org, project, name string) error {
store, err := s.storeFor(org)
if err != nil {
return fmt.Errorf("open store: %w", err)
}
name = normalizeName(name)
deleted, err := store.Delete(ctx, org, project, name)
if err != nil {
return fmt.Errorf("delete: %w", err)
}
if !deleted {
return errNotFound
}
if err := s.storage.remove(org, project, name); err != nil {
s.log.Warn("purge repo storage failed (continuing)", "org", org, "project", project, "repo", name, "err", err)
}
return nil
}
// coreUsage returns the org-wide per-repo + total storage rollup.
func (s *svc) coreUsage(ctx context.Context, org string) (usageView, error) {
store, err := s.storeFor(org)
if err != nil {
return usageView{}, fmt.Errorf("open store: %w", err)
}
rows, err := store.ListOrg(ctx, org)
if err != nil {
return usageView{}, fmt.Errorf("usage: %w", err)
}
out := usageView{Org: org, Repos: make([]usageRepo, 0, len(rows))}
for _, r := range rows {
out.Repos = append(out.Repos, usageRepo{Name: r.Name, Project: r.Project, SizeBytes: r.SizeBytes})
out.TotalBytes += r.SizeBytes
}
return out, nil
}
+87 -87
View File
@@ -72,6 +72,9 @@ type svc struct {
storage *storage
log luxlog.Logger
domain string // for cloneUrl construction
sshHost string // for sshUrl construction (e.g. "git.hanzo.ai")
ssh *sshServer
keys *keyStore // SSH public-key registry (global fingerprint index)
}
// storeFor resolves the caller's org-scoped repo-metadata store, opening the
@@ -95,6 +98,7 @@ type repoView struct {
Branches []string `json:"branches,omitempty"`
Head string `json:"head,omitempty"`
CloneURL string `json:"cloneUrl"`
SSHURL string `json:"sshUrl"`
SizeBytes int64 `json:"sizeBytes"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt,omitempty"`
@@ -115,11 +119,23 @@ func (s *svc) cloneURL(org, name string) string {
return fmt.Sprintf("https://%s/v1/git/%s/%s.git", host, org, name)
}
// sshURL is the scp-style Git SSH remote: git@<sshHost>:<org>/<repo>.git. The
// colon (not slash) after the host is the canonical scp-like syntax `git clone`
// accepts; the org/repo tail is the same path the SSH exec handler parses.
func (s *svc) sshURL(org, name string) string {
host := s.sshHost
if host == "" {
host = defaultSSHHost(s.domain)
}
return fmt.Sprintf("git@%s:%s/%s.git", host, org, name)
}
func (s *svc) toView(r Repo, branches []string, head string) repoView {
return repoView{
ID: r.ID, Org: r.Org, Project: r.Project, Name: r.Name, Description: r.Description,
DefaultBranch: r.DefaultBranch, Branches: branches, Head: head,
CloneURL: s.cloneURL(r.Org, r.Name),
SSHURL: s.sshURL(r.Org, r.Name),
SizeBytes: r.SizeBytes, CreatedAt: rfc3339(r.CreatedAt), UpdatedAt: rfc3339(r.UpdatedAt),
}
}
@@ -137,15 +153,22 @@ func Mount(app *zip.App, deps cloud.Deps) error {
if deps.DataDir == "" {
return fmt.Errorf("git.Mount: empty DataDir")
}
st, err := newStorage(filepath.Join(deps.DataDir, "git"))
gitRoot := filepath.Join(deps.DataDir, "git")
st, err := newStorage(gitRoot)
if err != nil {
return fmt.Errorf("git.Mount: open storage: %w", err)
}
keys, err := openKeyStore(filepath.Join(gitRoot, "ssh_keys.db"))
if err != nil {
return fmt.Errorf("git.Mount: open ssh key store: %w", err)
}
s := &svc{
stores: cloud.NewTenantStore(deps.DataDir, "git", openStore),
storage: st,
log: log,
domain: deps.Domain,
sshHost: gitSSHHost(deps.Domain),
keys: keys,
}
mounted = s
@@ -156,6 +179,13 @@ func Mount(app *zip.App, deps cloud.Deps) error {
app.Get("/v1/git/usage", s.usage)
app.Get("/v1/git/repos/:name", s.get)
app.Delete("/v1/git/repos/:name", s.del)
// Push generated files without a local git client (hanzo.app builder).
// A distinct trailing segment, so it never shadows the :org/:repo routes.
app.Post("/v1/git/repos/:name/push", s.pushFiles)
// SSH public-key registry (per-user keys for `git clone git@…`).
app.Post("/v1/git/keys", s.registerKey)
app.Get("/v1/git/keys", s.listKeys)
app.Delete("/v1/git/keys/:id", s.deleteKey)
// Mirror an external repo into <org>/:name (creates the repo on first use).
// A distinct trailing segment, so it never shadows the :org/:repo smart-HTTP
// routes below.
@@ -167,7 +197,26 @@ func Mount(app *zip.App, deps cloud.Deps) error {
app.Post("/v1/git/:org/:repo/git-upload-pack", s.uploadPack)
app.Post("/v1/git/:org/:repo/git-receive-pack", s.receivePack)
log.Info("git mounted", "brand", deps.Brand, "storage", "osfs", "root", filepath.Join(deps.DataDir, "git"))
// ZAP transport (WebSocket) — the SAME control-plane core, reachable by
// browsers/services that speak ZAP instead of REST. Mounts at /zap. See zap.go.
if err := s.mountZAP(app); err != nil {
return fmt.Errorf("git.Mount: mount ZAP: %w", err)
}
// SSH transport: `git clone git@<sshHost>:<org>/<repo>.git`. The listener is
// a per-process goroutine started here and stopped by Shutdown. The host key
// is loaded from KMS/env or generated + persisted under the git data root.
sshSrv, err := newSSHServer(s, sshConfig(deps, gitRoot))
if err != nil {
return fmt.Errorf("git.Mount: init ssh: %w", err)
}
s.ssh = sshSrv
if err := sshSrv.start(); err != nil {
return fmt.Errorf("git.Mount: start ssh: %w", err)
}
log.Info("git mounted", "brand", deps.Brand, "storage", "osfs", "root", gitRoot,
"sshHost", s.sshHost, "sshAddr", sshSrv.addr(), "zap", "/zap")
return nil
}
@@ -188,51 +237,28 @@ func (s *svc) create(c *zip.Ctx) error {
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
store, err := s.storeFor(org)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
}
var body createReq
if err := c.Bind(&body); err != nil {
return err
}
name := normalizeName(body.Name)
if name == "" {
return zip.ErrBadRequest("name is required")
}
if !nameRE.MatchString(name) {
return zip.ErrBadRequest("name must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
}
// Project sub-scope: explicit body value wins, else the header sub-scope.
project := strings.TrimSpace(body.Project)
if project == "" {
project = projectScope(c)
} else if !projectRE.MatchString(project) {
return zip.ErrBadRequest("project must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
}
if len(body.Description) > 4096 {
return zip.ErrBadRequest("description too large (max 4KiB)")
}
id, err := genID("repo")
view, err := s.coreCreate(c.Context(), org, projectScope(c), body)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
return createErr(err)
}
now := time.Now().Unix()
r := Repo{
ID: id, Org: org, Project: project, Name: name,
Description: strings.TrimSpace(body.Description), DefaultBranch: defaultBranchName,
CreatedAt: now, UpdatedAt: now,
return c.JSON(http.StatusCreated, view)
}
// createErr maps a coreCreate error to its HTTP status. The ONE mapping the REST
// adapter applies; the ZAP adapter maps the SAME sentinels to its own vocabulary.
func createErr(err error) error {
switch {
case errors.Is(err, errBadInput):
return zip.ErrBadRequest(strings.TrimPrefix(err.Error(), "git: invalid input: "))
case errors.Is(err, errConflict):
return zip.ErrConflict("repo name already exists in this scope")
default:
return zip.Errorf(http.StatusInternalServerError, "%v", err)
}
if err := s.provision(c.Context(), store, r); err != nil {
if errors.Is(err, errConflict) {
return zip.ErrConflict("repo name already exists in this scope")
}
return zip.Errorf(http.StatusInternalServerError, "provision: %v", err)
}
// Record initial storage size (billing hook: an empty bare repo is a few KiB).
r.SizeBytes = s.recordUsage(c.Context(), org, project, name)
return c.JSON(http.StatusCreated, s.toView(r, nil, ""))
}
// provision materializes a repo: its metadata row plus an empty bare repo on
@@ -256,17 +282,9 @@ func (s *svc) list(c *zip.Ctx) error {
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
store, err := s.storeFor(org)
out, err := s.coreList(c.Context(), org, projectScope(c))
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
}
rows, err := store.List(c.Context(), org, projectScope(c))
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "list: %v", err)
}
out := make([]repoView, 0, len(rows))
for _, r := range rows {
out = append(out, s.toView(r, nil, ""))
return zip.Errorf(http.StatusInternalServerError, "%v", err)
}
return c.JSON(http.StatusOK, map[string]any{"data": out})
}
@@ -276,21 +294,14 @@ func (s *svc) get(c *zip.Ctx) error {
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
store, err := s.storeFor(org)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
}
name := normalizeName(c.Param("name"))
project := projectScope(c)
r, err := store.Get(c.Context(), org, project, name)
view, err := s.coreGet(c.Context(), org, projectScope(c), c.Param("name"))
if errors.Is(err, errNotFound) {
return zip.ErrNotFound("repo not found")
}
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
return zip.Errorf(http.StatusInternalServerError, "%v", err)
}
branches, head := s.refState(org, project, name)
return c.JSON(http.StatusOK, s.toView(r, branches, head))
return c.JSON(http.StatusOK, view)
}
func (s *svc) del(c *zip.Ctx) error {
@@ -298,23 +309,12 @@ func (s *svc) del(c *zip.Ctx) error {
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
store, err := s.storeFor(org)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
}
name := normalizeName(c.Param("name"))
project := projectScope(c)
deleted, err := store.Delete(c.Context(), org, project, name)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "delete: %v", err)
}
if !deleted {
err := s.coreDelete(c.Context(), org, projectScope(c), c.Param("name"))
if errors.Is(err, errNotFound) {
return zip.ErrNotFound("repo not found")
}
// Purge storage. Metadata is already gone, so a purge failure must not
// resurrect the repo — log and continue.
if err := s.storage.remove(org, project, name); err != nil {
s.log.Warn("purge repo storage failed (continuing)", "org", org, "project", project, "repo", name, "err", err)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "%v", err)
}
return c.NoContent(http.StatusNoContent)
}
@@ -341,18 +341,9 @@ func (s *svc) usage(c *zip.Ctx) error {
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
store, err := s.storeFor(org)
out, err := s.coreUsage(c.Context(), org)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
}
rows, err := store.ListOrg(c.Context(), org)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "usage: %v", err)
}
out := usageView{Org: org, Repos: make([]usageRepo, 0, len(rows))}
for _, r := range rows {
out.Repos = append(out.Repos, usageRepo{Name: r.Name, Project: r.Project, SizeBytes: r.SizeBytes})
out.TotalBytes += r.SizeBytes
return zip.Errorf(http.StatusInternalServerError, "%v", err)
}
return c.JSON(http.StatusOK, out)
}
@@ -453,12 +444,21 @@ func genID(prefix string) (string, error) {
return prefix + "_" + hex.EncodeToString(b[:]), nil
}
// Shutdown closes every open per-org git store. Idempotent.
// Shutdown stops the SSH listener and closes every open store (per-org repo
// metadata + the SSH key registry). Idempotent.
func Shutdown() error {
if mounted == nil {
return nil
}
if mounted.ssh != nil {
mounted.ssh.stop()
}
err := mounted.stores.CloseAll()
if mounted.keys != nil {
if kerr := mounted.keys.Close(); kerr != nil && err == nil {
err = kerr
}
}
mounted = nil
return err
}
+7 -3
View File
@@ -12,7 +12,6 @@ import (
"testing"
"time"
"github.com/zap-proto/fiber/v3"
"github.com/go-git/go-billy/v5/memfs"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
@@ -22,9 +21,10 @@ import (
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
"github.com/valyala/fasthttp"
luxlog "github.com/luxfi/log"
"github.com/valyala/fasthttp"
"github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
)
var testCfg = fiber.TestConfig{Timeout: 10 * time.Second, FailOnTimeout: true}
@@ -63,6 +63,10 @@ func asTenant(org string) { asOrg.Lock(); asOrg.org = org; asOrg.Unlock() }
func mountApp(t *testing.T) *zip.App {
t.Helper()
// Bind the SSH listener to an ephemeral loopback port so parallel/sequential
// tests never collide on the default :2222 (each Mount gets its own port +
// host key under its own TempDir).
t.Setenv("CLOUD_GIT_SSH_ADDR", "127.0.0.1:0")
app := zip.New(zip.Config{Logger: luxlog.New("test")})
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), Domain: "api.hanzo.test"}); err != nil {
t.Fatalf("Mount: %v", err)
+115
View File
@@ -0,0 +1,115 @@
package git
import (
"errors"
"net/http"
"strings"
"time"
"github.com/zap-proto/zip"
"golang.org/x/crypto/ssh"
)
// keys.go is the control-plane surface for the SSH public-key registry:
//
// POST /v1/git/keys register a key (title + openssh pubkey) -> keyView (201)
// GET /v1/git/keys list the tenant's keys -> {data:[keyView]}
// DELETE /v1/git/keys/:id remove a key -> 204
//
// These are thin adapters over keystore.go, tenant-scoped identically to the
// repo routes (principal.Tenant → X-Org-Id). A key is stored with its SHA256
// fingerprint as the global unique handle; SSH auth (ssh.go) resolves a
// presented key to its owner by that fingerprint.
type registerKeyReq struct {
Title string `json:"title"`
PublicKey string `json:"publicKey"`
}
// registerKey validates an OpenSSH public key, computes its fingerprint, and
// stores it under the caller's org + user. The full key round-trips (it is
// public); the fingerprint is the auth lookup key. A key already registered
// (to this or any org — fingerprint is globally unique) yields 409.
func (s *svc) registerKey(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
var body registerKeyReq
if err := c.Bind(&body); err != nil {
return err
}
raw := strings.TrimSpace(body.PublicKey)
if raw == "" {
return zip.ErrBadRequest("publicKey is required")
}
title := strings.TrimSpace(body.Title)
if len(title) > 256 {
return zip.ErrBadRequest("title too long (max 256)")
}
// Parse the authorized-key line to validate it and canonicalize the stored
// form + fingerprint. A malformed key is a 400, never stored.
pub, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(raw))
if err != nil {
return zip.ErrBadRequest("invalid openssh public key")
}
if title == "" {
title = strings.TrimSpace(comment) // fall back to the key comment as the label
}
// Canonical authorized-key line (type + base64), no trailing newline.
canonical := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))
fp := ssh.FingerprintSHA256(pub)
id, err := genID("gitkey")
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
}
row := sshKey{
ID: id, Org: org, UserID: strings.TrimSpace(c.User()), Title: title,
PublicKey: canonical, Fingerprint: fp, CreatedAt: time.Now().Unix(),
}
if err := s.keys.Add(c.Context(), row); err != nil {
if errors.Is(err, errKeyConflict) {
return zip.ErrConflict("this ssh key is already registered")
}
return zip.Errorf(http.StatusInternalServerError, "register key: %v", err)
}
return c.JSON(http.StatusCreated, row.view())
}
// listKeys returns the caller org's registered keys.
func (s *svc) listKeys(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
rows, err := s.keys.List(c.Context(), org)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "list keys: %v", err)
}
out := make([]keyView, 0, len(rows))
for _, r := range rows {
out = append(out, r.view())
}
return c.JSON(http.StatusOK, map[string]any{"data": out})
}
// deleteKey removes a key by id, scoped to the caller's org (a tenant can only
// delete its own keys).
func (s *svc) deleteKey(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
return zip.ErrBadRequest("key id required")
}
if err := s.keys.Delete(c.Context(), org, id); err != nil {
if errors.Is(err, errKeyNotFound) {
return zip.ErrNotFound("key not found")
}
return zip.Errorf(http.StatusInternalServerError, "delete key: %v", err)
}
return c.NoContent(http.StatusNoContent)
}
+189
View File
@@ -0,0 +1,189 @@
package git
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
_ "github.com/hanzoai/sqlite" // registers the "sqlite" driver
)
// keystore.go is the SSH public-key registry: the ONE place a presented SSH key
// is resolved to a (tenant, user). It is a SINGLE global SQLite file
// ({DataDir}/git/ssh_keys.db) keyed by the key fingerprint, NOT a per-org file
// like the repo store — because the SSH PublicKeyCallback runs BEFORE any org is
// known: the ONLY thing the server has is the presented key. Auth is therefore a
// single fingerprint lookup, and the row carries the org the key belongs to.
//
// The fingerprint (SHA256, RFC 4253 form) is the PRIMARY KEY, so a given public
// key belongs to exactly one org: re-registering the same key under a different
// org is refused (errKeyConflict). Listing + deletion filter by org, so a tenant
// only ever sees/removes its own keys.
//
// What is stored: the key TITLE (a human label), the full authorized-key line
// (the public key — public keys are NOT secrets, and the full bytes are needed
// to match a presented key exactly), the SHA256 fingerprint (the lookup key),
// the owning org, and the registering user id. No private material ever touches
// this store; there is nothing here to hash — a fingerprint is already a hash.
var (
// errKeyConflict is returned when a fingerprint is already registered (to
// this or another org). Handlers map it to 409.
errKeyConflict = errors.New("git: ssh key already registered")
// errKeyNotFound is returned when a delete misses. Handlers map it to 404.
errKeyNotFound = errors.New("git: ssh key not found")
)
// sshKey is one registered public key row.
type sshKey struct {
ID string
Org string
UserID string
Title string
PublicKey string // full authorized-key line, e.g. "ssh-ed25519 AAAA... comment"
Fingerprint string // SHA256:... (RFC 4253 form from ssh.FingerprintSHA256)
CreatedAt int64
}
// keyStore is the global SSH-key registry DB. MaxOpenConns(1) serializes writes
// against the file lock, matching the repo store's discipline.
type keyStore struct {
db *sql.DB
}
// openKeyStore opens (creating if absent) the global SSH-key registry and runs
// its migration.
func openKeyStore(path string) (*keyStore, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open ssh key db: %w", err)
}
db.SetMaxOpenConns(1) // serialize writes against the file lock (same discipline as TenantDB)
for _, pragma := range []string{
"PRAGMA busy_timeout=5000",
"PRAGMA journal_mode=WAL",
"PRAGMA foreign_keys=ON",
} {
if _, err := db.Exec(pragma); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ssh key db pragma %q: %w", pragma, err)
}
}
ks := &keyStore{db: db}
if err := ks.migrate(); err != nil {
_ = db.Close()
return nil, err
}
return ks, nil
}
func (k *keyStore) migrate() error {
const ddl = `
CREATE TABLE IF NOT EXISTS ssh_keys (
id TEXT PRIMARY KEY,
org TEXT NOT NULL,
user_id TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
public_key TEXT NOT NULL,
fingerprint TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_ssh_keys_org ON ssh_keys(org, created_at);
`
if _, err := k.db.Exec(ddl); err != nil {
return fmt.Errorf("migrate ssh keys: %w", err)
}
return nil
}
// Close closes the underlying database.
func (k *keyStore) Close() error { return k.db.Close() }
// Add inserts a new key row. Returns errKeyConflict when the fingerprint is
// already registered (UNIQUE on fingerprint — a key belongs to exactly one org).
func (k *keyStore) Add(ctx context.Context, key sshKey) error {
_, err := k.db.ExecContext(ctx,
`INSERT INTO ssh_keys (id,org,user_id,title,public_key,fingerprint,created_at) VALUES (?,?,?,?,?,?,?)`,
key.ID, key.Org, key.UserID, key.Title, key.PublicKey, key.Fingerprint, key.CreatedAt)
if err != nil {
if isUnique(err) {
return errKeyConflict
}
return fmt.Errorf("insert ssh key: %w", err)
}
return nil
}
// ByFingerprint resolves a presented key's fingerprint to its row — the auth
// lookup the SSH PublicKeyCallback runs. errKeyNotFound (fail CLOSED) when the
// key was never registered.
func (k *keyStore) ByFingerprint(ctx context.Context, fp string) (sshKey, error) {
row := k.db.QueryRowContext(ctx,
`SELECT id,org,user_id,title,public_key,fingerprint,created_at FROM ssh_keys WHERE fingerprint=?`, fp)
return scanKey(row)
}
// List returns an org's registered keys, most-recent first. Filtered by org so a
// tenant never sees another's keys.
func (k *keyStore) List(ctx context.Context, org string) ([]sshKey, error) {
rows, err := k.db.QueryContext(ctx,
`SELECT id,org,user_id,title,public_key,fingerprint,created_at FROM ssh_keys WHERE org=? ORDER BY created_at DESC, id ASC`, org)
if err != nil {
return nil, fmt.Errorf("list ssh keys: %w", err)
}
defer func() { _ = rows.Close() }()
var out []sshKey
for rows.Next() {
key, err := scanKey(rows)
if err != nil {
return nil, fmt.Errorf("scan ssh key: %w", err)
}
out = append(out, key)
}
return out, rows.Err()
}
// Delete removes an org's key by id. Scoped by org so a tenant can only delete
// its OWN keys (an id from another org matches no row). Returns errKeyNotFound
// when nothing went.
func (k *keyStore) Delete(ctx context.Context, org, id string) error {
res, err := k.db.ExecContext(ctx, `DELETE FROM ssh_keys WHERE org=? AND id=?`, org, id)
if err != nil {
return fmt.Errorf("delete ssh key: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return errKeyNotFound
}
return nil
}
func scanKey(sc interface{ Scan(...any) error }) (sshKey, error) {
var key sshKey
err := sc.Scan(&key.ID, &key.Org, &key.UserID, &key.Title, &key.PublicKey, &key.Fingerprint, &key.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return sshKey{}, errKeyNotFound
}
if err != nil {
return sshKey{}, fmt.Errorf("scan ssh key: %w", err)
}
return key, nil
}
// keyView is the API shape for a registered key. The full public key is echoed
// (it is public); the fingerprint is the stable handle a UI shows.
type keyView struct {
ID string `json:"id"`
Title string `json:"title"`
PublicKey string `json:"publicKey"`
Fingerprint string `json:"fingerprint"`
CreatedAt string `json:"createdAt"`
}
func (key sshKey) view() keyView {
return keyView{
ID: key.ID, Title: key.Title, PublicKey: key.PublicKey,
Fingerprint: key.Fingerprint, CreatedAt: time.Unix(key.CreatedAt, 0).UTC().Format(time.RFC3339),
}
}
+230
View File
@@ -0,0 +1,230 @@
package git
import (
"context"
"errors"
"fmt"
"io"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/protocol/packp"
"github.com/go-git/go-git/v5/plumbing/transport"
)
// errBadPack marks a client-side protocol error (a malformed upload-pack or
// receive-pack request body) so a transport adapter can answer 400 rather than
// 500. Wrapped by serveUploadPack / serveReceivePack around the decode step.
var errBadPack = errors.New("git: malformed pack request")
// readerOnly hides an io.Reader's Closer (if any) from go-git's pack decoders.
// The SSH channel is an io.ReadWriteCloser; go-git captures the decode reader as
// the packfile reader and Closes it after unpacking — which on a raw channel
// would close the WRITE side too. Wrapping the read side in a plain io.Reader
// means that Close becomes a no-op (io.NopCloser), leaving the channel writable
// for the response/report.
type readerOnly struct{ r io.Reader }
func (ro readerOnly) Read(p []byte) (int, error) { return ro.r.Read(p) }
// pack.go is the ONE Git pack code path. Both transports — smart-HTTP
// (clients/git/smart_http.go) and SSH (clients/git/ssh.go) — drive clone/fetch
// and push through the SAME two funcs here, so there is exactly one place that
// decodes a request, runs the go-git server session, records usage, and fires
// the push-to-deploy hook. The transports differ only in framing: HTTP carries
// the request/response as a body/response byte stream; SSH carries it as a
// channel's stdin/stdout. Both are io.Reader (in) + io.Writer (out), so the
// pack logic never knows which transport called it (DRY — Rich Hickey's
// "one fact, one place").
//
// The org/project/name are already resolved + tenant-checked by the caller
// (the HTTP handler from X-Org-Id, the SSH session from the presented key's
// bound org), so these funcs are transport-agnostic and take plain strings —
// never a *zip.Ctx or an *ssh.Session.
// serveUploadPack runs the upload-pack (clone/fetch) exchange for one repo,
// reading the client's wants/haves from in and writing the packfile result to
// out. The caller has verified the repo exists and the tenant owns it.
func (s *svc) serveUploadPack(ctx context.Context, org, project, name string, in io.Reader, out io.Writer) error {
req := packp.NewUploadPackRequest()
if err := req.Decode(in); err != nil {
return fmt.Errorf("%w: decode upload-pack request: %v", errBadPack, err)
}
sess, err := s.uploadSession(org, project, name)
if err != nil {
return fmt.Errorf("git session: %w", err)
}
defer func() { _ = sess.Close() }()
resp, err := sess.UploadPack(ctx, req)
if err != nil {
return fmt.Errorf("upload-pack: %w", err)
}
defer func() { _ = resp.Close() }()
if err := resp.Encode(out); err != nil {
return fmt.Errorf("encode upload-pack: %w", err)
}
return nil
}
// serveReceivePack runs the receive-pack (push) exchange for one repo, reading
// the ref-update commands + packfile from in and writing the report-status to
// out. On success it re-measures storage (billing) and fires the push-to-deploy
// build hook — the SAME side effects a smart-HTTP push produces — so a push over
// SSH and a push over HTTPS are indistinguishable downstream.
//
// The caller has verified the repo exists and the tenant owns it. usage +
// build-trigger are best-effort by contract: the push has already landed, so a
// metering miss or trigger failure is never surfaced to the client.
func (s *svc) serveReceivePack(ctx context.Context, org, project, name string, in io.Reader, out io.Writer) error {
req := packp.NewReferenceUpdateRequest()
if err := req.Decode(in); err != nil {
return fmt.Errorf("%w: decode receive-pack request: %v", errBadPack, err)
}
sess, err := s.receiveSession(org, project, name)
if err != nil {
return fmt.Errorf("git session: %w", err)
}
defer func() { _ = sess.Close() }()
report, err := sess.ReceivePack(ctx, req)
if report == nil && err != nil {
return fmt.Errorf("receive-pack: %w", err)
}
// Re-measure + fire builds on a cancel-immune context: the client's transport
// may close the instant the report is flushed, but the push already committed,
// so the metering + build-trigger must still run to completion.
bg := context.WithoutCancel(ctx)
s.recordUsage(bg, org, project, name)
s.firePushBuilds(bg, org, project, name, req)
if report != nil {
if err := report.Encode(out); err != nil {
return fmt.Errorf("encode report: %w", err)
}
}
return nil
}
// sshUploadPack drives the FULL native-git-protocol upload-pack (clone/fetch)
// exchange over an SSH channel: it advertises refs first (native protocol has no
// separate info/refs request the way smart-HTTP does), THEN reads the client's
// wants/haves and streams the packfile — all on ONE go-git session, over the
// channel's stdin (in) / stdout (out). This is the SSH framing wrapper around the
// SAME go-git upload-pack session the smart-HTTP path uses.
func (s *svc) sshUploadPack(ctx context.Context, org, project, name string, in io.Reader, out io.Writer) error {
sess, err := s.uploadSession(org, project, name)
if err != nil {
return fmt.Errorf("git session: %w", err)
}
defer func() { _ = sess.Close() }()
// 1) Advertise refs (native protocol — no "# service=" prefix). An empty repo
// still advertises capabilities + a flush so the client can proceed / stop.
ar, err := sess.AdvertisedReferencesContext(ctx)
if err != nil {
if errors.Is(err, transport.ErrEmptyRemoteRepository) {
// Empty repo: send a bare capabilities advertisement + flush so the
// client cleanly observes "nothing to fetch" instead of hanging.
ar = packp.NewAdvRefs()
} else {
return fmt.Errorf("advertise refs: %w", err)
}
}
if err := ar.Encode(out); err != nil {
return fmt.Errorf("encode refs: %w", err)
}
// 2) Read the client's wants/haves. An empty request (client had everything /
// nothing to want) is a clean end, not an error. Read-only view so a decoder
// Close cannot tear down the channel's write side (see readerOnly).
req := packp.NewUploadPackRequest()
if err := req.Decode(readerOnly{in}); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return fmt.Errorf("%w: decode upload-pack request: %v", errBadPack, err)
}
if req.IsEmpty() {
return nil
}
resp, err := sess.UploadPack(ctx, req)
if err != nil {
if errors.Is(err, transport.ErrEmptyUploadPackRequest) {
return nil
}
return fmt.Errorf("upload-pack: %w", err)
}
defer func() { _ = resp.Close() }()
if err := resp.Encode(out); err != nil {
return fmt.Errorf("encode upload-pack: %w", err)
}
return nil
}
// sshReceivePack drives the FULL native-git-protocol receive-pack (push) exchange
// over an SSH channel: advertise refs first, THEN read the ref-update commands +
// packfile and apply them, then write the report-status — on ONE session, over
// the channel's stdin/stdout. It records usage + fires the build hook on success,
// the SAME side effects the smart-HTTP push produces.
func (s *svc) sshReceivePack(ctx context.Context, org, project, name string, in io.Reader, out io.Writer) error {
sess, err := s.receiveSession(org, project, name)
if err != nil {
return fmt.Errorf("git session: %w", err)
}
defer func() { _ = sess.Close() }()
ar, err := sess.AdvertisedReferencesContext(ctx)
if err != nil {
return fmt.Errorf("advertise refs: %w", err)
}
if err := ar.Encode(out); err != nil {
return fmt.Errorf("encode refs: %w", err)
}
// Decode from a READ-ONLY view of the channel. go-git's ReferenceUpdateRequest
// captures the reader AS the packfile reader and calls Close() on it after
// unpacking; if that reader were the ssh.Channel itself (an io.ReadWriteCloser),
// Close would tear down the WHOLE channel — including the write side we still
// need to send the report-status on. readerOnly hides the Closer so the pack
// unpack cannot close our write side.
req := packp.NewReferenceUpdateRequest()
if err := req.Decode(readerOnly{in}); err != nil {
if errors.Is(err, io.EOF) {
return nil // client disconnected without pushing — clean end
}
return fmt.Errorf("%w: decode receive-pack request: %v", errBadPack, err)
}
report, err := sess.ReceivePack(ctx, req)
if report == nil && err != nil {
return fmt.Errorf("receive-pack: %w", err)
}
bg := context.WithoutCancel(ctx)
s.recordUsage(bg, org, project, name)
s.firePushBuilds(bg, org, project, name, req)
if report != nil {
if err := report.Encode(out); err != nil {
return fmt.Errorf("encode report: %w", err)
}
}
return nil
}
// refExists reports whether refs/heads/<branch> resolves in the repo — used by
// the REST push path to decide create-vs-update semantics for the build trigger.
func (s *svc) refExists(org, project, name, branch string) (plumbing.Hash, bool) {
st, err := s.storage.storer(org, project, name)
if err != nil {
return plumbing.ZeroHash, false
}
ref, err := st.Reference(plumbing.NewBranchReferenceName(branch))
if err != nil {
return plumbing.ZeroHash, false
}
return ref.Hash(), true
}
+387
View File
@@ -0,0 +1,387 @@
package git
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"regexp"
"sort"
"strings"
"time"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/protocol/packp"
"github.com/go-git/go-git/v5/plumbing/storer"
"github.com/zap-proto/zip"
)
// push.go adds POST /v1/git/repos/:name/push — a client-less push. The hanzo.app
// builder (and any caller without a local git binary) posts a set of files and
// the server builds the tree + commit, updates the branch ref, and fires the
// EXACT same push-to-deploy hook a real receive-pack fires (smart_http.go
// firePushBuilds), so a client-less push is indistinguishable downstream from a
// `git push`.
//
// It is tenant-scoped identically to the other routes (principal.Tenant →
// X-Org-Id). It composes the SAME provision() a create does when the repo is
// absent, so there is one way a repo comes into being.
type pushFile struct {
Path string `json:"path"`
Content string `json:"content"`
Encoding string `json:"encoding"` // "" | "utf-8" | "base64"
}
type pushReq struct {
Branch string `json:"branch"`
Message string `json:"message"`
Files []pushFile `json:"files"`
}
type pushResp struct {
Commit string `json:"commit"`
Branch string `json:"branch"`
CloneURL string `json:"cloneUrl"`
SSHURL string `json:"sshUrl"`
}
// maxPushFiles / maxPushFileBytes bound a single client-less push so a hostile
// body cannot exhaust memory. A real monorepo push uses git-receive-pack (chunked
// negotiation) — this endpoint is for the builder's generated-file set.
const (
maxPushFiles = 5000
maxPushFileBytes = 32 << 20 // 32 MiB per file
)
// pushFiles handles POST /v1/git/repos/:name/push.
func (s *svc) pushFiles(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
name := normalizeName(c.Param("name"))
if name == "" || !nameRE.MatchString(name) {
return zip.ErrBadRequest("invalid repo name")
}
project := projectScope(c)
var body pushReq
if err := c.Bind(&body); err != nil {
return err
}
commit, branch, err := s.corePush(c.Context(), org, project, name, body)
switch {
case errors.Is(err, errBadInput):
return zip.ErrBadRequest(strings.TrimPrefix(err.Error(), "git: invalid input: "))
case err != nil:
return zip.Errorf(http.StatusInternalServerError, "%v", err)
}
return c.JSON(http.StatusOK, pushResp{
Commit: commit, Branch: branch,
CloneURL: s.cloneURL(org, name), SSHURL: s.sshURL(org, name),
})
}
// corePush is the transport-agnostic client-less push: it ensures the repo
// exists, materializes the files into a tree merged onto the branch tip, writes
// a commit, advances refs/heads/<branch> (and HEAD on a fresh branch), records
// usage, and fires the build hook. Returns the new commit hash + resolved branch.
func (s *svc) corePush(ctx context.Context, org, project, name string, in pushReq) (commitHash, branch string, err error) {
branch = strings.TrimSpace(in.Branch)
if branch == "" {
branch = defaultBranchName
}
if !branchRE.MatchString(branch) {
return "", "", badInput("branch must match ^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")
}
if len(in.Files) == 0 {
return "", "", badInput("files is required (at least one)")
}
if len(in.Files) > maxPushFiles {
return "", "", badInput("too many files (max %d)", maxPushFiles)
}
// Decode + validate files first, so a bad file fails BEFORE we touch storage.
blobs, err := decodePushFiles(in.Files)
if err != nil {
return "", "", err
}
// Ensure the repo exists (create on first push, composing the ONE provision).
store, err := s.storeFor(org)
if err != nil {
return "", "", fmt.Errorf("open store: %w", err)
}
if _, gerr := store.Get(ctx, org, project, name); errors.Is(gerr, errNotFound) {
id, ierr := genID("repo")
if ierr != nil {
return "", "", fmt.Errorf("rng: %w", ierr)
}
now := time.Now().Unix()
r := Repo{ID: id, Org: org, Project: project, Name: name, DefaultBranch: branch, CreatedAt: now, UpdatedAt: now}
if perr := s.provision(ctx, store, r); perr != nil && !errors.Is(perr, errConflict) {
return "", "", fmt.Errorf("provision: %w", perr)
}
} else if gerr != nil {
return "", "", fmt.Errorf("get: %w", gerr)
}
st, err := s.storage.storer(org, project, name)
if err != nil {
return "", "", fmt.Errorf("open storer: %w", err)
}
// Resolve the current branch tip (parent commit + base tree), if any.
branchRef := plumbing.NewBranchReferenceName(branch)
var parents []plumbing.Hash
var baseTree *object.Tree
if ref, rerr := st.Reference(branchRef); rerr == nil && ref.Hash() != plumbing.ZeroHash {
parents = append(parents, ref.Hash())
if pc, cerr := object.GetCommit(st, ref.Hash()); cerr == nil {
if bt, terr := pc.Tree(); terr == nil {
baseTree = bt
}
}
}
// Build the new root tree = base tree with the pushed files added/overwritten,
// then write the commit.
rootHash, err := writeTreeWithFiles(st, baseTree, blobs)
if err != nil {
return "", "", fmt.Errorf("build tree: %w", err)
}
msg := strings.TrimSpace(in.Message)
if msg == "" {
msg = "push via /v1/git/repos/" + name + "/push"
}
now := time.Now()
commit := &object.Commit{
Author: object.Signature{Name: "hanzo-cloud", Email: "git@hanzo.ai", When: now},
Committer: object.Signature{Name: "hanzo-cloud", Email: "git@hanzo.ai", When: now},
Message: msg,
TreeHash: rootHash,
ParentHashes: parents,
}
commitObj := st.NewEncodedObject()
if err := commit.Encode(commitObj); err != nil {
return "", "", fmt.Errorf("encode commit: %w", err)
}
ch, err := st.SetEncodedObject(commitObj)
if err != nil {
return "", "", fmt.Errorf("store commit: %w", err)
}
// Advance the branch ref. On a brand-new branch that is the repo default,
// point HEAD at it too (matching `git push` making the first branch the head).
newRef := plumbing.NewHashReference(branchRef, ch)
if err := st.SetReference(newRef); err != nil {
return "", "", fmt.Errorf("update ref: %w", err)
}
if _, herr := st.Reference(plumbing.HEAD); herr != nil {
_ = st.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, branchRef))
}
// Same side effects as a receive-pack push: meter storage + fire the build.
bg := context.WithoutCancel(ctx)
s.recordUsage(bg, org, project, name)
s.firePushBuilds(bg, org, project, name, syntheticUpdate(branchRef, parents, ch))
return ch.String(), branch, nil
}
// blob is a decoded file ready to be written: its repo-relative path + content.
type blob struct {
path string
content []byte
}
// decodePushFiles validates + decodes the request files (utf-8 default, or
// base64 when Encoding=="base64"), rejecting empty/absolute/traversing paths.
func decodePushFiles(files []pushFile) ([]blob, error) {
out := make([]blob, 0, len(files))
seen := make(map[string]struct{}, len(files))
for i, f := range files {
p := cleanRepoPath(f.Path)
if p == "" {
return nil, badInput("files[%d].path is invalid (empty, absolute, or traversing)", i)
}
if _, dup := seen[p]; dup {
return nil, badInput("files[%d].path %q is duplicated", i, p)
}
seen[p] = struct{}{}
var content []byte
switch strings.ToLower(strings.TrimSpace(f.Encoding)) {
case "", "utf-8", "utf8", "text":
content = []byte(f.Content)
case "base64":
b, err := base64.StdEncoding.DecodeString(f.Content)
if err != nil {
return nil, badInput("files[%d].content is not valid base64", i)
}
content = b
default:
return nil, badInput("files[%d].encoding must be utf-8 or base64", i)
}
if len(content) > maxPushFileBytes {
return nil, badInput("files[%d] exceeds %d bytes", i, maxPushFileBytes)
}
out = append(out, blob{path: p, content: content})
}
return out, nil
}
// cleanRepoPath normalizes a file path to a safe repo-relative slash path, or ""
// if it is empty, absolute, or escapes the root. This is the traversal guard on
// the client-less push boundary.
func cleanRepoPath(p string) string {
p = strings.TrimSpace(p)
p = strings.TrimPrefix(p, "./")
if p == "" || strings.HasPrefix(p, "/") {
return ""
}
// Reject any traversal or empty segments.
for _, seg := range strings.Split(p, "/") {
if seg == "" || seg == "." || seg == ".." {
return ""
}
}
return p
}
// treeNode is a mutable tree used to accumulate blobs into a hierarchy before
// serializing bottom-up. subDirs keeps child directories; files keeps leaf blob
// hashes.
type treeNode struct {
subDirs map[string]*treeNode
files map[string]plumbing.Hash
}
func newTreeNode() *treeNode {
return &treeNode{subDirs: map[string]*treeNode{}, files: map[string]plumbing.Hash{}}
}
// writeTreeWithFiles builds the new root tree: it seeds from baseTree (so a push
// adds/overwrites onto the existing content), writes each file's blob, layers the
// files into a directory hierarchy, then serializes the trees bottom-up and
// returns the root tree hash.
func writeTreeWithFiles(st storer.EncodedObjectStorer, baseTree *object.Tree, blobs []blob) (plumbing.Hash, error) {
root := newTreeNode()
// Seed from the base tree so unchanged files survive the push.
if baseTree != nil {
if err := seedFromTree(st, root, baseTree, ""); err != nil {
return plumbing.ZeroHash, err
}
}
// Write each pushed blob and place it in the hierarchy (overwriting).
for _, b := range blobs {
h, err := writeBlob(st, b.content)
if err != nil {
return plumbing.ZeroHash, err
}
placeFile(root, strings.Split(b.path, "/"), h)
}
return writeTreeNode(st, root)
}
// seedFromTree copies the base tree's entries into the mutable node hierarchy so
// a push merges onto existing content rather than replacing it.
func seedFromTree(st storer.EncodedObjectStorer, node *treeNode, t *object.Tree, prefix string) error {
for _, e := range t.Entries {
if e.Mode == filemode.Dir {
sub, err := object.GetTree(st, e.Hash)
if err != nil {
return err
}
child := newTreeNode()
node.subDirs[e.Name] = child
if err := seedFromTree(st, child, sub, prefix+e.Name+"/"); err != nil {
return err
}
continue
}
node.files[e.Name] = e.Hash
}
return nil
}
// placeFile inserts a file hash at the given path segments, creating intermediate
// directories. A file overwrites any prior file of the same name.
func placeFile(node *treeNode, segs []string, h plumbing.Hash) {
if len(segs) == 1 {
delete(node.subDirs, segs[0]) // a file replaces a dir of the same name
node.files[segs[0]] = h
return
}
dir := segs[0]
child, ok := node.subDirs[dir]
if !ok {
child = newTreeNode()
node.subDirs[dir] = child
}
delete(node.files, dir) // a dir replaces a file of the same name
placeFile(child, segs[1:], h)
}
// writeBlob stores a blob object and returns its hash.
func writeBlob(st storer.EncodedObjectStorer, content []byte) (plumbing.Hash, error) {
obj := st.NewEncodedObject()
obj.SetType(plumbing.BlobObject)
w, err := obj.Writer()
if err != nil {
return plumbing.ZeroHash, err
}
if _, err := w.Write(content); err != nil {
_ = w.Close()
return plumbing.ZeroHash, err
}
if err := w.Close(); err != nil {
return plumbing.ZeroHash, err
}
return st.SetEncodedObject(obj)
}
// writeTreeNode serializes a mutable tree node bottom-up (children first) into a
// git tree object and returns its hash. Entries are sorted per the git tree
// ordering contract (object.TreeEntrySorter).
func writeTreeNode(st storer.EncodedObjectStorer, node *treeNode) (plumbing.Hash, error) {
tree := &object.Tree{}
for name, h := range node.files {
tree.Entries = append(tree.Entries, object.TreeEntry{Name: name, Mode: filemode.Regular, Hash: h})
}
for name, child := range node.subDirs {
ch, err := writeTreeNode(st, child)
if err != nil {
return plumbing.ZeroHash, err
}
tree.Entries = append(tree.Entries, object.TreeEntry{Name: name, Mode: filemode.Dir, Hash: ch})
}
sort.Sort(object.TreeEntrySorter(tree.Entries))
obj := st.NewEncodedObject()
if err := tree.Encode(obj); err != nil {
return plumbing.ZeroHash, err
}
return st.SetEncodedObject(obj)
}
// syntheticUpdate builds a ReferenceUpdateRequest with the one command this push
// performed, so firePushBuilds sees the SAME shape a real receive-pack produces
// (old→new on the branch ref) and triggers exactly as it would for `git push`.
func syntheticUpdate(ref plumbing.ReferenceName, parents []plumbing.Hash, newHash plumbing.Hash) *packp.ReferenceUpdateRequest {
old := plumbing.ZeroHash
if len(parents) > 0 {
old = parents[0]
}
req := packp.NewReferenceUpdateRequest()
req.Commands = []*packp.Command{{Name: ref, Old: old, New: newHash}}
return req
}
// branchRE constrains a push branch name to a safe ref — nested (feature/foo)
// allowed, so it mirrors nameRE plus "/". The ref-update path uses it verbatim,
// so this is the traversal/injection guard on the branch name.
var branchRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
+211
View File
@@ -0,0 +1,211 @@
package git
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"sync"
"testing"
"github.com/go-git/go-billy/v5/memfs"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/hanzoai/cloud"
)
// TestRESTPushCreatesCommit proves the client-less push: POST files, a commit is
// built + the ref advanced, a second push updates the ref (with the first as
// parent), and the build hook fires on each push. Then a real git clone sees the
// pushed content — proving corePush wrote a valid commit/tree/blob graph.
func TestRESTPushCreatesCommit(t *testing.T) {
var mu sync.Mutex
var events []cloud.GitPushEvent
cloud.RegisterPushBuilder(func(_ context.Context, ev cloud.GitPushEvent) error {
mu.Lock()
events = append(events, ev)
mu.Unlock()
return nil
})
t.Cleanup(func() { cloud.RegisterPushBuilder(nil) })
app := mountApp(t)
base := liveServer(t, app)
if code, b := do(t, app, http.MethodPost, "/v1/git/repos", "acme",
map[string]any{"name": "app"}); code != http.StatusCreated {
t.Fatalf("create repo: %d %s", code, b)
}
// First push: two files, one nested, one base64.
code, body := do(t, app, http.MethodPost, "/v1/git/repos/app/push", "acme", map[string]any{
"branch": "main",
"message": "initial",
"files": []map[string]any{
{"path": "README.md", "content": "# hello\n"},
{"path": "src/app.js", "content": "console.log(1)\n"},
{"path": "logo.bin", "content": base64.StdEncoding.EncodeToString([]byte{0, 1, 2, 3}), "encoding": "base64"},
},
})
if code != http.StatusOK {
t.Fatalf("push want 200, got %d (%s)", code, body)
}
var r1 pushResp
if err := json.Unmarshal(body, &r1); err != nil {
t.Fatalf("push resp json: %v (%s)", err, body)
}
if r1.Commit == "" || r1.Branch != "main" {
t.Fatalf("unexpected push resp: %+v", r1)
}
if r1.CloneURL != "https://api.hanzo.test/v1/git/acme/app.git" {
t.Fatalf("unexpected cloneUrl: %q", r1.CloneURL)
}
if r1.SSHURL != "git@git.hanzo.test:acme/app.git" {
t.Fatalf("unexpected sshUrl: %q", r1.SSHURL)
}
// Second push: updates one file — the ref advances, first commit is the parent.
code, body = do(t, app, http.MethodPost, "/v1/git/repos/app/push", "acme", map[string]any{
"branch": "main",
"message": "update",
"files": []map[string]any{{"path": "README.md", "content": "# hello v2\n"}},
})
if code != http.StatusOK {
t.Fatalf("second push want 200, got %d (%s)", code, body)
}
var r2 pushResp
_ = json.Unmarshal(body, &r2)
if r2.Commit == r1.Commit {
t.Fatalf("second push must produce a new commit, got same %s", r2.Commit)
}
// Both pushes fired the build hook, with the right branch + tip commit.
mu.Lock()
defer mu.Unlock()
if len(events) != 2 {
t.Fatalf("want 2 build events, got %d: %+v", len(events), events)
}
if events[0].Repo != "app" || events[0].Branch != "main" || events[0].Commit != r1.Commit {
t.Fatalf("event[0] = %+v (want commit %s)", events[0], r1.Commit)
}
if events[1].Commit != r2.Commit {
t.Fatalf("event[1].Commit = %s, want %s", events[1].Commit, r2.Commit)
}
// A real clone sees the merged content: updated README + the untouched nested
// file + the base64 file survive the second push (merge, not replace).
cloneURL := base + "/v1/git/acme/app.git"
asTenant("acme")
cloned, err := gogit.Clone(memory.NewStorage(), memfs.New(), &gogit.CloneOptions{URL: cloneURL})
if err != nil {
t.Fatalf("clone: %v", err)
}
head, err := cloned.Head()
if err != nil {
t.Fatalf("head: %v", err)
}
if head.Hash().String() != r2.Commit {
t.Fatalf("cloned HEAD %s != last push %s", head.Hash(), r2.Commit)
}
commit, err := cloned.CommitObject(head.Hash())
if err != nil {
t.Fatalf("commit obj: %v", err)
}
tree, err := commit.Tree()
if err != nil {
t.Fatalf("tree: %v", err)
}
// README updated.
readme, err := tree.File("README.md")
if err != nil {
t.Fatalf("README.md missing after 2nd push: %v", err)
}
rc, _ := readme.Contents()
if rc != "# hello v2\n" {
t.Fatalf("README content = %q, want updated", rc)
}
// Nested file from the FIRST push survived (merge onto base tree).
if _, err := tree.File("src/app.js"); err != nil {
t.Fatalf("src/app.js should survive the second push (merge): %v", err)
}
// Base64 file decoded to raw bytes.
logo, err := tree.File("logo.bin")
if err != nil {
t.Fatalf("logo.bin missing: %v", err)
}
lc, _ := logo.Contents()
if lc != string([]byte{0, 1, 2, 3}) {
t.Fatalf("logo.bin content = %q, want raw bytes", lc)
}
}
// TestRESTPushCreatesRepoOnFirstPush proves a push to a not-yet-existing repo
// provisions it (composing the ONE provision path), and the repo is then listed.
func TestRESTPushCreatesRepoOnFirstPush(t *testing.T) {
app := mountApp(t)
code, body := do(t, app, http.MethodPost, "/v1/git/repos/fresh/push", "acme", map[string]any{
"files": []map[string]any{{"path": "main.go", "content": "package main\n"}},
})
if code != http.StatusOK {
t.Fatalf("push to fresh repo want 200, got %d (%s)", code, body)
}
// The repo now exists in the tenant listing.
code, body = do(t, app, http.MethodGet, "/v1/git/repos", "acme", nil)
var listed struct {
Data []repoView `json:"data"`
}
_ = json.Unmarshal(body, &listed)
if code != http.StatusOK || len(listed.Data) != 1 || listed.Data[0].Name != "fresh" {
t.Fatalf("fresh repo not created by push: %d %+v", code, listed.Data)
}
}
// TestRESTPushValidation covers the input guards: empty files, bad branch,
// traversing path, and bad base64.
func TestRESTPushValidation(t *testing.T) {
app := mountApp(t)
if code, _ := do(t, app, http.MethodPost, "/v1/git/repos", "acme",
map[string]any{"name": "v"}); code != http.StatusCreated {
t.Fatal("setup create failed")
}
cases := []map[string]any{
{"files": []map[string]any{}}, // empty files
{"branch": "bad branch", "files": []map[string]any{{"path": "a", "content": "x"}}}, // bad branch
{"files": []map[string]any{{"path": "../escape", "content": "x"}}}, // traversal
{"files": []map[string]any{{"path": "a", "content": "!!!", "encoding": "base64"}}}, // bad base64
}
for i, c := range cases {
if code, body := do(t, app, http.MethodPost, "/v1/git/repos/v/push", "acme", c); code != http.StatusBadRequest {
t.Fatalf("case %d want 400, got %d (%s)", i, code, body)
}
}
}
// TestRESTPushCrossTenantIsolation proves a push is org-scoped: beta pushing to a
// repo name acme owns creates beta's OWN repo, never touching acme's.
func TestRESTPushCrossTenantIsolation(t *testing.T) {
app := mountApp(t)
// acme owns "shared".
if code, _ := do(t, app, http.MethodPost, "/v1/git/repos", "acme",
map[string]any{"name": "shared"}); code != http.StatusCreated {
t.Fatal("acme create failed")
}
// beta pushes to "shared" — provisions beta's own, does not reach acme's.
if code, b := do(t, app, http.MethodPost, "/v1/git/repos/shared/push", "beta", map[string]any{
"files": []map[string]any{{"path": "b.txt", "content": "beta\n"}},
}); code != http.StatusOK {
t.Fatalf("beta push want 200, got %d %s", code, b)
}
// acme's repo is still empty (no HEAD) — beta never wrote into it.
branches, head := mounted.refState("acme", "", "shared")
if head != "" || len(branches) != 0 {
t.Fatalf("acme repo must be untouched, got head=%q branches=%v", head, branches)
}
// beta's repo has the commit.
_, betaHead := mounted.refState("beta", "", "shared")
if betaHead == "" {
t.Fatalf("beta repo should have a HEAD after its push")
}
}
+47 -89
View File
@@ -3,6 +3,7 @@ package git
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"strings"
@@ -92,126 +93,83 @@ func (s *svc) infoRefs(c *zip.Ctx) error {
return c.Bytes(http.StatusOK, buf.Bytes())
}
// uploadPack serves POST /git-upload-pack — the clone/fetch phase. It decodes
// the client's wants/haves, runs the upload-pack session, and streams the
// packfile response.
// uploadPack serves POST /git-upload-pack — the clone/fetch phase. It is a thin
// HTTP adapter over the shared pack driver (pack.go serveUploadPack): resolve +
// tenant-check, then hand the request body + response writer to the ONE pack
// code path SSH also uses.
func (s *svc) uploadPack(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
name, err := repoNameParam(c)
org, project, name, err := s.resolvePackRepo(c)
if err != nil {
return err
}
project := projectScope(c)
if p := c.Param("org"); p != "" && p != org {
return zip.ErrForbidden("org path does not match authenticated tenant")
}
store, err := s.storeFor(org)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
}
if _, err := store.Get(c.Context(), org, project, name); err != nil {
return zip.ErrNotFound("repo not found")
}
body := c.Body()
if int64(len(body)) > maxBody {
return zip.Errorf(http.StatusRequestEntityTooLarge, "request body exceeds %d bytes", maxBody)
}
req := packp.NewUploadPackRequest()
if err := req.Decode(bytes.NewReader(body)); err != nil {
return zip.ErrBadRequest("decode upload-pack request: " + err.Error())
}
sess, err := s.uploadSession(org, project, name)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "git session: %v", err)
}
defer func() { _ = sess.Close() }()
resp, err := sess.UploadPack(c.Context(), req)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "upload-pack: %v", err)
}
defer func() { _ = resp.Close() }()
var buf bytes.Buffer
if err := resp.Encode(&buf); err != nil {
return zip.Errorf(http.StatusInternalServerError, "encode upload-pack: %v", err)
if err := s.serveUploadPack(c.Context(), org, project, name, bytes.NewReader(body), &buf); err != nil {
if errors.Is(err, errBadPack) {
return zip.ErrBadRequest(err.Error())
}
return zip.Errorf(http.StatusInternalServerError, "%v", err)
}
c.SetHeader("Content-Type", "application/x-git-upload-pack-result")
c.SetHeader("Cache-Control", "no-cache")
return c.Bytes(http.StatusOK, buf.Bytes())
}
// receivePack serves POST /git-receive-pack — the push phase. It decodes the
// ref-update commands + packfile, applies them to the storer, records the new
// storage size (billing hook), and returns the report-status.
// receivePack serves POST /git-receive-pack — the push phase. Thin HTTP adapter
// over the shared pack driver (pack.go serveReceivePack), which applies the
// pack, records usage, and fires the push-to-deploy hook. SSH pushes take the
// SAME serveReceivePack path.
func (s *svc) receivePack(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
name, err := repoNameParam(c)
org, project, name, err := s.resolvePackRepo(c)
if err != nil {
return err
}
project := projectScope(c)
if p := c.Param("org"); p != "" && p != org {
return zip.ErrForbidden("org path does not match authenticated tenant")
}
store, err := s.storeFor(org)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
}
if _, err := store.Get(c.Context(), org, project, name); err != nil {
return zip.ErrNotFound("repo not found")
}
body := c.Body()
if int64(len(body)) > maxBody {
return zip.Errorf(http.StatusRequestEntityTooLarge, "request body exceeds %d bytes", maxBody)
}
req := packp.NewReferenceUpdateRequest()
if err := req.Decode(bytes.NewReader(body)); err != nil {
return zip.ErrBadRequest("decode receive-pack request: " + err.Error())
}
sess, err := s.receiveSession(org, project, name)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "git session: %v", err)
}
defer func() { _ = sess.Close() }()
report, err := sess.ReceivePack(c.Context(), req)
if report == nil && err != nil {
return zip.Errorf(http.StatusInternalServerError, "receive-pack: %v", err)
}
// Push landed (or partially landed with a report): re-measure and record the
// tenant's storage size so commerce/o11y meter the new bytes. Best-effort —
// a metering miss must never fail the push the client already committed.
s.recordUsage(context.WithoutCancel(c.Context()), org, project, name)
// git-push-to-deploy: trigger a build for every branch ref this push advanced.
// Best-effort by contract — the push already landed, so a build-trigger failure
// is logged, never surfaced to the client (build.go OnGitPush is a no-op when
// the platform subsystem is not co-resident).
s.firePushBuilds(context.WithoutCancel(c.Context()), org, project, name, req)
var buf bytes.Buffer
if report != nil {
if encErr := report.Encode(&buf); encErr != nil {
return zip.Errorf(http.StatusInternalServerError, "encode report: %v", encErr)
if err := s.serveReceivePack(c.Context(), org, project, name, bytes.NewReader(body), &buf); err != nil {
if errors.Is(err, errBadPack) {
return zip.ErrBadRequest(err.Error())
}
return zip.Errorf(http.StatusInternalServerError, "%v", err)
}
c.SetHeader("Content-Type", "application/x-git-receive-pack-result")
c.SetHeader("Cache-Control", "no-cache")
return c.Bytes(http.StatusOK, buf.Bytes())
}
// resolvePackRepo is the shared front-half of every smart-HTTP pack handler:
// resolve the tenant from X-Org-Id, validate the repo name, enforce the URL org
// segment matches the authenticated tenant (path-vs-identity guard), and confirm
// the repo exists. Returns the (org, project, name) the pack driver operates on.
func (s *svc) resolvePackRepo(c *zip.Ctx) (org, project, name string, err error) {
org, ok := tenant(c)
if !ok {
return "", "", "", zip.ErrForbidden("X-Org-Id required")
}
name, nerr := repoNameParam(c)
if nerr != nil {
return "", "", "", nerr
}
project = projectScope(c)
if p := c.Param("org"); p != "" && p != org {
return "", "", "", zip.ErrForbidden("org path does not match authenticated tenant")
}
store, serr := s.storeFor(org)
if serr != nil {
return "", "", "", zip.Errorf(http.StatusInternalServerError, "open store: %v", serr)
}
if _, gerr := store.Get(c.Context(), org, project, name); gerr != nil {
return "", "", "", zip.ErrNotFound("repo not found")
}
return org, project, name, nil
}
// firePushBuilds triggers a platform build for every branch ref this push
// advanced (created or updated). Tags and deletes are ignored. Best-effort and
// non-fatal: the push has already landed, so a trigger failure is logged, never
+399
View File
@@ -0,0 +1,399 @@
package git
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"github.com/hanzoai/cloud"
"golang.org/x/crypto/ssh"
)
// ssh.go serves the Git SSH transport so `git clone git@<sshHost>:<org>/<repo>.git`
// works alongside smart-HTTP. It uses golang.org/x/crypto/ssh (already a direct
// dependency — no new heavy dep) directly rather than a wrapper.
//
// Auth is per-user public key. The PublicKeyCallback fingerprints the presented
// key and resolves it, via the global key registry (keystore.go), to the org +
// user that registered it — failing CLOSED on any unknown key. The resolved
// identity is stashed in ssh.Permissions.Extensions and read back by the session
// handler, which sets the SAME (org) tenancy the HTTP path derives from
// X-Org-Id. So an SSH request and an HTTP request converge on the ONE pack code
// path (pack.go) with the ONE tenant scope.
//
// The session handler accepts exactly two exec commands —
// `git-upload-pack '<org>/<repo>.git'` (clone/fetch) and
// `git-receive-pack '<org>/<repo>.git'` (push) — parses the org/repo, enforces
// that the path org equals the key-bound org (no cross-tenant), confirms the
// repo exists, and drives the shared pack driver on the channel's stdin/stdout.
// sshConf carries the SSH server's runtime configuration, resolved from deps/env
// in sshConfig. Kept small: the listen address, the host-key source, and a
// back-reference to the git service the sessions operate on.
type sshConf struct {
addr string // listen address, e.g. ":2222"
hostKeyPEM []byte // KMS/env-provided host private key (PEM), or nil
hostKeyPath string // on-disk host key path; generated + persisted if absent
}
// sshServer owns the SSH listener and its lifecycle.
type sshServer struct {
svc *svc
cfg *ssh.ServerConfig
listen string
mu sync.Mutex
ln net.Listener
closed bool
wg sync.WaitGroup
boundTCP string // actual bound address (resolves :0 to the ephemeral port for tests)
}
// gitSSHHost resolves the SSH host advertised in sshUrl. CLOUD_GIT_SSH_HOST wins;
// else it is derived from the deployment domain (api.hanzo.ai → git.hanzo.ai);
// else "git.hanzo.ai".
func gitSSHHost(domain string) string {
if h := strings.TrimSpace(os.Getenv("CLOUD_GIT_SSH_HOST")); h != "" {
return h
}
return defaultSSHHost(domain)
}
// defaultSSHHost derives the git SSH host from the primary domain: the registered
// domain with a "git." prefix (api.hanzo.ai → git.hanzo.ai). Falls back to
// "git.hanzo.ai" when the domain is empty or unparseable.
func defaultSSHHost(domain string) string {
domain = strings.TrimSpace(domain)
if domain == "" {
return "git.hanzo.ai"
}
// Strip a leading "api." (the common cloud host) and prefix "git.".
base := strings.TrimPrefix(domain, "api.")
return "git." + base
}
// sshConfig resolves the SSH runtime config from deps/env. The host key is
// sourced KMS-first (CLOUD_GIT_SSH_HOST_KEY, a PEM the operator syncs from KMS),
// falling back to an on-disk key under the git data root that is generated +
// persisted 0600 on first boot. The listen address is CLOUD_GIT_SSH_ADDR
// (default :2222 — real :22 is fronted by a k8s TCP LoadBalancer).
func sshConfig(deps cloud.Deps, gitRoot string) sshConf {
addr := strings.TrimSpace(os.Getenv("CLOUD_GIT_SSH_ADDR"))
if addr == "" {
addr = ":2222"
}
var pemKey []byte
if p := strings.TrimSpace(os.Getenv("CLOUD_GIT_SSH_HOST_KEY")); p != "" {
pemKey = []byte(p)
}
return sshConf{
addr: addr,
hostKeyPEM: pemKey,
hostKeyPath: filepath.Join(gitRoot, "ssh_host_ed25519_key"),
}
}
// newSSHServer builds the SSH server: it resolves the host key, wires the
// public-key auth callback (fail-closed key→tenant resolution), and prepares the
// ServerConfig. It does NOT yet listen — call start.
func newSSHServer(s *svc, conf sshConf) (*sshServer, error) {
signer, err := loadOrCreateHostKey(conf)
if err != nil {
return nil, fmt.Errorf("host key: %w", err)
}
srv := &sshServer{svc: s, listen: conf.addr}
cfg := &ssh.ServerConfig{
// PublicKeyCallback is the ONLY accepted auth. It fingerprints the
// presented key and resolves it to a tenant; an unknown key returns an
// error, so auth fails CLOSED — there is no password / keyboard-interactive
// / none fallback.
PublicKeyCallback: srv.authPublicKey,
}
cfg.AddHostKey(signer)
srv.cfg = cfg
return srv, nil
}
// authPublicKey resolves a presented SSH key to its owning (org, user) via the
// global key registry, keyed by the SHA256 fingerprint. Unknown key → error →
// auth rejected (fail closed). On success the resolved identity rides in
// Permissions.Extensions so the session handler can scope the tenant WITHOUT a
// second lookup.
func (srv *sshServer) authPublicKey(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
fp := ssh.FingerprintSHA256(key)
row, err := srv.svc.keys.ByFingerprint(context.Background(), fp)
if err != nil {
// Unknown or errored key: reject. Never leak whether the fingerprint
// exists — a single opaque "unknown key" for every failure.
return nil, fmt.Errorf("ssh: unknown key")
}
return &ssh.Permissions{
Extensions: map[string]string{
"git-org": row.Org,
"git-user-id": row.UserID,
"git-key-id": row.ID,
},
}, nil
}
// start binds the listener and accepts connections in a background goroutine.
func (srv *sshServer) start() error {
ln, err := net.Listen("tcp", srv.listen)
if err != nil {
return fmt.Errorf("ssh listen %q: %w", srv.listen, err)
}
srv.mu.Lock()
srv.ln = ln
srv.boundTCP = ln.Addr().String()
srv.mu.Unlock()
srv.wg.Add(1)
go srv.acceptLoop(ln)
return nil
}
// addr returns the bound listen address (the ephemeral port resolved, for tests).
func (srv *sshServer) addr() string {
srv.mu.Lock()
defer srv.mu.Unlock()
if srv.boundTCP != "" {
return srv.boundTCP
}
return srv.listen
}
// stop closes the listener and waits for in-flight connections to drain.
// Idempotent.
func (srv *sshServer) stop() {
srv.mu.Lock()
if srv.closed {
srv.mu.Unlock()
return
}
srv.closed = true
ln := srv.ln
srv.mu.Unlock()
if ln != nil {
_ = ln.Close()
}
srv.wg.Wait()
}
func (srv *sshServer) acceptLoop(ln net.Listener) {
defer srv.wg.Done()
for {
conn, err := ln.Accept()
if err != nil {
srv.mu.Lock()
closed := srv.closed
srv.mu.Unlock()
if closed {
return // listener closed by stop() — clean shutdown
}
// Transient accept error; keep serving.
srv.svc.log.Warn("git ssh accept failed", "err", err)
continue
}
srv.wg.Add(1)
go func() {
defer srv.wg.Done()
srv.handleConn(conn)
}()
}
}
// handleConn performs the SSH handshake (auth via authPublicKey) and services
// the connection's session channels.
func (srv *sshServer) handleConn(nConn net.Conn) {
defer func() { _ = nConn.Close() }()
sconn, chans, reqs, err := ssh.NewServerConn(nConn, srv.cfg)
if err != nil {
// Handshake / auth failure — normal for probes + rejected keys.
return
}
defer func() { _ = sconn.Close() }()
go ssh.DiscardRequests(reqs) // reject global out-of-band requests
org := sconn.Permissions.Extensions["git-org"]
for newChan := range chans {
if newChan.ChannelType() != "session" {
_ = newChan.Reject(ssh.UnknownChannelType, "only session channels are supported")
continue
}
ch, chReqs, err := newChan.Accept()
if err != nil {
continue
}
go srv.handleSession(org, ch, chReqs)
}
}
// execPayload is the wire shape of an SSH "exec" request: a single
// length-prefixed command string (RFC 4254 §6.5).
type execPayload struct {
Command string
}
// handleSession services one session channel: it waits for the "exec" request
// carrying the git command, runs it, and closes the channel. Any other request
// type (shell, pty) is rejected — this is a git-only endpoint.
func (srv *sshServer) handleSession(org string, ch ssh.Channel, reqs <-chan *ssh.Request) {
defer func() { _ = ch.Close() }()
for req := range reqs {
switch req.Type {
case "exec":
var p execPayload
if err := ssh.Unmarshal(req.Payload, &p); err != nil {
_ = req.Reply(false, nil)
srv.exit(ch, 1)
return
}
_ = req.Reply(true, nil)
code := srv.runGitCommand(org, p.Command, ch)
srv.exit(ch, code)
return
case "shell", "pty-req", "env":
// A bare `ssh git@host` (shell) or env/pty setup: git-only endpoint,
// so reject shell/pty but ack env silently (git sets GIT_PROTOCOL).
_ = req.Reply(req.Type == "env", nil)
if req.Type == "shell" {
_, _ = io.WriteString(ch.Stderr(), "Hi! This is the Hanzo git SSH endpoint; interactive shells are not available.\n")
srv.exit(ch, 1)
return
}
default:
_ = req.Reply(false, nil)
}
}
}
// gitCmdRE parses `git-upload-pack '<path>'` / `git-receive-pack '<path>'`,
// accepting single-quoted or bare paths (git quotes; some clients don't).
var gitCmdRE = regexp.MustCompile(`^(git-upload-pack|git-receive-pack) '?([^']+?)'?$`)
// runGitCommand parses the exec command, enforces tenancy (path org == key org),
// confirms the repo exists, and drives the shared pack code path on the channel's
// stdin/stdout. Returns the exit code the session reports to the client.
func (srv *sshServer) runGitCommand(keyOrg, command string, ch ssh.Channel) int {
m := gitCmdRE.FindStringSubmatch(strings.TrimSpace(command))
if m == nil {
_, _ = io.WriteString(ch.Stderr(), "unsupported command; only git-upload-pack / git-receive-pack are allowed\n")
return 1
}
service, path := m[1], m[2]
pathOrg, name, err := parseRepoPath(path)
if err != nil {
_, _ = io.WriteString(ch.Stderr(), "invalid repo path: "+err.Error()+"\n")
return 1
}
// Tenancy: the key's bound org is authoritative; the path org must match it.
// A key for org A can never reach org B's namespace even if it crafts the path.
if keyOrg == "" || pathOrg != keyOrg {
_, _ = io.WriteString(ch.Stderr(), "access denied: repository is outside your organization\n")
return 1
}
// SSH has no project sub-scope (no X-Project-Id) — org-level repos only.
const project = ""
store, err := srv.svc.storeFor(keyOrg)
if err != nil {
_, _ = io.WriteString(ch.Stderr(), "internal error\n")
return 1
}
if _, err := store.Get(context.Background(), keyOrg, project, name); err != nil {
_, _ = io.WriteString(ch.Stderr(), "repository not found\n")
return 1
}
ctx := context.Background()
switch service {
case svcUploadPack:
if err := srv.svc.sshUploadPack(ctx, keyOrg, project, name, ch, ch); err != nil {
srv.svc.log.Warn("git ssh upload-pack failed", "org", keyOrg, "repo", name, "err", err)
return 1
}
case svcReceivePack:
if err := srv.svc.sshReceivePack(ctx, keyOrg, project, name, ch, ch); err != nil {
srv.svc.log.Warn("git ssh receive-pack failed", "org", keyOrg, "repo", name, "err", err)
return 1
}
}
return 0
}
// exit sends the exit-status request and closes the write side, mirroring what a
// real git server does so the client sees a clean end.
func (srv *sshServer) exit(ch ssh.Channel, code int) {
_, _ = ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{uint32(code)}))
}
// repoPathRE validates the "<org>/<repo>" tail of a git SSH path. Both segments
// are safe identifiers (mirrors nameRE); a leading slash is tolerated (git may
// send an absolute-looking path).
var repoPathRE = regexp.MustCompile(`^/?([A-Za-z0-9][A-Za-z0-9._-]{0,63})/([A-Za-z0-9][A-Za-z0-9._-]{0,63})$`)
// parseRepoPath extracts (org, repo) from a git SSH path like "acme/code.git"
// or "/acme/code.git". The trailing ".git" is stripped, and both segments are
// validated as safe identifiers so the path can never traverse storage.
func parseRepoPath(path string) (org, repo string, err error) {
path = strings.TrimSpace(path)
path = strings.TrimSuffix(path, ".git")
m := repoPathRE.FindStringSubmatch(path)
if m == nil {
return "", "", errors.New("path must be <org>/<repo>.git")
}
return m[1], m[2], nil
}
// loadOrCreateHostKey resolves the SSH host key signer: an operator-provided PEM
// (KMS/env) wins; else an on-disk key is loaded, or generated (ed25519) and
// persisted 0600 on first boot. NEVER hardcoded.
func loadOrCreateHostKey(conf sshConf) (ssh.Signer, error) {
if len(conf.hostKeyPEM) > 0 {
signer, err := ssh.ParsePrivateKey(conf.hostKeyPEM)
if err != nil {
return nil, fmt.Errorf("parse CLOUD_GIT_SSH_HOST_KEY: %w", err)
}
return signer, nil
}
if b, err := os.ReadFile(conf.hostKeyPath); err == nil {
signer, err := ssh.ParsePrivateKey(b)
if err != nil {
return nil, fmt.Errorf("parse host key %q: %w", conf.hostKeyPath, err)
}
return signer, nil
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("read host key %q: %w", conf.hostKeyPath, err)
}
// First boot: generate an ed25519 host key and persist it 0600.
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate host key: %w", err)
}
block, err := ssh.MarshalPrivateKey(priv, "hanzo-git-host")
if err != nil {
return nil, fmt.Errorf("marshal host key: %w", err)
}
if err := os.MkdirAll(filepath.Dir(conf.hostKeyPath), 0o700); err != nil {
return nil, fmt.Errorf("mkdir host key dir: %w", err)
}
if err := os.WriteFile(conf.hostKeyPath, pem.EncodeToMemory(block), 0o600); err != nil {
return nil, fmt.Errorf("persist host key %q: %w", conf.hostKeyPath, err)
}
signer, err := ssh.NewSignerFromKey(priv)
if err != nil {
return nil, fmt.Errorf("signer from host key: %w", err)
}
return signer, nil
}
+267
View File
@@ -0,0 +1,267 @@
package git
import (
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"encoding/pem"
"fmt"
"net"
"net/http"
"strings"
"testing"
"time"
"github.com/go-git/go-billy/v5/memfs"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing/object"
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"github.com/go-git/go-git/v5/storage/memory"
xssh "golang.org/x/crypto/ssh"
)
// genClientKey returns a fresh ed25519 keypair as (privatePEM, authorizedKeyLine).
// The private PEM feeds the go-git SSH client; the authorized-key line is what a
// user registers via POST /v1/git/keys.
func genClientKey(t *testing.T) (privPEM []byte, authLine string) {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen key: %v", err)
}
block, err := xssh.MarshalPrivateKey(priv, "")
if err != nil {
t.Fatalf("marshal priv: %v", err)
}
pub, err := xssh.NewPublicKey(priv.Public())
if err != nil {
t.Fatalf("pub: %v", err)
}
return pem.EncodeToMemory(block), strings.TrimSpace(string(xssh.MarshalAuthorizedKey(pub)))
}
// sshClientAuth builds a go-git SSH auth method from a client private PEM,
// ignoring the host key (the in-process listener's host key is ephemeral).
func sshClientAuth(t *testing.T, privPEM []byte) *gitssh.PublicKeys {
t.Helper()
auth, err := gitssh.NewPublicKeys("git", privPEM, "")
if err != nil {
t.Fatalf("client auth: %v", err)
}
auth.HostKeyCallback = xssh.InsecureIgnoreHostKey()
return auth
}
// TestSSHKeyRegistrationAndAuth proves the fingerprint→tenant resolution used by
// the PublicKeyCallback: a registered key resolves to its org; an unregistered
// key fails closed; and a key belongs to exactly one org (no cross-tenant).
func TestSSHKeyRegistrationAndAuth(t *testing.T) {
app := mountApp(t)
_, authLine := genClientKey(t)
// Register the key for acme via the control plane.
code, body := do(t, app, http.MethodPost, "/v1/git/keys", "acme",
map[string]any{"title": "laptop", "publicKey": authLine})
if code != http.StatusCreated {
t.Fatalf("register key want 201, got %d (%s)", code, body)
}
var kv keyView
if err := json.Unmarshal(body, &kv); err != nil {
t.Fatalf("key view json: %v (%s)", err, body)
}
if kv.Fingerprint == "" || !strings.HasPrefix(kv.Fingerprint, "SHA256:") {
t.Fatalf("unexpected fingerprint: %q", kv.Fingerprint)
}
// The registered key resolves to acme via the auth callback path.
pub, _, _, _, err := xssh.ParseAuthorizedKey([]byte(authLine))
if err != nil {
t.Fatalf("parse authline: %v", err)
}
perms, err := mounted.ssh.authPublicKey(fakeConnMeta{}, pub)
if err != nil {
t.Fatalf("registered key must authenticate, got: %v", err)
}
if perms.Extensions["git-org"] != "acme" {
t.Fatalf("resolved org = %q, want acme", perms.Extensions["git-org"])
}
// An UNREGISTERED key fails closed.
otherPub, _ := genClientKeyPub(t)
if _, err := mounted.ssh.authPublicKey(fakeConnMeta{}, otherPub); err == nil {
t.Fatalf("unregistered key must be rejected")
}
// The SAME key cannot be re-registered under a DIFFERENT org (fingerprint is
// globally unique — a key belongs to exactly one tenant).
if code, _ := do(t, app, http.MethodPost, "/v1/git/keys", "beta",
map[string]any{"title": "steal", "publicKey": authLine}); code != http.StatusConflict {
t.Fatalf("cross-org re-register want 409, got %d", code)
}
// beta lists ZERO keys — never acme's.
code, body = do(t, app, http.MethodGet, "/v1/git/keys", "beta", nil)
if code != http.StatusOK {
t.Fatalf("beta list keys want 200, got %d", code)
}
var lst struct {
Data []keyView `json:"data"`
}
_ = json.Unmarshal(body, &lst)
if len(lst.Data) != 0 {
t.Fatalf("beta must see zero keys, got %+v", lst.Data)
}
// Delete the key, then it no longer authenticates (fail closed again).
if code, _ := do(t, app, http.MethodDelete, "/v1/git/keys/"+kv.ID, "acme", nil); code != http.StatusNoContent {
t.Fatalf("delete key want 204, got %d", code)
}
if _, err := mounted.ssh.authPublicKey(fakeConnMeta{}, pub); err == nil {
t.Fatalf("deleted key must be rejected")
}
}
// TestSSHClonePushRoundTrip is the end-to-end SSH proof: register a client key,
// create a repo, push over SSH, then clone over SSH in a fresh client and SEE the
// pushed commit — all through the in-process SSH listener, driving the SAME pack
// code path smart-HTTP uses.
func TestSSHClonePushRoundTrip(t *testing.T) {
app := mountApp(t)
privPEM, authLine := genClientKey(t)
if code, body := do(t, app, http.MethodPost, "/v1/git/keys", "acme",
map[string]any{"title": "ci", "publicKey": authLine}); code != http.StatusCreated {
t.Fatalf("register key: %d %s", code, body)
}
if code, body := do(t, app, http.MethodPost, "/v1/git/repos", "acme",
map[string]any{"name": "code"}); code != http.StatusCreated {
t.Fatalf("create repo: %d %s", code, body)
}
sshURL := fmt.Sprintf("ssh://git@%s/acme/code.git", mounted.ssh.addr())
auth := sshClientAuth(t, privPEM)
// Build a local repo, commit, and push over SSH.
fs := memfs.New()
local, err := gogit.Init(memory.NewStorage(), fs)
if err != nil {
t.Fatalf("init: %v", err)
}
wt, _ := local.Worktree()
f, _ := fs.Create("README.md")
_, _ = f.Write([]byte("# ssh native\n"))
_ = f.Close()
if _, err := wt.Add("README.md"); err != nil {
t.Fatalf("add: %v", err)
}
commit, err := wt.Commit("via ssh", &gogit.CommitOptions{
Author: &object.Signature{Name: "hanzo-dev", Email: "dev@hanzo.ai", When: time.Now()},
})
if err != nil {
t.Fatalf("commit: %v", err)
}
if _, err := local.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{sshURL}}); err != nil {
t.Fatalf("remote: %v", err)
}
if err := local.Push(&gogit.PushOptions{
RemoteName: "origin",
RefSpecs: []config.RefSpec{"refs/heads/master:refs/heads/main"},
Auth: auth,
}); err != nil {
t.Fatalf("ssh push: %v", err)
}
// Fresh clone over SSH — the pushed commit must be there.
cloned, err := gogit.Clone(memory.NewStorage(), memfs.New(), &gogit.CloneOptions{URL: sshURL, Auth: auth})
if err != nil {
t.Fatalf("ssh clone: %v", err)
}
head, err := cloned.Head()
if err != nil {
t.Fatalf("head: %v", err)
}
if head.Hash() != commit {
t.Fatalf("cloned HEAD %s != pushed %s", head.Hash(), commit)
}
}
// TestSSHCrossTenantRejected proves a key bound to acme cannot reach beta's
// namespace even when it crafts a beta path — the SSH tenancy guard.
func TestSSHCrossTenantRejected(t *testing.T) {
app := mountApp(t)
privPEM, authLine := genClientKey(t)
if code, _ := do(t, app, http.MethodPost, "/v1/git/keys", "acme",
map[string]any{"publicKey": authLine}); code != http.StatusCreated {
t.Fatal("register acme key failed")
}
// beta owns a repo the acme key must NOT reach.
if code, _ := do(t, app, http.MethodPost, "/v1/git/repos", "beta",
map[string]any{"name": "secret"}); code != http.StatusCreated {
t.Fatal("create beta repo failed")
}
sshURL := fmt.Sprintf("ssh://git@%s/beta/secret.git", mounted.ssh.addr())
auth := sshClientAuth(t, privPEM)
// acme's key authenticates, but the beta path is outside its org → the exec
// handler denies and the clone fails.
_, err := gogit.Clone(memory.NewStorage(), memfs.New(), &gogit.CloneOptions{URL: sshURL, Auth: auth})
if err == nil {
t.Fatalf("acme key cloning beta repo must fail")
}
}
// TestSSHParseRepoPath covers the path parser's accept/reject cases directly.
func TestSSHParseRepoPath(t *testing.T) {
cases := []struct {
in, org, repo string
ok bool
}{
{"acme/code.git", "acme", "code", true},
{"/acme/code.git", "acme", "code", true},
{"acme/code", "acme", "code", true},
{"../etc/passwd", "", "", false},
{"acme/../beta/x.git", "", "", false},
{"acme", "", "", false},
{"a/b/c.git", "", "", false},
}
for _, tc := range cases {
org, repo, err := parseRepoPath(tc.in)
if tc.ok {
if err != nil || org != tc.org || repo != tc.repo {
t.Fatalf("parseRepoPath(%q) = (%q,%q,%v), want (%q,%q,nil)", tc.in, org, repo, err, tc.org, tc.repo)
}
} else if err == nil {
t.Fatalf("parseRepoPath(%q) should have failed, got (%q,%q)", tc.in, org, repo)
}
}
}
// --- test doubles ---
// fakeConnMeta is a minimal ssh.ConnMetadata for exercising authPublicKey
// directly (the callback only reads the presented key, not the conn).
type fakeConnMeta struct{}
func (fakeConnMeta) User() string { return "git" }
func (fakeConnMeta) SessionID() []byte { return nil }
func (fakeConnMeta) ClientVersion() []byte { return []byte("SSH-2.0-test") }
func (fakeConnMeta) ServerVersion() []byte { return []byte("SSH-2.0-hanzo") }
func (fakeConnMeta) RemoteAddr() net.Addr { return &net.TCPAddr{} }
func (fakeConnMeta) LocalAddr() net.Addr { return &net.TCPAddr{} }
// genClientKeyPub returns just an ssh.PublicKey for the unregistered-key case.
func genClientKeyPub(t *testing.T) (xssh.PublicKey, ed25519.PrivateKey) {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen key: %v", err)
}
pub, err := xssh.NewPublicKey(priv.Public())
if err != nil {
t.Fatalf("pub: %v", err)
}
return pub, priv
}
+1
View File
@@ -16,6 +16,7 @@ import (
// per-org SQLite files land where the path convention says.
func mountAppDir(t *testing.T, dir string) *zip.App {
t.Helper()
t.Setenv("CLOUD_GIT_SSH_ADDR", "127.0.0.1:0") // ephemeral SSH port (no :2222 collisions)
app := zip.New(zip.Config{Logger: luxlog.New("test")})
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: dir, Domain: "api.hanzo.test"}); err != nil {
t.Fatalf("Mount: %v", err)
+177
View File
@@ -0,0 +1,177 @@
package git
import (
"errors"
"net/http"
"strings"
"github.com/zap-proto/zip"
)
// zap.go is git's ZAP transport — the SECOND transport over the ONE control-plane
// core (core.go). It establishes the canonical pattern every Hanzo subsystem
// copies to "go ZAP": there is no per-service ZAP server and no gRPC.
//
// # THE STANDARD (copy this for the next service)
//
// The cloud binary already serves ONE ZAP-over-WebSocket plane: zapface.Handler,
// mounted at /zap in serve.go. It is a pure transport bridge — it replays every
// inbound ZAP frame as an in-process HTTP request against the SAME Fiber app,
// so ANY /v1 route is automatically reachable as a ZAP procedure. A service does
// NOT stand up its own zapclient.Server; doing so would be a redundant parallel
// path (one-and-only-one-way).
//
// The zapface bridge unwraps a `{status, msg, data}` envelope as the ZAP result.
// A service's REST handlers return RAW JSON (the git CLI + REST clients want the
// resource verbatim), so a service exposes ZAP procedures as a THIN, envelope-
// shaping adapter layer at:
//
// POST /v1/<service>/zap/<procedure>
//
// mapped by zapface from the ZAP method "<service>/zap/<procedure>". Each adapter
// resolves the tenant EXACTLY as the REST handler does (principal.Tenant →
// X-Org-Id, minted by the identity middleware from the browser's replayed
// credential), then calls the SAME core func the REST handler calls, and wraps
// the result in the envelope. So the REST handler and the ZAP procedure are two
// thin adapters over ONE core func each — the business logic lives once.
//
// git's procedures (all over the core in core.go):
//
// git/zap/createRepo -> coreCreate git/zap/deleteRepo -> coreDelete
// git/zap/listRepos -> coreList git/zap/usage -> coreUsage
// git/zap/getRepo -> coreGet
//
// The next service (e.g. crm, prompts) copies this file's shape: one mountZAP
// registering /v1/<service>/zap/<proc> envelope adapters over its own core funcs.
// Nothing else — the /zap plane does the rest.
// mountZAP registers git's ZAP procedure adapters. Called from Mount. The
// procedures are ordinary /v1 routes; the shared /zap plane turns them into ZAP
// procedures for the browser/service ZAP client.
func (s *svc) mountZAP(app *zip.App) error {
if app == nil {
return errors.New("git.mountZAP: nil app")
}
app.Post("/v1/git/zap/createRepo", s.zapCreate)
app.Post("/v1/git/zap/listRepos", s.zapList)
app.Post("/v1/git/zap/getRepo", s.zapGet)
app.Post("/v1/git/zap/deleteRepo", s.zapDelete)
app.Post("/v1/git/zap/usage", s.zapUsage)
return nil
}
// ---- envelope ----
// okEnvelope is the success shape the zapface bridge unwraps (data → the ZAP
// result). It is the SAME shape every /v1 ZAP-facing handler returns, so the
// bridge stays fully generic.
func okEnvelope(c *zip.Ctx, data any) error {
return c.JSON(http.StatusOK, map[string]any{"status": "ok", "msg": "", "data": data})
}
// errEnvelope reports a handler error in the envelope with the given HTTP status
// (the bridge maps non-ok status → a ZAP dispatch error the client observes).
func errEnvelope(c *zip.Ctx, status int, msg string) error {
return c.JSON(status, map[string]any{"status": "error", "msg": msg})
}
// zapErr maps a core sentinel error to an envelope response — the ZAP twin of
// the REST status mapping (createErr etc.), so both transports agree on which
// failure is a 400/404/409 while returning their own wire shape.
func zapErr(c *zip.Ctx, err error) error {
switch {
case errors.Is(err, errBadInput):
return errEnvelope(c, http.StatusBadRequest, strings.TrimPrefix(err.Error(), "git: invalid input: "))
case errors.Is(err, errConflict):
return errEnvelope(c, http.StatusConflict, "repo name already exists in this scope")
case errors.Is(err, errNotFound):
return errEnvelope(c, http.StatusNotFound, "repo not found")
default:
return errEnvelope(c, http.StatusInternalServerError, err.Error())
}
}
// ---- procedure adapters (thin — one core call each) ----
// zapProcReq is the JSON body a ZAP client sends to a git procedure. Which
// fields matter depends on the procedure (createRepo reads all; listRepos reads
// none; getRepo/deleteRepo read name). Tenant + project scope come from the
// request identity, NEVER the body — the body cannot widen the caller's org.
type zapProcReq struct {
Name string `json:"name"`
Project string `json:"project"`
Description string `json:"description"`
}
func (s *svc) zapCreate(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return errEnvelope(c, http.StatusForbidden, "X-Org-Id required")
}
var body zapProcReq
if err := c.Bind(&body); err != nil {
return errEnvelope(c, http.StatusBadRequest, "invalid body")
}
view, err := s.coreCreate(c.Context(), org, projectScope(c), createReq{
Name: body.Name, Project: body.Project, Description: body.Description,
})
if err != nil {
return zapErr(c, err)
}
return okEnvelope(c, view)
}
func (s *svc) zapList(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return errEnvelope(c, http.StatusForbidden, "X-Org-Id required")
}
out, err := s.coreList(c.Context(), org, projectScope(c))
if err != nil {
return zapErr(c, err)
}
return okEnvelope(c, out)
}
func (s *svc) zapGet(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return errEnvelope(c, http.StatusForbidden, "X-Org-Id required")
}
var body zapProcReq
if err := c.Bind(&body); err != nil {
return errEnvelope(c, http.StatusBadRequest, "invalid body")
}
view, err := s.coreGet(c.Context(), org, projectScope(c), body.Name)
if err != nil {
return zapErr(c, err)
}
return okEnvelope(c, view)
}
func (s *svc) zapDelete(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return errEnvelope(c, http.StatusForbidden, "X-Org-Id required")
}
var body zapProcReq
if err := c.Bind(&body); err != nil {
return errEnvelope(c, http.StatusBadRequest, "invalid body")
}
if err := s.coreDelete(c.Context(), org, projectScope(c), body.Name); err != nil {
return zapErr(c, err)
}
return okEnvelope(c, map[string]any{"deleted": true, "name": normalizeName(body.Name)})
}
func (s *svc) zapUsage(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return errEnvelope(c, http.StatusForbidden, "X-Org-Id required")
}
out, err := s.coreUsage(c.Context(), org)
if err != nil {
return zapErr(c, err)
}
return okEnvelope(c, out)
}
+232
View File
@@ -0,0 +1,232 @@
package git
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"testing"
"time"
"github.com/coder/websocket"
zap "github.com/zap-proto/go"
zaprpc "github.com/zap-proto/go/rpc"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/zapface"
luxlog "github.com/luxfi/log"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
)
// zapface reply field offsets — the wire contract zapface/wire.go encodes. Kept
// here (not imported — they are internal to zapface) so the test asserts the
// exact bytes a ZAP client reads.
const (
zapReplyOkOff = 0
zapReplyStatusOff = 4
zapReplyResultOff = 8
zapReplyErrorJSONOff = 16
zapReqMethodOff = 0
zapReqPayloadOff = 8
zapReqFixedSize = 16
)
// mountZapApp mounts git AND the shared zapface /zap plane on one app, with a
// tiny identity shim that maps `Authorization: Bearer test-<org>` to the
// X-Org-Id / X-User-Id headers the gateway/identity middleware would mint in
// production. This is the honest stand-in for the gateway: the ZAP frame really
// traverses the /zap plane into /v1/git/zap/*, and the tenant is resolved by the
// SAME principal.Tenant the REST path uses.
func mountZapApp(t *testing.T) (base string, stop func()) {
t.Helper()
t.Setenv("CLOUD_GIT_SSH_ADDR", "127.0.0.1:0")
app := zip.New(zip.Config{Logger: luxlog.New("test")})
// Identity shim (stands in for the gateway + SanitizeIdentity middleware):
// derive X-Org-Id/X-User-Id from a "Bearer test-<org>" credential, written
// onto the REQUEST headers so the downstream handler's principal.Tenant (which
// reads X-Org-Id/X-User-Id off the request) sees a validated principal. The
// zapface bridge replays the WS-upgrade Authorization header on every /v1
// dispatch, so this runs for ZAP calls exactly as for REST. Uses UseFiber to
// reach the raw request headers (zip.Ctx only exposes response-header writes).
app.UseFiber(func(c fiber.Ctx) error {
if h := c.Get("Authorization"); strings.HasPrefix(h, "Bearer test-") {
org := strings.TrimPrefix(h, "Bearer test-")
c.Request().Header.Set("X-Org-Id", org)
c.Request().Header.Set("X-User-Id", "u_"+org)
}
return c.Next()
})
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), Domain: "api.hanzo.test"}); err != nil {
t.Fatalf("Mount: %v", err)
}
// The shared ZAP-over-WebSocket plane — the SAME one serve.go mounts. It
// bridges ZAP frames onto this app's /v1 routes, so git/zap/* are procedures.
app.Get("/zap", zapface.Handler(app.Fiber(), zapface.Options{Logger: luxlog.New("test")}))
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() { _ = app.Fiber().Listener(ln) }()
addr := ln.Addr().String()
waitTCP(t, addr)
t.Cleanup(func() { _ = Shutdown() })
return addr, func() { _ = app.Fiber().Shutdown() }
}
func waitTCP(t *testing.T, addr string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond); err == nil {
_ = conn.Close()
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("listener %s never came up", addr)
}
// zapReply is the decoded reply the client reads off the wire.
type zapReply struct {
ok bool
status uint32
result string
errorJSON string
}
// zapCall drives one ZAP procedure over the WebSocket and returns the decoded
// reply — the browser/service ZAP client path, byte-for-byte.
func zapCall(t *testing.T, c *websocket.Conn, ctx context.Context, method string, input any, promiseID uint32) zapReply {
t.Helper()
var payload string
if input != nil {
j, _ := json.Marshal(input)
payload = `{"json":` + string(j) + `}` // SuperJSON wrap
}
inner := buildZapInner(method, payload)
frame := zaprpc.BuildRequest(zaprpc.Call{Method: 1, PromiseID: promiseID, Payload: inner})
if err := c.Write(ctx, websocket.MessageBinary, frame); err != nil {
t.Fatalf("ws write: %v", err)
}
_, data, err := c.Read(ctx)
if err != nil {
t.Fatalf("ws read: %v", err)
}
resp, err := zaprpc.ParseResponse(data)
if err != nil {
t.Fatalf("ParseResponse: %v", err)
}
if resp.PromiseID != promiseID {
t.Fatalf("promiseID echo = %d, want %d", resp.PromiseID, promiseID)
}
m, err := zap.Parse(resp.Body)
if err != nil {
t.Fatalf("parse reply body: %v", err)
}
r := m.Root()
return zapReply{
ok: r.Bool(zapReplyOkOff),
status: r.Uint32(zapReplyStatusOff),
result: r.Text(zapReplyResultOff),
errorJSON: r.Text(zapReplyErrorJSONOff),
}
}
// buildZapInner builds the inner {method, payload} request object zapface
// decodes (wire.go reqMethodOff=0 / reqPayloadOff=8 / fixed 16).
func buildZapInner(method, payload string) []byte {
b := zap.NewBuilder(len(method) + len(payload) + zapReqFixedSize + 64)
ob := b.StartObject(zapReqFixedSize)
ob.SetText(zapReqMethodOff, method)
ob.SetText(zapReqPayloadOff, payload)
ob.FinishAsRoot()
return b.Finish()
}
// unwrapResult unwraps the SuperJSON {"json":V} the reply result carries.
func unwrapResult(s string) []byte {
var env struct {
JSON json.RawMessage `json:"json"`
}
if err := json.Unmarshal([]byte(s), &env); err == nil && len(env.JSON) > 0 {
return env.JSON
}
return []byte(s)
}
// TestZAPControlPlaneRoundTrip is the ZAP proof: createRepo + listRepos over the
// shared /zap WebSocket plane hit the SAME core funcs the REST handlers call, and
// a ZAP-created repo is then visible over the REST list — one implementation,
// two transports.
func TestZAPControlPlaneRoundTrip(t *testing.T) {
base, stop := mountZapApp(t)
defer stop()
wsURL := fmt.Sprintf("ws://%s/zap", base)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
c, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{
HTTPHeader: http.Header{"Authorization": []string{"Bearer test-acme"}},
})
if err != nil {
t.Fatalf("ws dial: %v", err)
}
defer c.CloseNow()
// 1) createRepo over ZAP.
rep := zapCall(t, c, ctx, "git/zap/createRepo", map[string]any{"name": "zapsvc"}, 1)
if !rep.ok || rep.status != http.StatusOK {
t.Fatalf("createRepo: ok=%v status=%d err=%s", rep.ok, rep.status, rep.errorJSON)
}
var created repoView
if err := json.Unmarshal(unwrapResult(rep.result), &created); err != nil {
t.Fatalf("decode createRepo result: %v (%s)", err, rep.result)
}
if created.Org != "acme" || created.Name != "zapsvc" {
t.Fatalf("unexpected repo: %+v", created)
}
if created.CloneURL != "https://api.hanzo.test/v1/git/acme/zapsvc.git" {
t.Fatalf("unexpected cloneUrl: %q", created.CloneURL)
}
if !strings.HasPrefix(created.SSHURL, "git@git.hanzo.test:acme/zapsvc.git") {
t.Fatalf("unexpected sshUrl: %q", created.SSHURL)
}
// 2) listRepos over ZAP — sees the ZAP-created repo.
rep = zapCall(t, c, ctx, "git/zap/listRepos", map[string]any{}, 2)
if !rep.ok {
t.Fatalf("listRepos !ok: %s", rep.errorJSON)
}
var listed []repoView
if err := json.Unmarshal(unwrapResult(rep.result), &listed); err != nil {
t.Fatalf("decode listRepos: %v (%s)", err, rep.result)
}
if len(listed) != 1 || listed[0].Name != "zapsvc" {
t.Fatalf("listRepos over ZAP = %+v", listed)
}
// 3) Cross-transport proof: the ZAP-created repo is present in the SAME store
// the REST handler reads, via the SAME core func (coreList) the REST list
// handler calls. One implementation, two transports.
out, err := mounted.coreList(context.Background(), "acme", "")
if err != nil {
t.Fatalf("core list: %v", err)
}
if len(out) != 1 || out[0].Name != "zapsvc" {
t.Fatalf("REST/core view of ZAP-created repo = %+v", out)
}
// 4) A bad createRepo over ZAP maps the core error to a non-ok reply (the ZAP
// twin of the REST 400) — same core validation, different wire shape.
rep = zapCall(t, c, ctx, "git/zap/createRepo", map[string]any{"name": "bad name!"}, 3)
if rep.ok {
t.Fatalf("createRepo with invalid name should not be ok: %+v", rep)
}
}