Files
deploy/util/db/certificate.go
41536738c8 refactor(cd): hard-fork - rename CRD API group argoproj.io -> deploy.hanzo.ai (#1)
* feat(api): hard-fork CRD API group argoproj.io -> deploy.hanzo.ai

Hanzo CD hard-fork, phase 1.1. Single source of truth is
pkg/apis/application/register.go's Group constant (Application/AppProject/
ApplicationSet all derive their FullName from it); every other Go call-site
that hardcoded the literal group string is updated to match: the 3 generated
informer GVRs, the app-of-apps nested-Application detector in types.go, the
resource-tree Application-kind cache populator, the 3 NotFound error
constructors in server/application, and the applicationset-controller's
leader-election Lease name/ID (kept in lockstep across Go source + RBAC
resourceNames, or leader election would get RBAC-denied on startup).

CRD YAMLs (application/appproject/applicationset) and every RBAC apiGroups
rule that actually governs our 3 CRDs are repointed to deploy.hanzo.ai, across
both manifests/base/* (namespaced Roles) and manifests/cluster-rbac/* (cluster
scope). Two adjacent things that share the "argoproj.io" string but are NOT
ours are deliberately left untouched: gitops-engine's health-check dispatch
for Argo Workflows' `Workflow` kind, and argocd-server-clusterrole's second
apiGroups block granting read on Argo Workflows' `workflows` resource — both
belong to a separate, unrelated Argoproj-family project we are not forking.

Also untouched, per explicit scope: annotation/label keys (argocd.argoproj.io/*)
and finalizers (*.argoproj.io) that track already-applied live resources, and
argocd-cm.yaml's resource.customizations.ignoreResourceUpdates keys (a narrow
app-of-apps diffing nicety, not build- or RBAC-relevant).

CGO_ENABLED=0 go build ./... verified green.

* ci: wire canonical hanzo.yml + cicd.yml (Hanzo CD image build)

Phase 1.3. Follows the same one-way pattern every hanzo/lux/zoo repo uses:
hanzo.yml declares the image (root Dockerfile -> ghcr.io/hanzoai/deploy) and a
fast go-build sanity test (the multi-stage Dockerfile build is the slow, real
gate); cicd.yml is the ~10-line trigger shim importing hanzoai/ci's reusable
build workflow on the arc pool, secrets from KMS only.

No `deploy:` block yet, matching the hanzoai/cms build-only-first-cutover
pattern: this repo's own live hanzo-cd namespace runs the whole Hanzo fleet's
GitOps, so the prod image/CRD swap is a deliberate separate action (see the
[HELD] universe PR), never an automatic rollout on merge. Every PR/push still
builds and pushes an immutable ghcr.io/hanzoai/deploy:sha-<7>-amd64 image.

* feat(ui): rebrand Argo CD -> Hanzo CD (title, favicon, logo, sidebar)

Phase 1.2. User-facing "Argo CD"/"ArgoCD" text replaced with "Hanzo CD"
across the login page, help modal, settings screens, version-info panel,
sync-options warning, fullscreen-logs title, app.tsx page titles, and
index.html's <title>/noscript text (dropped the now-wrong upstream CLI
doc link rather than invent a hanzo.ai URL that doesn't exist).

Logo/favicon assets replaced with the canonical Hanzo mark (never
hand-drawn, copied from ~/work/hanzo/logo/dist): the two real usages of
Argo's logo collapse onto one Hanzo glyph (mono + white variants) at the
same 5 call sites (login icon + footer, login.scss $logoPath/
$logoNegativePath, sidebar text-logo), preserving the existing CSS
invert() filters so both the light login panel and dark sidebar still
render correctly. Old argologo.svg/argo_o.svg/argo.png deleted (no
longer referenced). favicon-{16x16,32x32}.png added at
ui/src/assets/favicon/ (index.html's links to that path were previously
dead - the directory didn't exist in this checkout). package.json name
argo-cd-ui -> hanzo-cd-ui.

Left alone, deliberately: the two Kubernetes resource-kind icon sets
under ui/src/assets/images/resources/ (unrelated to branding), and
upstream doc/project links (argoproj.io, argo-cd.readthedocs.io) since
we have no equivalent hanzo.ai docs to point to yet - except the login
footer logo's outbound link, which now points to hanzo.ai instead of
argoproj.io (a Hanzo-branded mark linking to the upstream project it
was forked from read as an obvious leftover).

* feat(module): rename Go module github.com/argoproj/argo-cd/v3 -> github.com/hanzoai/deploy

Phase 2. No version suffix on the new path (org policy: never publish our
own Go modules above v1.x.x / with a v2+ suffix) — this fork starts a fresh
v0/v1 module identity rather than carrying the v3 the upstream fork point
had reached.

Mechanical, symmetric rewrite: 704 .go files (import statements only; two
disjoint substitutions applied specific-first so the nested gitops-engine
module path never double-matches), plus go.mod (module line, the
gitops-engine require+replace pair, version reset to v0.0.0-... since a
suffix-less path can't carry a v3 semver), gitops-engine/go.mod, Makefile
(ldflags PACKAGE + 2 TEST_MODULE filters), .golangci.yaml, .goreleaser.yaml,
.mockery.yaml (+ gitops-engine's), Procfile, the codegen/proto generation
scripts (hack/generate-proto.sh, update-codegen.sh, update-openapi.sh), and
20 .proto files' go_package options. Also one OpenTelemetry tracer name
string in gitops-engine/pkg/sync/sync_context.go (a cosmestic label, not an
import, but worth keeping consistent).

CGO_ENABLED=0 go build ./... verified green for both the root module and
the nested gitops-engine module; go vet clean on both; go mod tidy run
(network available) to reconcile go.sum, dependency set unchanged.

Left alone, deliberately: doc-only mentions (docs/snyk/*.html scan reports,
gitops-engine/README.md), a comment-only reference in
.github/workflows/release.yaml, and one Renovate bot config line — none are
build- or codegen-load-bearing. Also left alone: comments citing upstream
GitHub issue URLs (github.com/argoproj/argo-cd/issues/NNNNN) and a handful
of test fixtures using "https://github.com/argoproj/argo-cd.git" as a
representative example repo URL — both are accurate historical/test-data
references, not stale self-references.

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 18:27:58 -07:00

494 lines
16 KiB
Go

package db
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"golang.org/x/crypto/ssh"
log "github.com/sirupsen/logrus"
"github.com/hanzoai/deploy/common"
appsv1 "github.com/hanzoai/deploy/pkg/apis/application/v1alpha1"
certutil "github.com/hanzoai/deploy/util/cert"
)
// SSHKnownHostsEntry represents an entry in the list of SSH known hosts.
type SSHKnownHostsEntry struct {
// Hostname the key is for
Host string
// The type of the key
SubType string
// The data of the key, including the type
Data string
// The SHA256 fingerprint of the key
Fingerprint string
}
// TLSCertificate represents a TLS certificate.
type TLSCertificate struct {
// Subject of the certificate
Subject string
// Issuer of the certificate
Issuer string
// Certificate data
Data string
}
// CertificateListSelector is a helper struct for certificate selection.
type CertificateListSelector struct {
// Pattern to match the hostname with
HostNamePattern string
// Type of certificate to match
CertType string
// Subtype of certificate to match
CertSubType string
}
// ListRepoCertificates returns a list of all configured repository certificates matching the given
// selector. The list of certificates explicitly excludes the CertData of
// the certificates, and only returns the metadata including CertInfo field.
//
// The CertInfo field in the returned entries will contain the following data:
// - For SSH keys, the SHA256 fingerprint of the key as string, prepended by
// the string "SHA256:"
// - For TLS certs, the Subject of the X509 cert as a string in DN notation
func (db *db) ListRepoCertificates(_ context.Context, selector *CertificateListSelector) (*appsv1.RepositoryCertificateList, error) {
// selector may be given as nil, but we need at least an empty data structure,
// so we create it if necessary.
if selector == nil {
selector = &CertificateListSelector{}
}
certificates := make([]appsv1.RepositoryCertificate, 0)
// Get all SSH known host entries
if selector.CertType == "" || selector.CertType == "*" || selector.CertType == "ssh" {
sshKnownHosts, err := db.getSSHKnownHostsData()
if err != nil {
return nil, err
}
for _, entry := range sshKnownHosts {
if certutil.MatchHostName(entry.Host, selector.HostNamePattern) && (selector.CertSubType == "" || selector.CertSubType == "*" || selector.CertSubType == entry.SubType) {
certificates = append(certificates, appsv1.RepositoryCertificate{
ServerName: entry.Host,
CertType: "ssh",
CertSubType: entry.SubType,
CertInfo: "SHA256:" + certutil.SSHFingerprintSHA256FromString(fmt.Sprintf("%s %s", entry.Host, entry.Data)),
})
}
}
}
// Get all TLS certificates
if selector.CertType == "" || selector.CertType == "*" || selector.CertType == "https" || selector.CertType == "tls" {
tlsCertificates, err := db.getTLSCertificateData()
if err != nil {
return nil, err
}
for _, entry := range tlsCertificates {
if certutil.MatchHostName(entry.Subject, selector.HostNamePattern) {
pemEntries, err := certutil.ParseTLSCertificatesFromData(entry.Data)
if err != nil {
continue
}
for _, pemEntry := range pemEntries {
var certInfo, certSubType string
x509Data, err := certutil.DecodePEMCertificateToX509(pemEntry)
if err != nil {
certInfo = err.Error()
certSubType = "invalid"
} else {
certInfo = x509Data.Subject.String()
certSubType = x509Data.PublicKeyAlgorithm.String()
}
certificates = append(certificates, appsv1.RepositoryCertificate{
ServerName: entry.Subject,
CertType: "https",
CertSubType: strings.ToLower(certSubType),
CertInfo: certInfo,
})
}
}
}
}
return &appsv1.RepositoryCertificateList{
Items: certificates,
}, nil
}
// GetRepoCertificate returns a single certificate from the datastore
func (db *db) GetRepoCertificate(_ context.Context, serverType string, serverName string) (*appsv1.RepositoryCertificate, error) {
if serverType == "ssh" {
sshKnownHostsList, err := db.getSSHKnownHostsData()
if err != nil {
return nil, err
}
for _, entry := range sshKnownHostsList {
if entry.Host == serverName {
repo := &appsv1.RepositoryCertificate{
ServerName: entry.Host,
CertType: "ssh",
CertSubType: entry.SubType,
CertData: []byte(entry.Data),
CertInfo: entry.Fingerprint,
}
return repo, nil
}
}
}
// Fail
return nil, nil
}
// CreateRepoCertificate creates one or more repository certificates and returns a list of certificates
// actually created.
func (db *db) CreateRepoCertificate(ctx context.Context, certificates *appsv1.RepositoryCertificateList, upsert bool) (*appsv1.RepositoryCertificateList, error) {
var (
saveSSHData = false
saveTLSData = false
)
sshKnownHostsList, err := db.getSSHKnownHostsData()
if err != nil {
return nil, err
}
tlsCertificates, err := db.getTLSCertificateData()
if err != nil {
return nil, err
}
// This will hold the final list of certificates that have been created
created := make([]appsv1.RepositoryCertificate, 0)
// Each request can contain multiple certificates of different types, so we
// make sure to handle each request accordingly.
for _, certificate := range certificates.Items {
// Ensure valid repo server name was given only for https certificates.
// For SSH known host entries, we let Go's ssh library do the validation
// later on.
if certificate.CertType == "https" && !certutil.IsValidHostname(certificate.ServerName, false) {
return nil, fmt.Errorf("invalid hostname in request: %s", certificate.ServerName)
} else if certificate.CertType == "ssh" {
// Matches "[hostname]:port" format
reExtract := regexp.MustCompile(`^\[(.*)]:\d+$`)
matches := reExtract.FindStringSubmatch(certificate.ServerName)
var hostnameToCheck string
if len(matches) == 0 {
hostnameToCheck = certificate.ServerName
} else {
hostnameToCheck = matches[1]
}
if !certutil.IsValidHostname(hostnameToCheck, false) {
return nil, fmt.Errorf("invalid hostname in request: %s", hostnameToCheck)
}
}
switch certificate.CertType {
case "ssh":
// Whether we have a new certificate entry
newEntry := true
// Whether we have upserted an existing certificate entry
upserted := false
// Check whether known hosts entry already exists. Must match hostname
// and the key sub type (e.g. ssh-rsa). It is considered an error if we
// already have a corresponding key and upsert was not specified.
for _, entry := range sshKnownHostsList {
if entry.Host == certificate.ServerName && entry.SubType == certificate.CertSubType {
if !upsert && entry.Data != string(certificate.CertData) {
return nil, fmt.Errorf("key for '%s' (subtype: '%s') already exists, and upsert was not specified", entry.Host, entry.SubType)
}
// Do not add an entry on upsert, but remember if we actually did an
// upsert.
newEntry = false
if entry.Data != string(certificate.CertData) {
entry.Data = string(certificate.CertData)
upserted = true
}
break
}
}
// Make sure that we received a valid public host key by parsing it
_, hostnames, rawKeyData, _, _, err := ssh.ParseKnownHosts(fmt.Appendf(nil, "%s %s %s", certificate.ServerName, certificate.CertSubType, certificate.CertData))
if err != nil {
return nil, err
}
if len(hostnames) == 0 {
log.Errorf("Could not parse hostname for key from token %s", certificate.ServerName)
}
if newEntry {
sshKnownHostsList = append(sshKnownHostsList, &SSHKnownHostsEntry{
Host: hostnames[0],
Data: string(certificate.CertData),
SubType: certificate.CertSubType,
})
}
// If we created a new entry, or if we upserted an existing one, we need
// to save the data and notify the consumer about the operation.
if newEntry || upserted {
certificate.CertInfo = certutil.SSHFingerprintSHA256(rawKeyData)
created = append(created, certificate)
saveSSHData = true
}
case "https":
var tlsCertificate *TLSCertificate
newEntry := true
upserted := false
pemCreated := make([]string, 0)
for _, entry := range tlsCertificates {
// We have an entry for this server already. Check for upsert.
if entry.Subject == certificate.ServerName {
newEntry = false
if entry.Data != string(certificate.CertData) {
if !upsert {
return nil, fmt.Errorf("TLS certificate for server '%s' already exists, and upsert was not specified", entry.Subject)
}
}
// Store pointer to this entry for later use.
tlsCertificate = entry
break
}
}
// Check for validity of data received
pemData, err := certutil.ParseTLSCertificatesFromData(string(certificate.CertData))
if err != nil {
return nil, err
}
// We should have at least one valid PEM entry
if len(pemData) == 0 {
return nil, errors.New("no valid PEM data received")
}
// Make sure we have valid X509 certificates in the data
for _, entry := range pemData {
_, err := certutil.DecodePEMCertificateToX509(entry)
if err != nil {
return nil, err
}
pemCreated = append(pemCreated, entry)
}
// New certificate if pointer to existing cert is nil
if tlsCertificate == nil {
tlsCertificate = &TLSCertificate{
Subject: certificate.ServerName,
Data: string(certificate.CertData),
}
tlsCertificates = append(tlsCertificates, tlsCertificate)
} else if tlsCertificate.Data != string(certificate.CertData) {
// We have made sure the upsert flag was set above. Now just figure out
// again if we have to actually update the data in the existing cert.
tlsCertificate.Data = string(certificate.CertData)
upserted = true
}
if newEntry || upserted {
// We append the certificate for every PEM entry in the request, so the
// caller knows that we processed each single item.
for _, entry := range pemCreated {
created = append(created, appsv1.RepositoryCertificate{
ServerName: certificate.ServerName,
CertType: "https",
CertData: []byte(entry),
})
}
saveTLSData = true
}
default:
// Invalid/unknown certificate type
return nil, fmt.Errorf("unknown certificate type: %s", certificate.CertType)
}
}
if saveSSHData {
err = db.settingsMgr.SaveSSHKnownHostsData(ctx, knownHostsDataToStrings(sshKnownHostsList))
if err != nil {
return nil, err
}
}
if saveTLSData {
err = db.settingsMgr.SaveTLSCertificateData(ctx, tlsCertificatesToMap(tlsCertificates))
if err != nil {
return nil, err
}
}
return &appsv1.RepositoryCertificateList{Items: created}, nil
}
// RemoveRepoCertificates removes configured certificates according to the selector query
func (db *db) RemoveRepoCertificates(ctx context.Context, selector *CertificateListSelector) (*appsv1.RepositoryCertificateList, error) {
var (
knownHostsOld []*SSHKnownHostsEntry
knownHostsNew []*SSHKnownHostsEntry
tlsCertificatesOld []*TLSCertificate
tlsCertificatesNew []*TLSCertificate
err error
)
removed := &appsv1.RepositoryCertificateList{
Items: make([]appsv1.RepositoryCertificate, 0),
}
if selector.CertType == "" || selector.CertType == "ssh" || selector.CertType == "*" {
knownHostsOld, err = db.getSSHKnownHostsData()
if err != nil {
return nil, err
}
knownHostsNew = make([]*SSHKnownHostsEntry, 0)
for _, entry := range knownHostsOld {
if matchSSHKnownHostsEntry(entry, selector) {
removed.Items = append(removed.Items, appsv1.RepositoryCertificate{
ServerName: entry.Host,
CertType: "ssh",
CertSubType: entry.SubType,
CertData: []byte(entry.Data),
})
} else {
knownHostsNew = append(knownHostsNew, entry)
}
}
}
if selector.CertType == "" || selector.CertType == "*" || selector.CertType == "https" || selector.CertType == "tls" {
tlsCertificatesOld, err = db.getTLSCertificateData()
if err != nil {
return nil, err
}
tlsCertificatesNew = make([]*TLSCertificate, 0)
for _, entry := range tlsCertificatesOld {
if certutil.MatchHostName(entry.Subject, selector.HostNamePattern) {
// Wrap each PEM certificate into its own RepositoryCertificate object
// so the caller knows what has actually been removed.
//
// The downside of this is, only valid data can be removed from the CM,
// so if the data somehow got corrupted, it can only be removed by
// means of editing the CM directly using e.g. kubectl.
pemCertificates, err := certutil.ParseTLSCertificatesFromData(entry.Data)
if err != nil {
return nil, err
}
if len(pemCertificates) > 0 {
for _, pem := range pemCertificates {
removed.Items = append(removed.Items, appsv1.RepositoryCertificate{
ServerName: entry.Subject,
CertType: "https",
CertData: []byte(pem),
})
}
}
} else {
tlsCertificatesNew = append(tlsCertificatesNew, entry)
}
}
}
if len(knownHostsNew) < len(knownHostsOld) {
err = db.settingsMgr.SaveSSHKnownHostsData(ctx, knownHostsDataToStrings(knownHostsNew))
if err != nil {
return nil, err
}
}
if len(tlsCertificatesNew) < len(tlsCertificatesOld) {
err = db.settingsMgr.SaveTLSCertificateData(ctx, tlsCertificatesToMap(tlsCertificatesNew))
if err != nil {
return nil, err
}
}
return removed, nil
}
// Converts list of known hosts data to array of strings, suitable for storing
// in a known_hosts file for SSH.
func knownHostsDataToStrings(knownHostsList []*SSHKnownHostsEntry) []string {
knownHostsData := make([]string, 0)
for _, entry := range knownHostsList {
knownHostsData = append(knownHostsData, fmt.Sprintf("%s %s %s", entry.Host, entry.SubType, entry.Data))
}
return knownHostsData
}
// Converts list of TLS certificates to a map whose key will be the certificate
// subject and the data will be a string containing TLS certificate data as PEM
func tlsCertificatesToMap(tlsCertificates []*TLSCertificate) map[string]string {
certMap := make(map[string]string)
for _, entry := range tlsCertificates {
certMap[entry.Subject] = entry.Data
}
return certMap
}
// Get the TLS certificate data from the config map
func (db *db) getTLSCertificateData() ([]*TLSCertificate, error) {
certificates := make([]*TLSCertificate, 0)
certCM, err := db.settingsMgr.GetConfigMapByName(common.ArgoCDTLSCertsConfigMapName)
if err != nil {
return nil, err
}
for key, entry := range certCM.Data {
certificates = append(certificates, &TLSCertificate{Subject: key, Data: entry})
}
return certificates, nil
}
// Gets the SSH known host data from ConfigMap and parse it into an array of
// SSHKnownHostEntry structs.
func (db *db) getSSHKnownHostsData() ([]*SSHKnownHostsEntry, error) {
certCM, err := db.settingsMgr.GetConfigMapByName(common.ArgoCDKnownHostsConfigMapName)
if err != nil {
return nil, err
}
sshKnownHostsData := certCM.Data["ssh_known_hosts"]
entries := make([]*SSHKnownHostsEntry, 0)
// ssh_known_hosts data contains one key per line, so we must iterate over
// the whole data to get all keys.
//
// We validate the data found to a certain extent before we accept them as
// entry into our list to be returned.
//
sshKnownHostsEntries, err := certutil.ParseSSHKnownHostsFromData(sshKnownHostsData)
if err != nil {
return nil, err
}
for _, entry := range sshKnownHostsEntries {
hostname, subType, keyData, err := certutil.TokenizeSSHKnownHostsEntry(entry)
if err != nil {
return nil, err
}
entries = append(entries, &SSHKnownHostsEntry{
Host: hostname,
SubType: subType,
Data: string(keyData),
})
}
return entries, nil
}
func matchSSHKnownHostsEntry(entry *SSHKnownHostsEntry, selector *CertificateListSelector) bool {
return certutil.MatchHostName(entry.Host, selector.HostNamePattern) && (selector.CertSubType == "" || selector.CertSubType == "*" || selector.CertSubType == entry.SubType)
}