mirror of
https://github.com/hanzoai/ingress.git
synced 2026-10-20 15:07:40 +00:00
ingress: KMS is a dependency of ACME, not of the proxy
edgeSecrets ran on every boot, so an edge that orders no certificates still had to reach KMS before it served anything — including routes whose certificates come from a Kubernetes Secret, and plain-HTTP routes. It now returns the identity seal when no resolver orders certificates, and reaches KMS only when there is ACME state to seal. Adoption covers ONE store. It admits the first unsealed document and the same document again — a store is read more than once before the write that seals it lands, and a replica that cannot write reads it every poll — but not a different one. A seal left adopting was taking whatever plaintext it was handed for as long as the process ran. The setting's comment now says what it does: it writes, it covers one store, and it adopts again after a restart, which is why it is removed once it has done its work. One spelling for the KMS credential. The bare IAM_CLIENT_ID fallback undid the reason the names are prefixed — a pod carrying another subsystem's IAM client had it picked up here. k8s/hanzo/deployment.yaml and service.yaml are deleted. They described a hostNetwork DaemonSet named hanzo-ingress binding hostPort 80 and 443, while what runs in that cluster is a Deployment named ingress declared in universe. `kubectl apply -f k8s/hanzo/` appears in this README and in the sibling gateway's Makefile, so the file was one command from taking :80 and :443 on every node beside the workload that already had them. Universe is the one place that says what runs; the cluster-level objects stay here. The OTLP endpoint paths in the docs and the vendored Knative fixture are restored: /v1/metrics is the OpenTelemetry receiver path, not a route of ours.
This commit is contained in:
@@ -24,3 +24,6 @@ hanzo-ingress
|
||||
AGENTS.md
|
||||
GEMINI.md
|
||||
QWEN.md
|
||||
|
||||
# the built binary
|
||||
/ingress
|
||||
|
||||
@@ -437,12 +437,21 @@ kinds and they fail differently:
|
||||
Neither is a fleet-wide outage. The DaemonSet one is a real sustained
|
||||
single-node outage and is the reason to check first.
|
||||
|
||||
**The manifests here are not the deployed shape.** `k8s/lux/deployment.yaml`
|
||||
matches what runs in `lux-system` (DaemonSet `hanzo-ingress`).
|
||||
`k8s/hanzo/deployment.yaml` is a DaemonSet named `hanzo-ingress` in namespace
|
||||
`hanzo`, and what runs there is a Deployment named `ingress`. Reconciling those
|
||||
two is its own change; applying this file as-is to hanzo creates a second
|
||||
workload rather than updating the one that is there.
|
||||
**Only lux has a workload manifest here.** `k8s/lux/deployment.yaml` matches
|
||||
what runs in `lux-system` (DaemonSet `hanzo-ingress`). Hanzo's workload is
|
||||
declared in `hanzoai/universe` (`infra/k8s/ingress/`, Deployment `ingress`) and
|
||||
`k8s/hanzo/` carries only the cluster-level objects it uses — RBAC, the
|
||||
IngressClass, the `security-headers` Middleware.
|
||||
|
||||
The Deployment and Service that used to be in `k8s/hanzo/` described a
|
||||
different workload from the one that runs: a `hostNetwork` DaemonSet named
|
||||
`hanzo-ingress` binding `hostPort` 80 and 443. `kubectl apply -f k8s/hanzo/` is
|
||||
in this repo's README and in the sibling gateway's Makefile, so that file was
|
||||
one command away from taking :80 and :443 on every node in the cluster, beside
|
||||
the Deployment that already had them, each pod with its own node-local ACME
|
||||
store ordering for the same domains. It is deleted rather than corrected:
|
||||
universe is the one place that says what runs, and a second copy here is a
|
||||
second answer.
|
||||
|
||||
**Adoption.** A first boot against an empty `data` volume needs nothing: the
|
||||
store is written sealed from its first write. `INGRESS_ACME_ADOPT` is only for a
|
||||
|
||||
@@ -61,12 +61,17 @@ Deployed on the `hanzo-k8s` cluster as the default IngressClass (`ingress`), it
|
||||
|
||||
### Kubernetes
|
||||
|
||||
The workload is declared in `hanzoai/universe`
|
||||
(`infra/k8s/ingress/`) and reconciled by the operator, so it is not applied
|
||||
from here. What this repository carries is the cluster-level objects the
|
||||
workload uses:
|
||||
|
||||
```bash
|
||||
# From a clone -- RBAC, IngressClass, Deployment, Service, middlewares
|
||||
# RBAC, IngressClass, middlewares
|
||||
kubectl apply -f k8s/hanzo/
|
||||
|
||||
# Verify
|
||||
kubectl -n hanzo get pods -l app=hanzo-ingress
|
||||
kubectl -n hanzo get pods -l app.kubernetes.io/name=ingress
|
||||
```
|
||||
|
||||
The manifests are not fetchable by URL: this repository is private, so
|
||||
@@ -208,10 +213,14 @@ Any Ingress resource without an explicit `ingressClassName` is automatically pic
|
||||
k8s/hanzo/
|
||||
rbac.yaml # ServiceAccount, ClusterRole, ClusterRoleBinding
|
||||
ingressclass.yaml # IngressClass "ingress" (default)
|
||||
deployment.yaml # 2 replicas, hostNetwork, ports 80/443
|
||||
service.yaml # LoadBalancer service
|
||||
middlewares.yaml # security-headers Middleware CRD
|
||||
```
|
||||
|
||||
The Deployment and its Service are declared in `hanzoai/universe`
|
||||
(`infra/k8s/ingress/`), which is the one place that says what runs. A copy
|
||||
here would be a second answer to that question, and the copy that was here
|
||||
described a different workload than the one the cluster runs.
|
||||
|
||||
### Creating Ingress Resources
|
||||
|
||||
Once deployed, create standard Kubernetes Ingress resources to route traffic:
|
||||
@@ -263,11 +272,14 @@ Hanzo Ingress runs on the `hanzo-k8s` DOKS cluster (`24.199.76.156`) as the sole
|
||||
|
||||
### Deploy / Update
|
||||
|
||||
The image tag is pinned in `hanzoai/universe` and rolled by the operator. The
|
||||
manifests here are the cluster-level objects only:
|
||||
|
||||
```bash
|
||||
kubectl --context do-sfo3-hanzo-k8s apply -f k8s/hanzo/
|
||||
|
||||
# Verify pods
|
||||
kubectl --context do-sfo3-hanzo-k8s -n hanzo get pods -l app=hanzo-ingress
|
||||
kubectl --context do-sfo3-hanzo-k8s -n hanzo get pods -l app.kubernetes.io/name=ingress
|
||||
|
||||
# Check service
|
||||
kubectl --context do-sfo3-hanzo-k8s -n hanzo get svc hanzo-ingress
|
||||
|
||||
@@ -186,8 +186,9 @@ func setupServer(staticConfiguration *static.Configuration) (*server.Server, err
|
||||
//
|
||||
// The edge's own secrets are read here, before anything ACME is built: the
|
||||
// sealing key the state is written under and the DNS credential the
|
||||
// challenge is answered with are both inputs to the provider below.
|
||||
seal := edgeSecrets()
|
||||
// challenge is answered with are both inputs to the provider below. An edge
|
||||
// with no ACME resolver reads none of them.
|
||||
seal := edgeSecrets(staticConfiguration)
|
||||
|
||||
tlsManager := ingresstls.NewManager(staticConfiguration.OCSP)
|
||||
routinesPool.GoCtx(tlsManager.Run)
|
||||
|
||||
+25
-4
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/ingress/pkg/config/static"
|
||||
"github.com/hanzoai/ingress/pkg/kms"
|
||||
"github.com/hanzoai/ingress/pkg/provider/acme"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -38,9 +39,10 @@ const reach = 30 * time.Second
|
||||
// store that predates sealing keeps its certificates instead of re-ordering
|
||||
// every one of them against a rate limit.
|
||||
//
|
||||
// It does its work once and then does nothing: after that boot the store IS
|
||||
// sealed, so the unsealed path is never reached again. It says so at warn on
|
||||
// every boot it is set, so it is not left on quietly.
|
||||
// It admits ONE store — the one the operator was looking at — and adoption is
|
||||
// not a read: it writes that store back under seal. Left set, it adopts again
|
||||
// after every restart, whatever plaintext is on the disk at the time. So it is
|
||||
// set for one boot and removed, and it says so at warn on every boot it is set.
|
||||
const envAdopt = "INGRESS_ACME_ADOPT"
|
||||
|
||||
// edgeSeal is how the ACME state reaches its store.
|
||||
@@ -160,7 +162,15 @@ func unset(name string) {
|
||||
// edgeSecrets is the one call that reads everything this process needs from
|
||||
// KMS. It runs before the ACME provider is built, because both of its results
|
||||
// are inputs to it.
|
||||
func edgeSecrets() acme.Seal {
|
||||
func edgeSecrets(c *static.Configuration) acme.Seal {
|
||||
if !ordering(c) {
|
||||
// Nothing here orders a certificate, so there is no ACME state to seal
|
||||
// and no challenge to answer. Reading KMS anyway would make it a
|
||||
// condition of serving every route this edge has — the ones whose
|
||||
// certificates come from a Kubernetes Secret, and the plain-HTTP ones
|
||||
// — for a subsystem that is not running.
|
||||
return acme.Plain()
|
||||
}
|
||||
client, err := kms.FromEnv()
|
||||
if err != nil {
|
||||
// A configuration error does not heal, so it is not retried.
|
||||
@@ -169,3 +179,14 @@ func edgeSecrets() acme.Seal {
|
||||
loadDNSCredential(client, reach)
|
||||
return edgeSeal(client, reach)
|
||||
}
|
||||
|
||||
// ordering reports whether any certificate resolver orders certificates. It is
|
||||
// the one question that decides whether this edge needs its own secrets at all.
|
||||
func ordering(c *static.Configuration) bool {
|
||||
for _, r := range c.CertificatesResolvers {
|
||||
if r.ACME != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/ingress/pkg/config/static"
|
||||
"github.com/hanzoai/ingress/pkg/kms"
|
||||
"github.com/hanzoai/ingress/pkg/provider/acme"
|
||||
)
|
||||
|
||||
// Every way a Cloudflare credential can reach lego: the variable, and the
|
||||
@@ -90,3 +92,46 @@ func assertSupplied(t *testing.T) {
|
||||
t.Errorf("%s = %q; a token appeared without one being read", envCFToken, got)
|
||||
}
|
||||
}
|
||||
|
||||
// An edge with no ACME resolver reads no secrets. Its certificates come from
|
||||
// Kubernetes Secrets and some of its routes are plain HTTP; making KMS a
|
||||
// condition of serving them would take the whole edge down on a node whenever
|
||||
// KMS is unreachable during a restart.
|
||||
func TestEdgeSecrets_NoResolverReadsNothing(t *testing.T) {
|
||||
fill(t)
|
||||
// Configured for KMS, and pointed at nothing that answers: reaching it
|
||||
// would fail, and reaching it at all is the thing being ruled out.
|
||||
t.Setenv("INGRESS_KMS_ENDPOINT", "https://127.0.0.1:1")
|
||||
t.Setenv("INGRESS_KMS_CLIENT_ID", "ingress")
|
||||
t.Setenv("INGRESS_KMS_CLIENT_SECRET", "shhh")
|
||||
|
||||
start := time.Now()
|
||||
seal := edgeSecrets(&static.Configuration{})
|
||||
if took := time.Since(start); took > time.Second {
|
||||
t.Errorf("an edge with no resolver spent %v reaching KMS", took)
|
||||
}
|
||||
if _, ok := seal.(*kms.Seal); ok {
|
||||
t.Error("an edge with no resolver built a seal from KMS")
|
||||
}
|
||||
assertSupplied(t)
|
||||
}
|
||||
|
||||
// One resolver that orders certificates is enough to need them.
|
||||
func TestOrdering(t *testing.T) {
|
||||
if ordering(&static.Configuration{}) {
|
||||
t.Error("a configuration with no resolver was read as ordering certificates")
|
||||
}
|
||||
none := &static.Configuration{CertificatesResolvers: map[string]static.CertificateResolver{
|
||||
"tailscale": {},
|
||||
}}
|
||||
if ordering(none) {
|
||||
t.Error("a resolver that is not ACME was read as ordering certificates")
|
||||
}
|
||||
some := &static.Configuration{CertificatesResolvers: map[string]static.CertificateResolver{
|
||||
"tailscale": {},
|
||||
"letsencrypt": {ACME: &acme.Configuration{}},
|
||||
}}
|
||||
if !ordering(some) {
|
||||
t.Error("an ACME resolver was not read as ordering certificates")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,17 +221,17 @@ URL of the OpenTelemetry Collector to send metrics to.
|
||||
metrics:
|
||||
otlp:
|
||||
http:
|
||||
endpoint: https://collector:4318/ext/metrics
|
||||
endpoint: https://collector:4318/v1/metrics
|
||||
```
|
||||
|
||||
```toml tab="File (TOML)"
|
||||
[metrics]
|
||||
[metrics.otlp.http]
|
||||
endpoint = "https://collector:4318/ext/metrics"
|
||||
endpoint = "https://collector:4318/v1/metrics"
|
||||
```
|
||||
|
||||
```bash tab="CLI"
|
||||
--metrics.otlp.http.endpoint=https://collector:4318/ext/metrics
|
||||
--metrics.otlp.http.endpoint=https://collector:4318/v1/metrics
|
||||
```
|
||||
|
||||
#### `headers`
|
||||
|
||||
@@ -25,12 +25,12 @@ To enable metrics in Hanzo Ingress, you need to configure the metrics provider i
|
||||
metrics:
|
||||
otlp:
|
||||
http:
|
||||
endpoint: http://myotlpcollector:4318/ext/metrics
|
||||
endpoint: http://myotlpcollector:4318/v1/metrics
|
||||
```
|
||||
|
||||
```toml tab="Structured (TOML)"
|
||||
[metrics.otlp.http]
|
||||
endpoint = "http://myotlpcollector:4318/ext/metrics"
|
||||
endpoint = "http://myotlpcollector:4318/v1/metrics"
|
||||
```
|
||||
|
||||
```yaml tab="Helm Chart Values"
|
||||
@@ -43,7 +43,7 @@ metrics:
|
||||
enabled: true
|
||||
http:
|
||||
enabled: true
|
||||
endpoint: http://myotlpcollector:4318/ext/metrics
|
||||
endpoint: http://myotlpcollector:4318/v1/metrics
|
||||
```
|
||||
|
||||
## Per-Router Metrics
|
||||
|
||||
@@ -8593,7 +8593,7 @@ data:
|
||||
#
|
||||
# When the protocol is prometheus the endpoint can accept a 'host:port' string to customize the
|
||||
# listening host interface and port.
|
||||
request-metrics-endpoint: http://promstack-kube-prometheus-prometheus.observability:9090/api/v1/otlp/ext/metrics
|
||||
request-metrics-endpoint: http://promstack-kube-prometheus-prometheus.observability:9090/api/v1/otlp/v1/metrics
|
||||
|
||||
# request-metrics-export-interval specifies the global metrics reporting period for the queue-proxy.
|
||||
#
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
name: hanzo-ingress
|
||||
namespace: hanzo
|
||||
labels:
|
||||
app: hanzo-ingress
|
||||
app.kubernetes.io/name: hanzo-ingress
|
||||
app.kubernetes.io/part-of: hanzo-platform
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: hanzo-ingress
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: hanzo-ingress
|
||||
spec:
|
||||
serviceAccountName: hanzo-ingress
|
||||
imagePullSecrets:
|
||||
- name: ghcr-pull-secret
|
||||
hostNetwork: true
|
||||
dnsPolicy: ClusterFirstWithHostNet
|
||||
# No cloud-specific nodeSelector — runs on any K8s cluster
|
||||
containers:
|
||||
- name: ingress
|
||||
image: ghcr.io/hanzoai/ingress:1.7.41
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
# Providers — watches Ingress resources with class "ingress"
|
||||
- "--providers.kubernetesingress=true"
|
||||
- "--providers.kubernetesingress.ingressclass=ingress"
|
||||
- "--providers.kubernetesingress.allowemptyservices=true"
|
||||
- "--providers.kubernetesingress.allowexternalnameservices=true"
|
||||
- "--providers.kubernetescrd=true"
|
||||
- "--providers.kubernetescrd.allowcrossnamespace=true"
|
||||
- "--providers.kubernetescrd.allowexternalnameservices=true"
|
||||
- "--providers.kubernetescrd.allowemptyservices=true"
|
||||
# Entrypoints
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.to=:443"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.permanent=true"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
- "--entrypoints.websecure.http.tls=true"
|
||||
- "--entrypoints.websecure.http.tls.certresolver=letsencrypt"
|
||||
# Default security headers on all HTTPS routes
|
||||
- "--entrypoints.websecure.http.middlewares=security-headers@kubernetescrd"
|
||||
# ACME / Let's Encrypt via Cloudflare DNS-01
|
||||
- "[email protected]"
|
||||
- "--certificatesresolvers.letsencrypt.acme.storage=/data/acme.json"
|
||||
- "--certificatesresolvers.letsencrypt.acme.dnschallenge=true"
|
||||
- "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare"
|
||||
- "--certificatesresolvers.letsencrypt.acme.dnschallenge.resolvers=1.1.1.1:53,8.8.8.8:53"
|
||||
# Health + observability
|
||||
- "--ping=true"
|
||||
- "--ping.entryPoint=web"
|
||||
- "--api.dashboard=false"
|
||||
- "--log.level=INFO"
|
||||
- "--accesslog=true"
|
||||
- "--accesslog.fields.defaultmode=keep"
|
||||
- "--accesslog.fields.headers.defaultmode=drop"
|
||||
# The key /data/acme.json is sealed under comes from KMS — it is never
|
||||
# in this file and never in a Kubernetes Secret:
|
||||
#
|
||||
# ingress/acme-seal 32 bytes, AES-256-GCM, seals the ACME state
|
||||
#
|
||||
# Reading it needs one credential of its own, and that one IS a Secret,
|
||||
# because it is what opens KMS. It reads one path, in one org, and
|
||||
# nothing else. The reference is REQUIRED: this pod is configured for
|
||||
# KMS, so a missing credential is a missing configuration and the
|
||||
# kubelet naming the absent Secret is the clearest way to say so.
|
||||
# Materialise it with a KMSSecret before applying this manifest.
|
||||
#
|
||||
# The Cloudflare credential the DNS-01 challenge is answered with stays
|
||||
# where the cluster already holds it. Moving it to a zone-scoped token
|
||||
# is its own change: it swaps the mechanism lego authenticates with,
|
||||
# and lego prefers the account pair over any token, so the two do not
|
||||
# coexist. The reference is optional because a deployment that supplies
|
||||
# the credential another way should still start.
|
||||
env:
|
||||
- name: INGRESS_KMS_ENDPOINT
|
||||
value: "https://kms.hanzo.ai"
|
||||
- name: INGRESS_KMS_ORG
|
||||
value: "hanzo"
|
||||
- name: INGRESS_KMS_PATH
|
||||
value: "ingress"
|
||||
- name: INGRESS_KMS_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ingress-kms
|
||||
key: clientId
|
||||
- name: INGRESS_KMS_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ingress-kms
|
||||
key: clientSecret
|
||||
- name: CF_API_EMAIL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: cloudflare-api-credentials
|
||||
key: EMAIL
|
||||
optional: true
|
||||
- name: CF_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: cloudflare-api-credentials
|
||||
key: APIKEY
|
||||
optional: true
|
||||
ports:
|
||||
- name: web
|
||||
containerPort: 80
|
||||
hostPort: 80
|
||||
- name: websecure
|
||||
containerPort: 443
|
||||
hostPort: 443
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /ping
|
||||
port: 80
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ping
|
||||
port: 80
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 512Mi
|
||||
securityContext:
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
runAsNonRoot: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
add: ["NET_BIND_SERVICE"]
|
||||
drop: ["ALL"]
|
||||
volumes:
|
||||
# /data/acme.json holds the ACME account key and every leaf private key
|
||||
# this node has been issued. It is sealed (AES-256-GCM under a key read
|
||||
# from KMS at startup and held only in memory), so the file on the node
|
||||
# is inert: node access alone no longer yields a certificate for any
|
||||
# domain the estate serves.
|
||||
#
|
||||
# Still node-local, and that is a separate problem: a DaemonSet means one
|
||||
# store per node and one ACME order per node. ACME_SHARED_STORE_NAMESPACE
|
||||
# moves the state to a single Kubernetes object with one elected writer;
|
||||
# the seal applies there too.
|
||||
- name: data
|
||||
hostPath:
|
||||
path: /var/lib/hanzo-ingress
|
||||
type: DirectoryOrCreate
|
||||
@@ -1,18 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: hanzo-ingress
|
||||
namespace: hanzo
|
||||
labels:
|
||||
app: hanzo-ingress
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: web
|
||||
- name: https
|
||||
port: 443
|
||||
targetPort: websecure
|
||||
selector:
|
||||
app: hanzo-ingress
|
||||
+5
-4
@@ -32,8 +32,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Env names. The prefix is this service's, so a pod that also carries another
|
||||
// subsystem's KMS credentials cannot have them picked up here by accident.
|
||||
// Env names. The prefix is this service's, and it is the ONLY spelling: a pod
|
||||
// that also carries another subsystem's KMS credentials cannot have them picked
|
||||
// up here by accident, which a bare fallback spelling would undo.
|
||||
const (
|
||||
envEndpoint = "INGRESS_KMS_ENDPOINT"
|
||||
envClientID = "INGRESS_KMS_CLIENT_ID"
|
||||
@@ -83,8 +84,8 @@ func FromEnv() (*Client, error) {
|
||||
if !strings.HasPrefix(endpoint, "https://") {
|
||||
return nil, fmt.Errorf("kms: %s is %q; the endpoint carries a client secret and must be https", envEndpoint, endpoint)
|
||||
}
|
||||
id := firstOf(os.Getenv(envClientID), os.Getenv("IAM_CLIENT_ID"))
|
||||
secret := firstOf(os.Getenv(envSecret), os.Getenv("IAM_CLIENT_SECRET"))
|
||||
id := strings.TrimSpace(os.Getenv(envClientID))
|
||||
secret := strings.TrimSpace(os.Getenv(envSecret))
|
||||
if id == "" || secret == "" {
|
||||
return nil, fmt.Errorf("kms: %s is set but %s/%s are empty", envEndpoint, envClientID, envSecret)
|
||||
}
|
||||
|
||||
+14
-3
@@ -150,17 +150,28 @@ func TestFromEnv_HalfConfiguredIsAnError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEnv_FallsBackToIAMCredentials(t *testing.T) {
|
||||
// One spelling for the credential. A pod that also carries another subsystem's
|
||||
// IAM client must not have it picked up here.
|
||||
func TestFromEnv_TakesOnlyItsOwnCredential(t *testing.T) {
|
||||
t.Setenv(envEndpoint, "https://kms.hanzo.ai")
|
||||
t.Setenv(envClientID, "")
|
||||
t.Setenv(envSecret, "")
|
||||
t.Setenv("IAM_CLIENT_ID", "ingress")
|
||||
t.Setenv("IAM_CLIENT_SECRET", "shhh")
|
||||
t.Setenv("IAM_CLIENT_ID", "someone-elses")
|
||||
t.Setenv("IAM_CLIENT_SECRET", "someone-elses")
|
||||
if _, err := FromEnv(); err == nil {
|
||||
t.Error("FromEnv used a credential that was not addressed to it")
|
||||
}
|
||||
|
||||
t.Setenv(envClientID, "ingress")
|
||||
t.Setenv(envSecret, "shhh")
|
||||
t.Setenv(envOrg, "lux")
|
||||
c, err := FromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.clientID != "ingress" {
|
||||
t.Errorf("clientID = %q, want ingress", c.clientID)
|
||||
}
|
||||
if c.Org() != "lux" {
|
||||
t.Errorf("org = %q, want lux", c.Org())
|
||||
}
|
||||
|
||||
+36
-6
@@ -13,6 +13,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Seal encrypts the ACME state document at rest.
|
||||
@@ -38,9 +39,12 @@ import (
|
||||
// editing its count all fail to open rather than opening into something the
|
||||
// ACME provider would act on.
|
||||
type Seal struct {
|
||||
kek cipher.AEAD
|
||||
id string
|
||||
kek cipher.AEAD
|
||||
id string
|
||||
|
||||
mu sync.Mutex
|
||||
adopt bool
|
||||
taken *[32]byte // the one unsealed document this seal admitted
|
||||
}
|
||||
|
||||
// envelope is the sealed document as written. It is JSON so that a file which
|
||||
@@ -66,9 +70,9 @@ var ErrUnsealed = errors.New("kms: the stored document is not sealed")
|
||||
// adopt says what to do with a store that is not sealed. False — the default
|
||||
// everywhere — reads it as an error: the store this process was pointed at is
|
||||
// not the store this key wrote, and continuing would mean writing this edge's
|
||||
// account key over whatever is there. True opens it once and hands it back for
|
||||
// the caller to write under seal, which is how a store that predates sealing
|
||||
// keeps its certificates instead of re-ordering every one of them.
|
||||
// account key over whatever is there. True opens ONE such store and hands it
|
||||
// back for the caller to write under seal, which is how a store that predates
|
||||
// sealing keeps its certificates instead of re-ordering every one of them.
|
||||
func NewSeal(key []byte, adopt bool) (*Seal, error) {
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("kms: seal key is %d bytes, want 32", len(key))
|
||||
@@ -175,7 +179,7 @@ func (s *Seal) Wrap(name string, count uint64, plain []byte) ([]byte, error) {
|
||||
func (s *Seal) Unwrap(name string, stored []byte) (plain []byte, count uint64, sealed bool, err error) {
|
||||
env, ok := parse(stored)
|
||||
if !ok {
|
||||
if !s.adopt {
|
||||
if !s.take(stored) {
|
||||
return nil, 0, false, ErrUnsealed
|
||||
}
|
||||
return stored, 0, false, nil
|
||||
@@ -214,6 +218,32 @@ func (s *Seal) Unwrap(name string, stored []byte) (plain []byte, count uint64, s
|
||||
return doc, env.Count, true, nil
|
||||
}
|
||||
|
||||
// take spends the operator's adoption on ONE document.
|
||||
//
|
||||
// The first unsealed document is admitted and remembered. That same document is
|
||||
// admitted again, because a store is read more than once before the write that
|
||||
// seals it lands and a replica that cannot write reads it on every poll. A
|
||||
// DIFFERENT unsealed document is not: adoption covers the store the operator
|
||||
// was looking at, not every unsealed store this process is handed for as long
|
||||
// as it runs.
|
||||
//
|
||||
// The memory is this seal's, so it lasts one process. A seal built to adopt
|
||||
// will adopt again after a restart — which is why the setting is removed once
|
||||
// it has done its work, and why it announces itself on every boot it is set.
|
||||
func (s *Seal) take(stored []byte) bool {
|
||||
sum := sha256.Sum256(stored)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.adopt {
|
||||
return false
|
||||
}
|
||||
if s.taken == nil {
|
||||
s.taken = &sum
|
||||
return true
|
||||
}
|
||||
return *s.taken == sum
|
||||
}
|
||||
|
||||
// bind renders the facts the ciphertext is tied to. Each variable-length field
|
||||
// is written after its length, so no combination of a store name and a key id
|
||||
// can produce the bytes of another combination.
|
||||
|
||||
@@ -138,6 +138,31 @@ func TestSeal_AdoptsAnUnsealedStoreOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Adoption covers ONE store. A second, different unsealed document is not the
|
||||
// store the operator was looking at, and it is refused — otherwise a seal left
|
||||
// adopting takes whatever plaintext it is handed for as long as the process
|
||||
// runs.
|
||||
func TestSeal_AdoptsOneStoreOnly(t *testing.T) {
|
||||
s, err := NewSeal(key(t), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := []byte(acmeLike)
|
||||
other := []byte(`{"letsencrypt":{"Account":{"Email":"[email protected]"}}}`)
|
||||
|
||||
if _, _, _, err := s.Unwrap(store, first); err != nil {
|
||||
t.Fatalf("the first unsealed store was refused: %v", err)
|
||||
}
|
||||
// The same document again: a store is read more than once before the write
|
||||
// that seals it lands, and a replica that cannot write reads it every poll.
|
||||
if _, _, _, err := s.Unwrap(store, first); err != nil {
|
||||
t.Errorf("re-reading the adopted store was refused: %v", err)
|
||||
}
|
||||
if _, _, _, err := s.Unwrap(store, other); !errors.Is(err, ErrUnsealed) {
|
||||
t.Fatalf("a second, different unsealed store was adopted: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A document belongs to the store it was written for. Moving one between
|
||||
// stores does not open it, because the store's name is what both layers were
|
||||
// sealed with and not merely what the JSON says.
|
||||
|
||||
Reference in New Issue
Block a user