Skip to content

Commit 56ca8cd

Browse files
fix(packaging): preserve operator TLS cert across the cert-shipping -> rc.10 upgrade (#598)
The #596 generate-if-absent fix protects rc.10->onward upgrades, but the one-time transition FROM a release that shipped the cert in its payload (<= rc.9) removed the operator's cert entirely: rc.9 owned /etc/openwatch/tls/cert.pem, rc.10 does not, so the package manager reclaimed the orphaned file AFTER rc.10's provisioning had already run and skipped it. RPM left it missing (service can't start TLS); DEB regenerated a demo (operator cert lost). Found via the Stage-3 gap-closure RPM/DEB install-transition tests. Fix: - RPM: declare the cert/key paths %ghost so rpm tracks them with no payload content and does not reclaim the operator's file on the transition. - DEB: preinst stashes an existing cert/key to .dpkg-bak on upgrade (before dpkg removes the orphan); postinst restores it before provisioning. - AC-22 updated: assert %ghost (flag 'g', no content) on RPM and the preinst/postinst preserve dance on DEB; upgrade-container-test.sh now asserts an operator cert survives the rpm -U upgrade. - release-package-build spec v1.3.0 (C-05 + AC-22 extended). Verified in containers: genuine published rc.9 -> rc.10 preserves the operator cert+key on both RPM (%ghost) and DEB (preinst/postinst); fresh install still generates a demo; steady-state reinstall preserves.
1 parent 4ebab5e commit 56ca8cd

7 files changed

Lines changed: 96 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ overwriting an operator's TLS certificate.
5353
certificate is now generated at install time only when the TLS files are
5454
absent (the same generate-if-absent model already used for the server's
5555
identity keys), so a certificate you put in place survives upgrades untouched
56-
(#596).
56+
(#596). This also covers the one-time upgrade from an earlier build that did
57+
ship the demo certificate (rc.9 and before): your certificate is preserved
58+
rather than removed during that transition too (#598).
5759

5860
---
5961

packaging/deb/postinst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ if [ "$1" = "configure" ]; then
1616
# failure should abort configure rather than leave an unbootable service.
1717
/usr/lib/openwatch/provision-identity-keys.sh
1818

19+
# Restore an operator TLS cert/key that preinst stashed before dpkg removed
20+
# the orphaned demo cert during an upgrade from a cert-shipping release
21+
# (<= rc.9). The restore runs BEFORE provisioning so the operator's file
22+
# wins over a freshly generated demo.
23+
for f in cert.pem key.pem; do
24+
if [ -f "/etc/openwatch/tls/.$f.dpkg-bak" ]; then
25+
mv -f "/etc/openwatch/tls/.$f.dpkg-bak" "/etc/openwatch/tls/$f"
26+
fi
27+
done
28+
1929
# Provision a demo TLS cert + key (generate-if-absent). Not shipped in the
2030
# payload so an upgrade never overwrites an operator's replacement cert.
2131
/usr/lib/openwatch/provision-tls-cert.sh

packaging/deb/preinst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,20 @@
1111

1212
set -e
1313

14+
# On upgrade FROM a release that shipped the demo TLS cert in its payload
15+
# (<= rc.9), dpkg removes that now-orphaned cert/key during unpack because
16+
# rc.10+ no longer own those paths. Stash a copy first so postinst can restore
17+
# an operator's replacement certificate. rc.10+ generate the cert in postinst
18+
# and never ship it, so a steady-state upgrade has nothing to remove and this
19+
# is a harmless round-trip. (The RPM uses %ghost to the same end.)
20+
if [ "$1" = "upgrade" ]; then
21+
for f in cert.pem key.pem; do
22+
if [ -f "/etc/openwatch/tls/$f" ]; then
23+
cp -p "/etc/openwatch/tls/$f" "/etc/openwatch/tls/.$f.dpkg-bak" 2>/dev/null || :
24+
fi
25+
done
26+
fi
27+
1428
getent group openwatch >/dev/null || groupadd --system openwatch
1529
getent passwd openwatch >/dev/null || \
1630
useradd --system --gid openwatch \

packaging/rpm/openwatch.spec

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,12 @@ systemctl daemon-reload || :
145145
# TLS dir ships empty (0750); %post generates the demo cert/key into it
146146
# generate-if-absent. The cert/key files are intentionally NOT packaged, so
147147
# a package upgrade cannot revert an operator's replacement certificate.
148+
# They are declared %ghost — rpm tracks the paths (no payload content, nothing
149+
# laid down or verified) so that on an upgrade FROM a release that DID ship the
150+
# cert (<= rc.9), rpm does not reclaim/erase the operator's file as an orphan.
148151
%dir %attr(0750, root, openwatch) /etc/openwatch/tls
152+
%ghost %attr(0644, root, openwatch) /etc/openwatch/tls/cert.pem
153+
%ghost %attr(0600, openwatch, openwatch) /etc/openwatch/tls/key.pem
149154
%config(noreplace) %attr(0640, root, openwatch) /etc/openwatch/openwatch.toml
150155
%config(noreplace) %attr(0640, root, openwatch) /etc/openwatch/upgrade.conf
151156
%attr(0644, root, root) /etc/systemd/system/openwatch.service

packaging/tests/package_test.go

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -659,20 +659,30 @@ func TestTLS_PostInstallProvisions(t *testing.T) {
659659
}
660660
}
661661

662-
// Payload ships the empty tls dir + the helper, but NEVER the cert/key
663-
// files themselves (that is what made upgrades clobber operator certs).
662+
// Payload ships the empty tls dir + the helper, and carries NO real
663+
// cert/key content. On RPM the cert/key paths are declared %ghost so
664+
// rpm tracks them (flag 'g') without laying down content — this stops a
665+
// package upgrade FROM a cert-shipping release (<= rc.9) from reclaiming
666+
// the operator's file as an orphan, while still never shipping a cert.
664667
const tlsDir = "/etc/openwatch/tls"
665-
rpmFiles := rpmQuery(t, rpm, "[%{FILENAMES}\n]")
666-
if !strings.Contains(rpmFiles, helperPath) {
668+
if !strings.Contains(rpmQuery(t, rpm, "[%{FILENAMES}\n]"), helperPath) {
667669
t.Errorf("RPM payload missing the TLS helper %s", helperPath)
668670
}
669-
if !strings.Contains(rpmFiles, tlsDir) {
671+
rpmGhost := rpmQuery(t, rpm, "[%{FILENAMES} %{FILEFLAGS:fflags}\n]")
672+
if !strings.Contains(rpmGhost, tlsDir) {
670673
t.Errorf("RPM payload missing the %s directory", tlsDir)
671674
}
672-
if strings.Contains(rpmFiles, "tls/cert.pem") || strings.Contains(rpmFiles, "tls/key.pem") {
673-
t.Errorf("RPM payload MUST NOT ship the TLS cert/key; got:\n%s", rpmFiles)
675+
for _, p := range []string{"cert.pem", "key.pem"} {
676+
re := regexp.MustCompile(`/etc/openwatch/tls/` + regexp.QuoteMeta(p) + `\s+\S*g\S*`)
677+
if !re.MatchString(rpmGhost) {
678+
t.Errorf("RPM tls/%s MUST be declared %%ghost (flag 'g', no payload content); file list:\n%s", p, rpmGhost)
679+
}
674680
}
675681

682+
// DEB has no %ghost: the cert/key are not in the payload, and preinst
683+
// stashes an operator's cert before dpkg removes the orphan on upgrade,
684+
// while postinst restores it. So the cert/key are NEVER real payload
685+
// files in either format.
676686
debFiles := debContents(t, deb)
677687
if !strings.Contains(debFiles, helperPath) {
678688
t.Errorf("DEB payload missing the TLS helper %s", helperPath)
@@ -684,6 +694,16 @@ func TestTLS_PostInstallProvisions(t *testing.T) {
684694
t.Errorf("DEB payload MUST NOT ship the TLS cert/key; got:\n%s", debFiles)
685695
}
686696

697+
// DEB preinst backs up an operator cert on upgrade; postinst restores it.
698+
preinst := readPackagingFile(t, dir, "deb", "preinst")
699+
if !strings.Contains(preinst, ".dpkg-bak") || !strings.Contains(preinst, `"$1" = "upgrade"`) {
700+
t.Errorf("deb/preinst must back up the TLS cert/key (.dpkg-bak) on upgrade")
701+
}
702+
postinst := readPackagingFile(t, dir, "deb", "postinst")
703+
if !strings.Contains(postinst, ".dpkg-bak") || !strings.Contains(postinst, "mv -f") {
704+
t.Errorf("deb/postinst must restore the preserved TLS cert/key (.dpkg-bak)")
705+
}
706+
687707
// The build scripts no longer stage a demo cert into the payload.
688708
for _, bs := range []string{"rpm/build-rpm.sh", "deb/build-deb.sh"} {
689709
b, err := os.ReadFile(filepath.Join(dir, "packaging", bs))
@@ -697,6 +717,16 @@ func TestTLS_PostInstallProvisions(t *testing.T) {
697717
})
698718
}
699719

720+
// readPackagingFile reads packaging/<sub>/<name> as a string.
721+
func readPackagingFile(t *testing.T, appDir, sub, name string) string {
722+
t.Helper()
723+
b, err := os.ReadFile(filepath.Join(appDir, "packaging", sub, name))
724+
if err != nil {
725+
t.Fatalf("read packaging/%s/%s: %v", sub, name, err)
726+
}
727+
return string(b)
728+
}
729+
700730
// readDebControlScript extracts a named maintainer script from a DEB
701731
// using `dpkg-deb --ctrl-tarfile` piped to `tar`. Returns the script body
702732
// as a string.

packaging/tests/upgrade-container-test.sh

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ echo "### install OLD package (release 1)"
4848
rpm -i --nodeps /rpms/old/openwatch-*.rpm
4949
echo "OPENWATCH_DATABASE_DSN=$DSN" > /etc/openwatch/secrets.env
5050

51+
# Simulate an operator who replaced the demo TLS cert with their own. It MUST
52+
# survive the upgrade — including the transition from a release that shipped
53+
# the cert in its payload (the cert/key are %ghost in current packages, so rpm
54+
# must not reclaim the operator's file). release-package-build C-05 / AC-22.
55+
echo "OPERATOR-TLS-CERT-SENTINEL-DO-NOT-CLOBBER" > /etc/openwatch/tls/cert.pem
56+
echo "OPERATOR-TLS-KEY-SENTINEL" > /etc/openwatch/tls/key.pem
57+
5158
echo "### bring DB to head, then roll back the head migration ($HEAD_VER) to simulate the prior version"
5259
set -a; . /etc/openwatch/secrets.env; set +a
5360
openwatch migrate >/dev/null
@@ -76,8 +83,12 @@ fail=0
7683
ls /var/lib/openwatch/backups/openwatch-pre-upgrade-*.sql >/dev/null 2>&1 || { echo "FAIL: no pre-upgrade backup"; fail=1; }
7784
grep -q "stop openwatch.service" /tmp/systemctl.log || { echo "FAIL: service not stopped"; fail=1; }
7885
grep -q "start openwatch.service" /tmp/systemctl.log || { echo "FAIL: service not restarted"; fail=1; }
86+
grep -q "OPERATOR-TLS-CERT-SENTINEL-DO-NOT-CLOBBER" /etc/openwatch/tls/cert.pem 2>/dev/null \
87+
|| { echo "FAIL: operator TLS cert was NOT preserved across the upgrade (cert.pem=$(head -1 /etc/openwatch/tls/cert.pem 2>/dev/null || echo MISSING))"; fail=1; }
88+
grep -q "OPERATOR-TLS-KEY-SENTINEL" /etc/openwatch/tls/key.pem 2>/dev/null \
89+
|| { echo "FAIL: operator TLS key was NOT preserved across the upgrade"; fail=1; }
7990
if [ "$fail" -eq 0 ]; then
80-
echo "RESULT: PASS - the package upgrade migrated $PREV_VER -> $HEAD_VER with a backup and a stop/start"
91+
echo "RESULT: PASS - the package upgrade migrated $PREV_VER -> $HEAD_VER with a backup, a stop/start, and preserved the operator TLS cert"
8192
else
8293
echo "RESULT: FAIL"
8394
exit 1

specs/release/package-build.spec.yaml

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
spec:
22
id: release-package-build
33
title: Native RPM and DEB packaging
4-
version: "1.2.0"
4+
version: "1.3.0"
55
status: approved
66
tier: 2
77

@@ -73,7 +73,12 @@ spec:
7373
time by a post-install scriptlet, generate-if-absent (never overwrite).
7474
The packages ship only the empty /etc/openwatch/tls directory plus the
7575
provisioning helper. openssl MUST be a package dependency (shared with
76-
C-11).
76+
C-11). Additionally, an operator's certificate MUST survive the one-time
77+
upgrade FROM a release that DID ship the cert in its payload (<= rc.9):
78+
the RPM MUST declare the cert/key paths %ghost (rpm tracks the path with
79+
no payload content, so it does not reclaim the operator's file as an
80+
orphan), and the DEB MUST stash an existing cert/key in preinst (before
81+
dpkg removes the orphaned file) and restore it in postinst.
7782
type: security
7883
enforcement: error
7984
- id: C-06
@@ -232,9 +237,13 @@ spec:
232237
root:openwatch, key.pem 0600 openwatch:openwatch) only when both files
233238
are absent. The package payloads ship the empty /etc/openwatch/tls
234239
directory and the helper at /usr/lib/openwatch/provision-tls-cert.sh,
235-
but do NOT contain cert.pem or key.pem (verified via rpm -qpl and
236-
dpkg-deb -c), and the build scripts no longer stage a cert into the
237-
payload — so a package upgrade never reverts an operator's replacement
238-
certificate.
240+
but do NOT contain cert.pem or key.pem as real payload content (verified
241+
via rpm -qpl and dpkg-deb -c), and the build scripts no longer stage a
242+
cert into the payload. For the one-time upgrade from a cert-shipping
243+
release (<= rc.9): the RPM declares the cert/key paths %ghost (file flag
244+
'g', no content) so rpm does not reclaim the operator's orphaned file,
245+
and the DEB preinst stashes the cert/key to a .dpkg-bak on upgrade while
246+
the postinst restores them — so a package upgrade never reverts (or
247+
removes) an operator's replacement certificate, including the transition.
239248
priority: critical
240249
references_constraints: [C-05]

0 commit comments

Comments
 (0)