Skip to content

Commit 0829eda

Browse files
feat: handshake-node chart
Signed-off-by: Tripura Repalle <tripura@blinklabs.io>
1 parent 5f0b497 commit 0829eda

9 files changed

Lines changed: 239 additions & 51 deletions

File tree

charts/handshake-node/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: |
55
full node from Blink Labs. This is distinct from the JavaScript hsd chart.
66
type: application
77
version: 0.1.0
8-
appVersion: "0.1.1-rc1"
8+
appVersion: "0.2.0-rc1"
99
# 1.27+ is required for the StatefulSet .spec.persistentVolumeClaimRetentionPolicy
1010
# field that the chart renders by default. Older clusters silently drop the field.
1111
kubeVersion: ">=1.27.0-0"

charts/handshake-node/README.md

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,19 @@ PersistentVolumeClaim mounted at `/home/handshake/.handshake-node`.
2222
Security-relevant defaults:
2323

2424
- The container runs as the non-root `handshake` user baked into the upstream
25-
image with `runAsNonRoot: true`, all Linux capabilities dropped, no
26-
privilege escalation, `readOnlyRootFilesystem: true`, and the
27-
`RuntimeDefault` seccomp profile.
25+
image (UID 100 / GID 101), pinned numerically via `runAsUser` /
26+
`runAsGroup` so kubelet can enforce `runAsNonRoot: true` without resolving
27+
the image's `/etc/passwd`. All Linux capabilities are dropped, no privilege
28+
escalation, `readOnlyRootFilesystem: true`, and the `RuntimeDefault`
29+
seccomp profile.
30+
- An `init-chown-data` init container (busybox, `CAP_CHOWN` only) fixes
31+
ownership of the mounted PVC on every start. The `chown -R` runs against
32+
the mount root and its descendants but only touches owner/group — file
33+
modes (including the Brontide identity key's `0600`) are unchanged, so a
34+
pre-populated `persistence.existingClaim` is fully readable by the
35+
non-root main container. `fsGroup` is deliberately not used because
36+
kubelet's recursive `fsGroup` chmod adds group r/w to every file, which
37+
the daemon rejects for its Brontide identity key.
2838
- The peer-to-peer Service is `ClusterIP` by default. Public exposure via
2939
`NodePort` or `LoadBalancer` is an explicit operator choice.
3040
- The authenticated TLS RPC listener (port `12037`) is not exposed unless
@@ -35,6 +45,10 @@ Security-relevant defaults:
3545
- The Prometheus metrics endpoint (`12039`) and Stratum server (`12040`) are
3646
disabled by default. When enabled they are kept on private `ClusterIP`
3747
Services.
48+
- Supported `network` values are `main` and `regtest`. The upstream
49+
`handshake-node` binary does not implement `--testnet` or `--simnet`; the
50+
chart rejects those values at render time to avoid a `CreateContainerError`
51+
loop from an unknown flag.
3852

3953
## Prerequisites
4054

@@ -160,6 +174,9 @@ stratum:
160174
# Optional. Derived from stratum.service.port + allowPublic when unset.
161175
# listen: ""
162176
allowPublic: true
177+
# Required whenever stratum.enabled=true (even for private/loopback binds).
178+
# handshake-node v0.2.0-rc1 refuses to start if --stratumlisten is set
179+
# without --miningaddr.
163180
miningAddress: hs1qyourhandshakeaddress
164181
auth:
165182
existingSecret: handshake-node-stratum
@@ -168,10 +185,11 @@ stratum:
168185
type: ClusterIP
169186
```
170187

171-
Public exposure requires `stratum.allowPublic=true` and both
172-
`miningAddress` and `auth.existingSecret` (validated at render time). The
173-
Stratum Service is only rendered when `stratum.allowPublic=true` because
174-
loopback listeners cannot be reached through a ClusterIP.
188+
`stratum.miningAddress` is required whenever Stratum is enabled. Public
189+
exposure additionally requires `stratum.allowPublic=true` and
190+
`stratum.auth.existingSecret` (all validated at render time). The Stratum
191+
Service is only rendered when `stratum.allowPublic=true` because loopback
192+
listeners cannot be reached through a ClusterIP.
175193

176194
## Service exposure
177195

@@ -195,6 +213,12 @@ service:
195213
type: NodePort
196214
port: 12038
197215
nodePort: 32038
216+
config:
217+
extra:
218+
# handshake-node does not derive its advertised address from the
219+
# Service; when using NodePort, set EXTERNALIP to the routable
220+
# "<node-ip>:<nodePort>" so peers can dial you back.
221+
EXTERNALIP: "203.0.113.10:32038"
198222
```
199223

200224
## Mainnet operations examples
@@ -204,7 +228,7 @@ Full mainnet node with private RPC, metrics enabled, and a beefier PVC:
204228
```yaml
205229
network: main
206230
image:
207-
tag: "0.1.1-rc1"
231+
tag: "0.2.0-rc1"
208232
209233
persistence:
210234
size: 400Gi
@@ -215,8 +239,8 @@ rpc:
215239
existingSecret: handshake-node-rpc
216240
217241
config:
218-
# Required whenever rpc.enabled=true. Substitute the Pod CIDR of the
219-
# workloads that should be allowed to call RPC.
242+
# Required whenever rpc.enabled=true. Substitute the observed client
243+
# source CIDR (Pod CIDR unless CNI SNAT changes it to node IPs).
220244
rpcallowip: "<trusted-rpc-client-cidr>"
221245
222246
metrics:
@@ -262,8 +286,8 @@ See [`values.yaml`](values.yaml) for the full list of tunables. Key knobs:
262286
| Key | Description | Default |
263287
| -------------------------------- | ------------------------------------------------------------ | ------------------------------------ |
264288
| `image.repository` | Image name | `ghcr.io/blinklabs-io/handshake-node` |
265-
| `image.tag` | Image tag (never `latest`) | `0.1.1-rc1` |
266-
| `network` | Handshake network: `main`, `regtest`, `simnet`, `testnet` | `main` |
289+
| `image.tag` | Image tag (never `latest`) | `0.2.0-rc1` |
290+
| `network` | Handshake network: `main`, `regtest` | `main` |
267291
| `persistence.size` | PVC size | `200Gi` |
268292
| `persistence.storageClass` | StorageClass name | cluster default |
269293
| `rpc.enabled` | Enable authenticated RPC | `false` |

charts/handshake-node/templates/_helpers.tpl

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,24 +31,54 @@ Chart name/version label.
3131

3232
{{/*
3333
Handshake network selector. Fails template rendering for unsupported values so
34-
labels and daemon flags cannot silently disagree.
34+
labels and daemon flags cannot silently disagree. Restricted to the networks
35+
the upstream handshake-node image actually implements as CLI flags; testnet
36+
and simnet are rejected by the daemon and therefore not offered here.
3537
*/}}
3638
{{- define "handshake-node.network" -}}
3739
{{- $n := default "main" .Values.network -}}
38-
{{- $valid := list "main" "regtest" "simnet" "testnet" -}}
40+
{{- $valid := list "main" "regtest" -}}
3941
{{- if not (has $n $valid) -}}
40-
{{- fail (printf "handshake-node: unsupported network %q. Supported: main, regtest, simnet, testnet." $n) -}}
42+
{{- fail (printf "handshake-node: unsupported network %q. Supported: main, regtest." $n) -}}
4143
{{- end -}}
4244
{{- $n -}}
4345
{{- end -}}
4446

4547
{{/*
46-
Name of the headless Service backing the StatefulSet. Reserved suffix space
47-
keeps the resulting name within the 63-char DNS label limit even with a long
48-
fullnameOverride or release name.
48+
Suffix-aware component name. Truncates the fullname so the returned value
49+
stays within the 63-character DNS label limit even when a long release name
50+
or fullnameOverride is combined with a component suffix like "-p2p".
51+
Call as: include "handshake-node.componentName" (dict "ctx" . "suffix" "-p2p")
52+
*/}}
53+
{{- define "handshake-node.componentName" -}}
54+
{{- $ctx := .ctx -}}
55+
{{- $suffix := .suffix -}}
56+
{{- $max := int (sub 63 (len $suffix)) -}}
57+
{{- printf "%s%s" (include "handshake-node.fullname" $ctx | trunc $max | trimSuffix "-") $suffix -}}
58+
{{- end -}}
59+
60+
{{/*
61+
Name of the headless Service backing the StatefulSet.
4962
*/}}
5063
{{- define "handshake-node.headlessName" -}}
51-
{{- printf "%s-headless" (include "handshake-node.fullname" . | trunc 54 | trimSuffix "-") -}}
64+
{{- include "handshake-node.componentName" (dict "ctx" . "suffix" "-headless") -}}
65+
{{- end -}}
66+
67+
{{/*
68+
Per-component Service names. Each reserves the suffix length so a maximum
69+
63-char fullname override still renders a valid Service name.
70+
*/}}
71+
{{- define "handshake-node.p2pName" -}}
72+
{{- include "handshake-node.componentName" (dict "ctx" . "suffix" "-p2p") -}}
73+
{{- end -}}
74+
{{- define "handshake-node.rpcName" -}}
75+
{{- include "handshake-node.componentName" (dict "ctx" . "suffix" "-rpc") -}}
76+
{{- end -}}
77+
{{- define "handshake-node.metricsName" -}}
78+
{{- include "handshake-node.componentName" (dict "ctx" . "suffix" "-metrics") -}}
79+
{{- end -}}
80+
{{- define "handshake-node.stratumName" -}}
81+
{{- include "handshake-node.componentName" (dict "ctx" . "suffix" "-stratum") -}}
5282
{{- end -}}
5383

5484
{{/*
@@ -66,12 +96,15 @@ handshake_network: {{ include "handshake-node.network" . }}
6696
{{- end -}}
6797

6898
{{/*
69-
Selector labels
99+
Selector labels. Deliberately excludes handshake_network because
100+
StatefulSet.spec.selector is immutable in Kubernetes; a network change on
101+
helm upgrade would fail with `spec: Forbidden`. name + instance already
102+
isolate the release. The network label is still exposed on general resource
103+
and pod metadata via handshake-node.labels for observability.
70104
*/}}
71105
{{- define "handshake-node.matchLabels" -}}
72106
app.kubernetes.io/name: {{ include "handshake-node.name" . }}
73107
app.kubernetes.io/instance: {{ .Release.Name }}
74-
handshake_network: {{ include "handshake-node.network" . }}
75108
{{- end -}}
76109

77110
{{/*

charts/handshake-node/templates/service-metrics.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
apiVersion: v1
44
kind: Service
55
metadata:
6-
name: {{ include "handshake-node.fullname" . }}-metrics
6+
name: {{ include "handshake-node.metricsName" . }}
77
labels:
88
{{- include "handshake-node.labels" . | nindent 4 }}
99
app.kubernetes.io/component: metrics

charts/handshake-node/templates/service-p2p.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
apiVersion: v1
33
kind: Service
44
metadata:
5-
name: {{ include "handshake-node.fullname" . }}-p2p
5+
name: {{ include "handshake-node.p2pName" . }}
66
labels:
77
{{- include "handshake-node.labels" . | nindent 4 }}
88
{{- with (merge (dict) .Values.service.p2p.annotations .Values.serviceAnnotations) }}

charts/handshake-node/templates/service-rpc.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
apiVersion: v1
44
kind: Service
55
metadata:
6-
name: {{ include "handshake-node.fullname" . }}-rpc
6+
name: {{ include "handshake-node.rpcName" . }}
77
labels:
88
{{- include "handshake-node.labels" . | nindent 4 }}
99
app.kubernetes.io/component: rpc

charts/handshake-node/templates/service-stratum.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
apiVersion: v1
44
kind: Service
55
metadata:
6-
name: {{ include "handshake-node.fullname" . }}-stratum
6+
name: {{ include "handshake-node.stratumName" . }}
77
labels:
88
{{- include "handshake-node.labels" . | nindent 4 }}
99
app.kubernetes.io/component: stratum

charts/handshake-node/templates/statefulset.yaml

Lines changed: 95 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,22 +61,80 @@ spec:
6161
{{- end }}
6262
securityContext:
6363
{{- toYaml .Values.podSecurityContext | nindent 8 }}
64+
{{- if and .Values.persistence.enabled .Values.persistence.chownInitContainer.enabled }}
65+
initContainers:
66+
- name: init-chown-data
67+
image: {{ .Values.persistence.chownInitContainer.image | quote }}
68+
imagePullPolicy: {{ .Values.persistence.chownInitContainer.imagePullPolicy }}
69+
# Restart-safe ownership fix. handshake-node creates subdirectories
70+
# like data/ and logs/ as UID 100 mode 0700, so on pod restart root
71+
# cannot traverse them without CAP_DAC_READ_SEARCH; that capability
72+
# is added below alongside CAP_CHOWN. `find ... -not -user` skips
73+
# entries already owned by the target UID, keeping the operation
74+
# idempotent and cheap on warm PVCs. File modes are never touched,
75+
# so the Brontide identity key stays 0600.
76+
command:
77+
- sh
78+
- -c
79+
- |
80+
set -eu
81+
find /home/handshake/.handshake-node \( -not -user {{ .Values.podSecurityContext.runAsUser }} -o -not -group {{ .Values.podSecurityContext.runAsGroup }} \) -exec chown {{ .Values.podSecurityContext.runAsUser }}:{{ .Values.podSecurityContext.runAsGroup }} {} +
82+
securityContext:
83+
runAsUser: 0
84+
runAsNonRoot: false
85+
readOnlyRootFilesystem: true
86+
allowPrivilegeEscalation: false
87+
capabilities:
88+
drop:
89+
- ALL
90+
add:
91+
- CHOWN
92+
- DAC_READ_SEARCH
93+
seccompProfile:
94+
type: RuntimeDefault
95+
volumeMounts:
96+
- name: data
97+
mountPath: /home/handshake/.handshake-node
98+
{{- end }}
6499
containers:
65100
- name: handshake-node
66101
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
67102
imagePullPolicy: {{ .Values.image.pullPolicy }}
68103
securityContext:
69104
{{- toYaml .Values.securityContext | nindent 12 }}
70105
{{- $network := include "handshake-node.network" . }}
71-
{{- $networkNeedsArg := has $network (list "regtest" "simnet" "testnet") }}
106+
{{- $networkNeedsArg := eq $network "regtest" }}
107+
{{- /* Validate listener overrides: if the operator sets an explicit
108+
host:port, the port must match the corresponding Kubernetes
109+
Service target port so the container port, Services, and TCP
110+
probes stay in sync. Address-only overrides (no colon) are
111+
rejected because handshake-node expects host:port. */ -}}
112+
{{- $p2pExpect := printf ":%v" (.Values.service.p2p.port | int) }}
113+
{{- if and .Values.config.listen (not (hasSuffix $p2pExpect .Values.config.listen)) }}
114+
{{- fail (printf "config.listen %q must end with %q to match service.p2p.port; use service.p2p.port to change the port and set config.listen only to override the bind address." .Values.config.listen $p2pExpect) }}
115+
{{- end }}
116+
{{- if .Values.rpc.enabled }}
117+
{{- $rpcExpect := printf ":%v" (.Values.rpcService.port | int) }}
118+
{{- if and .Values.config.rpclisten (not (hasSuffix $rpcExpect .Values.config.rpclisten)) }}
119+
{{- fail (printf "config.rpclisten %q must end with %q to match rpcService.port; use rpcService.port to change the port and set config.rpclisten only to override the bind address." .Values.config.rpclisten $rpcExpect) }}
120+
{{- end }}
121+
{{- end }}
122+
{{- if .Values.metrics.enabled }}
123+
{{- $metricsExpect := printf ":%v" (.Values.metrics.service.port | int) }}
124+
{{- if and .Values.metrics.listen (not (hasSuffix $metricsExpect .Values.metrics.listen)) }}
125+
{{- fail (printf "metrics.listen %q must end with %q to match metrics.service.port; use metrics.service.port to change the port and set metrics.listen only to override the bind address." .Values.metrics.listen $metricsExpect) }}
126+
{{- end }}
127+
{{- end }}
128+
{{- if .Values.stratum.enabled }}
129+
{{- $stratumExpect := printf ":%v" (.Values.stratum.service.port | int) }}
130+
{{- if and .Values.stratum.listen (not (hasSuffix $stratumExpect .Values.stratum.listen)) }}
131+
{{- fail (printf "stratum.listen %q must end with %q to match stratum.service.port; use stratum.service.port to change the port and set stratum.listen only to override the bind address." .Values.stratum.listen $stratumExpect) }}
132+
{{- end }}
133+
{{- end }}
72134
{{- if or $networkNeedsArg .Values.metrics.enabled .Values.stratum.enabled }}
73135
args:
74136
{{- if eq $network "regtest" }}
75137
- --regtest
76-
{{- else if eq $network "simnet" }}
77-
- --simnet
78-
{{- else if eq $network "testnet" }}
79-
- --testnet
80138
{{- end }}
81139
{{- if .Values.metrics.enabled }}
82140
{{- $metricsBind := ternary "0.0.0.0" "127.0.0.1" .Values.metrics.allowPublic }}
@@ -89,16 +147,17 @@ spec:
89147
{{- if .Values.stratum.enabled }}
90148
{{- $stratumBind := ternary "0.0.0.0" "127.0.0.1" .Values.stratum.allowPublic }}
91149
{{- $stratumListen := default (printf "%s:%v" $stratumBind (.Values.stratum.service.port | int)) .Values.stratum.listen }}
150+
{{- /* handshake-node v0.2.0-rc1 refuses to start when --stratumlisten
151+
is set without --miningaddr, regardless of allowPublic. Require
152+
stratum.miningAddress whenever Stratum is enabled and always
153+
emit --miningaddr. Auth credentials remain required only for
154+
public exposure. */ -}}
155+
{{- $miningAddr := required "stratum.miningAddress is required when stratum.enabled=true (handshake-node rejects --stratumlisten without --miningaddr)." .Values.stratum.miningAddress }}
92156
- --stratumlisten={{ $stratumListen }}
157+
- --miningaddr={{ $miningAddr }}
93158
{{- if .Values.stratum.allowPublic }}
94-
{{- $miningAddr := required "stratum.miningAddress is required when stratum.enabled=true and stratum.allowPublic=true" .Values.stratum.miningAddress }}
95159
{{- $_ := required "stratum.auth.existingSecret is required when stratum.enabled=true and stratum.allowPublic=true" .Values.stratum.auth.existingSecret }}
96160
- --stratumallowpublic
97-
- --miningaddr={{ $miningAddr }}
98-
{{- else }}
99-
{{- with .Values.stratum.miningAddress }}
100-
- --miningaddr={{ . }}
101-
{{- end }}
102161
{{- end }}
103162
{{- end }}
104163
{{- end }}
@@ -141,12 +200,36 @@ spec:
141200
name: {{ .Values.stratum.auth.existingSecret }}
142201
key: {{ .Values.stratum.auth.passwordKey }}
143202
{{- end }}
203+
{{- /* Reserve chart-managed env names from user-supplied override
204+
mechanisms. Duplicates in the env list cause kubelet to warn
205+
and the later definition wins, which lets config.extra or
206+
extraEnv silently shadow validated listener values or
207+
Secret-sourced credentials. Failing render here keeps the
208+
chart's invariants intact. */ -}}
209+
{{- $reserved := list
210+
"HANDSHAKE_NODE_LISTEN"
211+
"HANDSHAKE_NODE_RPCLISTEN"
212+
"HANDSHAKE_NODE_RPCALLOWIP"
213+
"HANDSHAKE_NODE_RPCUSER"
214+
"HANDSHAKE_NODE_RPCPASS"
215+
"HANDSHAKE_NODE_STRATUMUSER"
216+
"HANDSHAKE_NODE_STRATUMPASS"
217+
}}
144218
{{- range $k, $v := .Values.config.extra }}
145219
{{- if not (kindIs "invalid" $v) }}
146-
- name: HANDSHAKE_NODE_{{ $k | upper | replace "-" "_" }}
220+
{{- $rendered := printf "HANDSHAKE_NODE_%s" ($k | upper | replace "-" "_") }}
221+
{{- if has $rendered $reserved }}
222+
{{- fail (printf "config.extra key %q renders env %q which is reserved by the chart. Use the corresponding chart value (service.p2p.port / rpcService.port / config.listen / config.rpclisten / config.rpcallowip / rpc.existingSecret / stratum.auth.existingSecret) instead." $k $rendered) }}
223+
{{- end }}
224+
- name: {{ $rendered }}
147225
value: {{ $v | quote }}
148226
{{- end }}
149227
{{- end }}
228+
{{- range .Values.extraEnv }}
229+
{{- if has .name $reserved }}
230+
{{- fail (printf "extraEnv name %q is reserved by the chart. Use the corresponding chart value (service.p2p.port / rpcService.port / config.listen / config.rpclisten / config.rpcallowip / rpc.existingSecret / stratum.auth.existingSecret) instead." .name) }}
231+
{{- end }}
232+
{{- end }}
150233
{{- with .Values.extraEnv }}
151234
{{- toYaml . | nindent 12 }}
152235
{{- end }}

0 commit comments

Comments
 (0)