Skip to content

Commit 04aacdd

Browse files
kvapsclaude
andauthored
fix(rest,satellite): encryption create-passphrase unlocks LUKS provisioning (#143)
* fix(rest): LUKS gate accepts master passphrase from encryption Secret The LUKS RD-create gate (Bug 95) only consulted the legacy DrbdOptions/EncryptPassphrase controller prop, so the upstream-standard flow - linstor encryption create-passphrase followed by creating a LUKS-layered RD - failed with 400, and the error hint told operators to store a plaintext passphrase in a controller prop. Hoist the Secret access into a shared pkg/passphrase package and make the gate consult the encryption Secret first (primary, upstream-parity), keeping the legacy prop working for backward compatibility. The refusal hint now leads with encryption create-passphrase and marks the prop path deprecated. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com> * fix(satellite): deliver Secret-stored master passphrase to the LUKS layer The satellite lifts the LUKS key onto the LuksPassphrase wire prop only from the controller-scope props, so a cluster whose passphrase lives solely in the encryption Secret passed the RD-create gate but looped on 'Props.LuksPassphrase empty' at apply time. Fold the Secret value into the resolved effective props under the canonical DrbdOptions/EncryptPassphrase key before BuildDesired, so the passphrase travels the exact channel the legacy prop used (lifted to LuksPassphrase, stripped from rendered DRBD options). Operator-set props keep precedence so existing volumes keep their key. The Secret is read through the uncached APIReader in the controller namespace (new --controller-namespace flag, default $POD_NAMESPACE then blockstor-system); satellite RBAC already grants secrets read. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com> * test: pin Secret-passphrase LUKS unlock at gate and dispatch L1 regression suite for the encryption-Secret flow: REST gate accepts LUKS RD creation after create-passphrase (or a directly seeded Secret), keeps the legacy controller-prop path working, and hints at create-passphrase on refusal; satellite-side injection delivers the Secret value to the LuksPassphrase wire prop with legacy-prop precedence, custom PassphraseSecretRef resolution, and safe no-ops for non-LUKS stacks and missing Secrets. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com> --------- Signed-off-by: Andrei Kvapil <kvapss@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1728baf commit 04aacdd

13 files changed

Lines changed: 817 additions & 74 deletions

File tree

.golangci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,9 @@ linters:
369369
- linters:
370370
- godox
371371
path: pkg/rest/
372+
- linters:
373+
- godox
374+
path: pkg/passphrase/
372375
- linters:
373376
- godox
374377
path: pkg/satellite/

cmd/satellite/main.go

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,10 @@ func main() {
5858
// as the os.Exit call.
5959
func run() int {
6060
var (
61-
nodeName string
62-
stateDir string
63-
probeAddr string
61+
nodeName string
62+
stateDir string
63+
probeAddr string
64+
controllerNamespace string
6465
)
6566

6667
flag.StringVar(&nodeName, "node-name", os.Getenv("NODE_NAME"),
@@ -69,9 +70,13 @@ func run() int {
6970
"directory the satellite uses to persist DRBD .res files and per-resource state")
7071
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081",
7172
"The address the /healthz and /readyz probe endpoints bind to.")
73+
flag.StringVar(&controllerNamespace, "controller-namespace", "",
74+
"Namespace where the controller's Secrets (cluster master passphrase) live (default: $POD_NAMESPACE, then 'blockstor-system').")
7275

7376
flag.Parse()
7477

78+
controllerNamespace = resolveControllerNamespace(controllerNamespace)
79+
7580
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
7681
slog.SetDefault(logger)
7782

@@ -193,7 +198,7 @@ func run() int {
193198
LocalAddress: localAddress,
194199
Logger: logger,
195200
RESTConfig: restCfg,
196-
ManagerFactory: mgrFactory(ready, logger, ueventListener),
201+
ManagerFactory: mgrFactory(ready, logger, ueventListener, controllerNamespace),
197202
HealthProbeBindAddress: probeAddr,
198203
OnCacheSynced: func() {
199204
logger.Info("satellite cache sync complete, marking ready")
@@ -240,13 +245,18 @@ func loadRESTConfig() (*rest.Config, error) {
240245
// The factory shape lets the agent stay ignorant of the readyState
241246
// + logger plumbing — it only sees the standard
242247
// satellite.ManagerFactory signature.
243-
func mgrFactory(ready *readyState, logger *slog.Logger, ueventListener controllers.UeventNotifier) satellite.ManagerFactory {
248+
func mgrFactory(ready *readyState, logger *slog.Logger, ueventListener controllers.UeventNotifier, controllerNamespace string) satellite.ManagerFactory {
244249
return func(restCfg *rest.Config, nodeName, probeAddr string, rec *satellite.Reconciler) (manager.Manager, error) {
245250
mgr, err := controllers.NewManager(restCfg, &controllers.Config{
246251
NodeName: nodeName,
247252
Apply: rec,
248253
Exec: storage.RealExec{},
249254
HealthProbeBindAddress: probeAddr,
255+
// Bug 023: where the cluster master passphrase Secret
256+
// lives — the ResourceReconciler reads it so LUKS RDs
257+
// provisioned via `linstor encryption create-passphrase`
258+
// receive their key (see controllers/luks_passphrase.go).
259+
Namespace: controllerNamespace,
250260
// Optional: nil when `uevent.New` failed at startup —
251261
// PhysicalDeviceDiscoveryRunnable falls back to pure-
252262
// polling discovery.
@@ -630,3 +640,22 @@ func replicationStateIsLive(s string) bool {
630640
return true
631641
}
632642
}
643+
644+
// resolveControllerNamespace picks the namespace the controller's
645+
// Secrets (cluster master passphrase) live in. Precedence: explicit
646+
// flag value wins, then $POD_NAMESPACE (the standard downward-API
647+
// env var — the satellite DaemonSet deploys into the same namespace
648+
// as the controller), then the kustomize default `blockstor-system`.
649+
// Mirrors cmd/controller/main.go's resolver so the two binaries
650+
// never drift on the lookup rules.
651+
func resolveControllerNamespace(flagValue string) string {
652+
if flagValue != "" {
653+
return flagValue
654+
}
655+
656+
if ns := os.Getenv("POD_NAMESPACE"); ns != "" {
657+
return ns
658+
}
659+
660+
return "blockstor-system"
661+
}

pkg/passphrase/passphrase.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
/*
4+
Copyright 2026 Cozystack contributors.
5+
6+
Licensed under the Apache License, Version 2.0 (the "License");
7+
you may not use this file except in compliance with the License.
8+
You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing, software
13+
distributed under the License is distributed on an "AS IS" BASIS,
14+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
See the License for the specific language governing permissions and
16+
limitations under the License.
17+
*/
18+
19+
// Package passphrase centralises access to the cluster master
20+
// passphrase Secret — the value `linstor encryption
21+
// create-passphrase` stamps (POST /v1/encryption/passphrase, served
22+
// by pkg/rest/encryption.go) and the LUKS layer consumes.
23+
//
24+
// Bug 023: before this package, the Secret was written by the REST
25+
// layer but never read outside it — the LUKS RD-create gate and the
26+
// satellite-facing dispatch chain only consulted the legacy
27+
// plaintext controller property `DrbdOptions/EncryptPassphrase`, so
28+
// the upstream-standard `encryption create-passphrase` →
29+
// `rd create --layer-list ...,luks,...` flow dead-ended with a hint
30+
// telling operators to put a PLAINTEXT passphrase into a controller
31+
// prop. Both the REST gate (pkg/rest) and the satellite-side
32+
// dispatch (pkg/satellite/controllers) now resolve the passphrase
33+
// through this single implementation.
34+
package passphrase
35+
36+
import (
37+
"context"
38+
39+
"github.com/cockroachdb/errors"
40+
corev1 "k8s.io/api/core/v1"
41+
apierrors "k8s.io/apimachinery/pkg/api/errors"
42+
"sigs.k8s.io/controller-runtime/pkg/client"
43+
44+
blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1"
45+
)
46+
47+
// DefaultSecretName is the Secret the controller falls back to when
48+
// ControllerConfig.Spec.PassphraseSecretRef is unset. Operators
49+
// override via the ControllerConfig CRD.
50+
const DefaultSecretName = "blockstor-cluster-passphrase"
51+
52+
// SecretKey is the data key inside the Secret carrying the cluster
53+
// passphrase. Matches the upstream-LINSTOR-on-k8s convention so
54+
// existing Secret YAML manifests continue to work.
55+
const SecretKey = "passphrase"
56+
57+
// PropKeyCanonical is upstream LINSTOR's cluster-scope master-key
58+
// property name (`linstor controller set-property
59+
// DrbdOptions/EncryptPassphrase <pass>`). Bug 023: this LEGACY path
60+
// stores the passphrase in PLAINTEXT on the ControllerConfig CRD —
61+
// kept working for backward compatibility, but the Secret written
62+
// by `encryption create-passphrase` is the primary mechanism.
63+
const PropKeyCanonical = "DrbdOptions/EncryptPassphrase"
64+
65+
// PropKeyLegacy is the early-Phase-9 per-RD alias the dispatcher
66+
// still alias-reads (Bug 265 history, pkg/dispatcher). Deprecated.
67+
const PropKeyLegacy = "DrbdOptions/Encryption/passphrase"
68+
69+
// SecretName resolves the passphrase Secret's name from the
70+
// singleton ControllerConfig, falling back to DefaultSecretName when
71+
// the ControllerConfig is absent or doesn't pin a reference. The
72+
// fallback lets operators get a working cluster without first
73+
// applying a ControllerConfig CRD.
74+
func SecretName(ctx context.Context, reader client.Reader) (string, error) {
75+
var ctrlConfig blockstoriov1alpha1.ControllerConfig
76+
77+
err := reader.Get(ctx, client.ObjectKey{Name: blockstoriov1alpha1.ControllerConfigName}, &ctrlConfig)
78+
if err != nil {
79+
if apierrors.IsNotFound(err) {
80+
return DefaultSecretName, nil
81+
}
82+
83+
return "", errors.Wrap(err, "get ControllerConfig")
84+
}
85+
86+
if ctrlConfig.Spec.PassphraseSecretRef != nil && ctrlConfig.Spec.PassphraseSecretRef.Name != "" {
87+
return ctrlConfig.Spec.PassphraseSecretRef.Name, nil
88+
}
89+
90+
return DefaultSecretName, nil
91+
}
92+
93+
// Read returns the cluster master passphrase from the encryption
94+
// Secret in the given namespace. Empty string (not an error) means
95+
// "not yet set" — the Secret is missing or carries an empty value;
96+
// that's the explicit signal the create/enter handshake relies on.
97+
func Read(ctx context.Context, reader client.Reader, namespace string) (string, error) {
98+
name, err := SecretName(ctx, reader)
99+
if err != nil {
100+
return "", err
101+
}
102+
103+
var sec corev1.Secret
104+
105+
err = reader.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, &sec)
106+
if err != nil {
107+
if apierrors.IsNotFound(err) {
108+
return "", nil
109+
}
110+
111+
return "", errors.Wrap(err, "get passphrase Secret")
112+
}
113+
114+
return string(sec.Data[SecretKey]), nil
115+
}

pkg/rest/encryption.go

Lines changed: 14 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ import (
3333
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3434
"sigs.k8s.io/controller-runtime/pkg/client"
3535

36-
blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1"
3736
apiv1 "github.com/cozystack/blockstor/pkg/api/v1"
37+
"github.com/cozystack/blockstor/pkg/passphrase"
3838
)
3939

4040
// passphraseOpTimeout bounds every Secret access from an encryption
@@ -91,13 +91,17 @@ func (r passphraseRequest) proofOfKnowledge() string {
9191

9292
// defaultPassphraseSecretName is the Secret the controller falls
9393
// back to when ControllerConfig.Spec.PassphraseSecretRef is unset.
94-
// Operators override via the ControllerConfig CRD.
95-
const defaultPassphraseSecretName = "blockstor-cluster-passphrase"
94+
// Operators override via the ControllerConfig CRD. Canonical home:
95+
// pkg/passphrase (Bug 023 hoisted the Secret access there so the
96+
// LUKS RD-create gate and the satellite-side dispatch read the same
97+
// Secret this file writes); aliased locally for the existing
98+
// REST-side callers.
99+
const defaultPassphraseSecretName = passphrase.DefaultSecretName
96100

97101
// passphraseSecretKey is the data key inside the Secret carrying
98102
// the cluster passphrase. Matches the upstream-LINSTOR-on-k8s
99103
// convention so existing Secret YAML manifests continue to work.
100-
const passphraseSecretKey = "passphrase"
104+
const passphraseSecretKey = passphrase.SecretKey
101105

102106
// registerEncryption wires `linstor encryption *` endpoints. The
103107
// cluster passphrase is the master key used to encrypt LUKS volume
@@ -638,35 +642,21 @@ func (s *Server) writePassphrase(ctx context.Context, value string) error {
638642
// readPassphraseSecret reads the passphrase from a native Secret.
639643
// Empty string for "Secret missing" is the explicit signal upstream
640644
// uses for "no passphrase set yet" so the POST→PATCH handshake
641-
// works the same as on the KV path.
645+
// works the same as on the KV path. Delegates to the shared
646+
// pkg/passphrase reader (Bug 023) so the LUKS RD-create gate and the
647+
// satellite-side dispatch resolve the exact same Secret.
642648
func (s *Server) readPassphraseSecret(ctx context.Context) (string, error) {
643-
name, err := s.resolvePassphraseSecretName(ctx)
644-
if err != nil {
645-
return "", err
646-
}
647-
648-
var sec corev1.Secret
649-
650-
err = s.Client.Get(ctx, client.ObjectKey{Namespace: s.Namespace, Name: name}, &sec)
651-
if err != nil {
652-
if apierrors.IsNotFound(err) {
653-
return "", nil
654-
}
655-
656-
return "", errors.Wrap(err, "get passphrase Secret")
657-
}
658-
659-
return string(sec.Data[passphraseSecretKey]), nil
649+
return passphrase.Read(ctx, s.Client, s.Namespace) //nolint:wrapcheck // pkg/passphrase wraps with context already
660650
}
661651

662652
// writePassphraseSecret create-or-updates the Secret. The Secret is
663653
// namespaced; pre-existing Secrets without our managed key get the
664654
// key added rather than the whole data map replaced so operators
665655
// can carry extra annotations / data fields out-of-band.
666656
func (s *Server) writePassphraseSecret(ctx context.Context, value string) error {
667-
name, err := s.resolvePassphraseSecretName(ctx)
657+
name, err := passphrase.SecretName(ctx, s.Client)
668658
if err != nil {
669-
return err
659+
return err //nolint:wrapcheck // pkg/passphrase wraps with context already
670660
}
671661

672662
var sec corev1.Secret
@@ -704,27 +694,3 @@ func (s *Server) writePassphraseSecret(ctx context.Context, value string) error
704694

705695
return nil
706696
}
707-
708-
// resolvePassphraseSecretName reads the singleton ControllerConfig
709-
// and returns the configured Secret name, falling back to the
710-
// default when the ControllerConfig is absent or doesn't pin a
711-
// reference. The fallback lets operators get a working cluster
712-
// without first applying a ControllerConfig CRD.
713-
func (s *Server) resolvePassphraseSecretName(ctx context.Context) (string, error) {
714-
var ctrlConfig blockstoriov1alpha1.ControllerConfig
715-
716-
err := s.Client.Get(ctx, client.ObjectKey{Name: blockstoriov1alpha1.ControllerConfigName}, &ctrlConfig)
717-
if err != nil {
718-
if apierrors.IsNotFound(err) {
719-
return defaultPassphraseSecretName, nil
720-
}
721-
722-
return "", errors.Wrap(err, "get ControllerConfig")
723-
}
724-
725-
if ctrlConfig.Spec.PassphraseSecretRef != nil && ctrlConfig.Spec.PassphraseSecretRef.Name != "" {
726-
return ctrlConfig.Spec.PassphraseSecretRef.Name, nil
727-
}
728-
729-
return defaultPassphraseSecretName, nil
730-
}

0 commit comments

Comments
 (0)