Skip to content

Commit 8eaf41f

Browse files
authored
Merge pull request #812 from marv7000/mgradm_ssl_rotation
Add mgradm commands for SSL CA and certificate rotation
2 parents f542103 + c46f123 commit 8eaf41f

18 files changed

Lines changed: 948 additions & 56 deletions

mgradm/cmd/cmd.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/uyuni-project/uyuni-tools/mgradm/cmd/restart"
2020
"github.com/uyuni-project/uyuni-tools/mgradm/cmd/scale"
2121
"github.com/uyuni-project/uyuni-tools/mgradm/cmd/server"
22+
"github.com/uyuni-project/uyuni-tools/mgradm/cmd/ssl"
2223
"github.com/uyuni-project/uyuni-tools/mgradm/cmd/start"
2324
"github.com/uyuni-project/uyuni-tools/mgradm/cmd/status"
2425
"github.com/uyuni-project/uyuni-tools/mgradm/cmd/stop"
@@ -92,6 +93,7 @@ func NewUyuniadmCommand() (*cobra.Command, error) {
9293
rootCmd.AddCommand(gpg.NewCommand(globalFlags))
9394
rootCmd.AddCommand(backup.NewCommand(globalFlags))
9495
rootCmd.AddCommand(server.NewCommand(globalFlags))
96+
rootCmd.AddCommand(ssl.NewCommand(globalFlags))
9597

9698
rootCmd.AddCommand(utils.GetConfigHelpCommand())
9799

mgradm/cmd/server/rename/podman.go

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// SPDX-FileCopyrightText: 2025 SUSE LLC
1+
// SPDX-FileCopyrightText: 2026 SUSE LLC
22
//
33
// SPDX-License-Identifier: Apache-2.0
44

@@ -9,12 +9,10 @@ import (
99
"os"
1010
"path"
1111
"regexp"
12-
"strings"
1312

1413
"github.com/rs/zerolog/log"
1514
"github.com/spf13/cobra"
1615
adm_podman "github.com/uyuni-project/uyuni-tools/mgradm/shared/podman"
17-
"github.com/uyuni-project/uyuni-tools/shared"
1816
. "github.com/uyuni-project/uyuni-tools/shared/l10n"
1917
"github.com/uyuni-project/uyuni-tools/shared/podman"
2018
"github.com/uyuni-project/uyuni-tools/shared/types"
@@ -31,7 +29,7 @@ func renameForPodman(_ *types.GlobalFlags, flags *renameFlags, _ *cobra.Command,
3129

3230
// Regenerate Server SSL certificate if requested
3331
image := podman.GetServiceImage(podman.ServerService)
34-
tz := findTimezone("podman")
32+
tz := adm_podman.GetContainerTimezone()
3533
log.Info().Msg(L("Preparing SSL certificates to match the new hostname"))
3634
if err := adm_podman.PrepareSSLCertificates(image, &flags.SSL, tz, fqdn); err != nil {
3735
return err
@@ -64,31 +62,6 @@ The logs can be found in journalctl -u uyuni-config-update.service output.`))
6462
return nil
6563
}
6664

67-
func findTimezone(backend string) string {
68-
// If the container is running, call podman exec uyuni-server sh -c "echo $TZ".
69-
cnx := shared.NewConnection(backend, podman.ServerContainerName, "")
70-
out, err := cnx.Exec("echo", "$TZ")
71-
if err == nil {
72-
return strings.TrimSpace(string(out))
73-
}
74-
75-
if backend == "podman" {
76-
// Otherwise get the value from the uyuni-server.service.d/custom.conf file, in the 'Environment=TZ=' line.
77-
// In theory users shouldn't remove this, but who knows what could happen?
78-
log.Debug().Msg("Failed to get the timezone from the container, looking for it in systemd configuration file")
79-
if env, err := systemd.Show(podman.ServerService, "Environment"); err == nil {
80-
pattern := regexp.MustCompile("TZ=([^[:space]]*)")
81-
matches := pattern.FindStringSubmatch(env)
82-
if len(matches) == 1 {
83-
return matches[0]
84-
}
85-
}
86-
}
87-
88-
log.Debug().Msg("Failed to get the timezone from the configuration, getting the host one")
89-
return utils.GetLocalTimezone()
90-
}
91-
9265
// alterHostnameConfig changes the UYUNI_HOSTNAME value in the server systemd service or adds it if needed.
9366
func alterHostnameConfig(fqdn string) error {
9467
config, err := readCustomConf()

mgradm/cmd/ssl/addca/addca.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// SPDX-FileCopyrightText: 2026 SUSE LLC
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package addca
6+
7+
import (
8+
"github.com/spf13/cobra"
9+
"github.com/spf13/viper"
10+
adm_utils "github.com/uyuni-project/uyuni-tools/mgradm/shared/utils"
11+
. "github.com/uyuni-project/uyuni-tools/shared/l10n"
12+
"github.com/uyuni-project/uyuni-tools/shared/ssl"
13+
"github.com/uyuni-project/uyuni-tools/shared/types"
14+
"github.com/uyuni-project/uyuni-tools/shared/utils"
15+
)
16+
17+
type addCAFlags struct {
18+
SSL adm_utils.InstallSSLFlags
19+
}
20+
21+
func newCmd(globalFlags *types.GlobalFlags, run utils.CommandFunc[addCAFlags]) *cobra.Command {
22+
cmd := &cobra.Command{
23+
Use: "addca [fqdn]",
24+
// Alternative spelling; always prefer the original spelling.
25+
Aliases: []string{"add-ca"},
26+
Short: L("Add a new root CA (phase 1 of an SSL CA rotation)"),
27+
Long: L(`Add a new root CA to the trusted CA bundle without switching the server certificate.`),
28+
Args: cobra.MaximumNArgs(1),
29+
RunE: func(cmd *cobra.Command, args []string) error {
30+
var flags addCAFlags
31+
return utils.CommandHelper(globalFlags, cmd, args, &flags, getFlagsUpdater(&flags), run)
32+
},
33+
}
34+
35+
ssl.AddSSLGenerationFlags(cmd)
36+
ssl.AddSSLCARootFlags(cmd)
37+
cmd.Flags().String("ssl-password", "", L("Password for the CA key to generate"))
38+
_ = utils.AddFlagToHelpGroupID(cmd, "ssl-password", ssl.GeneratedFlagsGroup)
39+
return cmd
40+
}
41+
42+
// getFlagsUpdater defaults the database root CA from the server one when it is not provided.
43+
func getFlagsUpdater(flags *addCAFlags) utils.FlagsUpdaterFunc {
44+
return func(_ *viper.Viper) {
45+
if flags.SSL.Ca.IsThirdParty() && !flags.SSL.DB.CA.IsThirdParty() {
46+
flags.SSL.DB.CA.Root = flags.SSL.Ca.Root
47+
}
48+
}
49+
}
50+
51+
// NewCommand creates the command to add a new root CA to the trust (phase 1 of a rotation).
52+
func NewCommand(globalFlags *types.GlobalFlags) *cobra.Command {
53+
return newCmd(globalFlags, addCAForPodman)
54+
}

mgradm/cmd/ssl/addca/addca_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// SPDX-FileCopyrightText: 2026 SUSE LLC
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package addca
6+
7+
import (
8+
"testing"
9+
10+
"github.com/spf13/cobra"
11+
"github.com/uyuni-project/uyuni-tools/shared/testutils"
12+
"github.com/uyuni-project/uyuni-tools/shared/testutils/flagstests"
13+
"github.com/uyuni-project/uyuni-tools/shared/types"
14+
)
15+
16+
func TestParamsParsing(t *testing.T) {
17+
args := []string{}
18+
args = append(args, flagstests.SSLGenerationFlagsTestArgs...)
19+
args = append(args,
20+
"--ssl-ca-root", "path/root.crt",
21+
"--ssl-db-ca-root", "path/db-root.crt",
22+
"--ssl-password", "sslsecret",
23+
)
24+
25+
// Test function asserting that the args are properly parsed.
26+
tester := func(_ *types.GlobalFlags, flags *addCAFlags, _ *cobra.Command, _ []string) error {
27+
flagstests.AssertSSLGenerationFlag(t, &flags.SSL.SSLCertGenerationFlags)
28+
testutils.AssertEquals(t, "Error parsing --ssl-ca-root", "path/root.crt", flags.SSL.Ca.Root)
29+
testutils.AssertEquals(t, "Error parsing --ssl-db-ca-root", "path/db-root.crt", flags.SSL.DB.CA.Root)
30+
testutils.AssertEquals(t, "Error parsing --ssl-password", "sslsecret", flags.SSL.Password)
31+
return nil
32+
}
33+
34+
globalFlags := types.GlobalFlags{}
35+
cmd := newCmd(&globalFlags, tester)
36+
37+
testutils.AssertHasAllFlags(t, cmd, args)
38+
39+
cmd.SetArgs(args)
40+
if err := cmd.Execute(); err != nil {
41+
t.Errorf("command failed with error: %s", err)
42+
}
43+
}

mgradm/cmd/ssl/addca/podman.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// SPDX-FileCopyrightText: 2026 SUSE LLC
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package addca
6+
7+
import (
8+
"errors"
9+
10+
"github.com/rs/zerolog/log"
11+
"github.com/spf13/cobra"
12+
adm_podman "github.com/uyuni-project/uyuni-tools/mgradm/shared/podman"
13+
. "github.com/uyuni-project/uyuni-tools/shared/l10n"
14+
"github.com/uyuni-project/uyuni-tools/shared/podman"
15+
"github.com/uyuni-project/uyuni-tools/shared/types"
16+
"github.com/uyuni-project/uyuni-tools/shared/utils"
17+
)
18+
19+
func addCAForPodman(_ *types.GlobalFlags, flags *addCAFlags, cmd *cobra.Command, args []string) error {
20+
fqdn, err := utils.GetFqdn(args)
21+
if err != nil {
22+
return err
23+
}
24+
25+
if !flags.SSL.Ca.IsThirdParty() {
26+
utils.AskPasswordIfMissing(&flags.SSL.Password, cmd.Flag("ssl-password").Usage, 0, 0)
27+
if flags.SSL.Password == "" {
28+
return errors.New(L("a password for the new CA key is required"))
29+
}
30+
}
31+
32+
image := podman.GetServiceImage(podman.ServerService)
33+
tz := adm_podman.GetContainerTimezone()
34+
35+
if err := adm_podman.AddCA(image, &flags.SSL, tz, fqdn); err != nil {
36+
return err
37+
}
38+
39+
if err := adm_podman.ApplyNewCertificates(fqdn); err != nil {
40+
return err
41+
}
42+
43+
if fingerprint, err := adm_podman.NewCAFingerprint(); err != nil {
44+
log.Warn().Err(err).Msg(L("Could not read the new CA fingerprint"))
45+
} else {
46+
log.Info().Msgf(L("The new root CA (SHA-256 fingerprint %s) has been added to the trusted bundle."),
47+
fingerprint)
48+
}
49+
50+
return nil
51+
}

mgradm/cmd/ssl/rotate/podman.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// SPDX-FileCopyrightText: 2026 SUSE LLC
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package rotate
6+
7+
import (
8+
"errors"
9+
"strings"
10+
11+
"github.com/rs/zerolog/log"
12+
"github.com/spf13/cobra"
13+
adm_podman "github.com/uyuni-project/uyuni-tools/mgradm/shared/podman"
14+
. "github.com/uyuni-project/uyuni-tools/shared/l10n"
15+
"github.com/uyuni-project/uyuni-tools/shared/podman"
16+
"github.com/uyuni-project/uyuni-tools/shared/types"
17+
"github.com/uyuni-project/uyuni-tools/shared/utils"
18+
)
19+
20+
func rotateForPodman(_ *types.GlobalFlags, flags *rotateFlags, cmd *cobra.Command, args []string) error {
21+
fqdn, err := utils.GetFqdn(args)
22+
if err != nil {
23+
return err
24+
}
25+
26+
if flags.Emergency {
27+
return emergencyRotate(flags, cmd, fqdn)
28+
}
29+
return plannedRotate(flags, cmd, fqdn)
30+
}
31+
32+
func plannedRotate(flags *rotateFlags, cmd *cobra.Command, fqdn string) error {
33+
checkOnly, _ := cmd.Flags().GetBool("check-only")
34+
35+
fingerprint, err := adm_podman.NewCAFingerprint()
36+
if err != nil {
37+
return err
38+
}
39+
if err := checkClientReadiness(fingerprint, flags.Force); err != nil {
40+
return err
41+
}
42+
if checkOnly {
43+
return nil
44+
}
45+
46+
if err := switchServerCertificate(flags, cmd, fqdn, false); err != nil {
47+
return err
48+
}
49+
return adm_podman.ApplyNewCertificates(fqdn)
50+
}
51+
52+
func emergencyRotate(flags *rotateFlags, cmd *cobra.Command, fqdn string) error {
53+
if checkOnly, _ := cmd.Flags().GetBool("check-only"); checkOnly {
54+
log.Warn().Msg(L("--check-only is ignored with --emergency! Proceeding with the emergency rotation"))
55+
}
56+
57+
if err := switchServerCertificate(flags, cmd, fqdn, true); err != nil {
58+
return err
59+
}
60+
return adm_podman.ApplyNewCertificates(fqdn)
61+
}
62+
63+
func switchServerCertificate(flags *rotateFlags, cmd *cobra.Command, fqdn string, regenerateCA bool) error {
64+
if flags.SSL.Ca.IsThirdParty() {
65+
if !flags.SSL.UseProvided() {
66+
return errors.New(L("the server certificate, key and root CA need to be all provided"))
67+
}
68+
log.Info().Msg(L("Installing the provided 3rd party server certificate"))
69+
return adm_podman.SetThirdPartyCertificates(&flags.SSL, fqdn)
70+
}
71+
72+
utils.AskPasswordIfMissing(&flags.SSL.Password, cmd.Flag("ssl-password").Usage, 0, 0)
73+
if flags.SSL.Password == "" {
74+
return errors.New(L("the CA key password is required"))
75+
}
76+
77+
image := podman.GetServiceImage(podman.ServerService)
78+
tz := adm_podman.GetContainerTimezone()
79+
if regenerateCA {
80+
log.Info().Msg(L("Generating a new CA and server certificate"))
81+
return adm_podman.RegenerateCAAndCertificate(image, &flags.SSL, tz, fqdn)
82+
}
83+
log.Info().Msg(L("Generating a new server certificate signed by the new CA"))
84+
return adm_podman.RotateServerCertificate(image, &flags.SSL, tz, fqdn)
85+
}
86+
87+
func checkClientReadiness(fingerprint string, force bool) error {
88+
if force {
89+
log.Warn().Msg(L("Skipping the client CA trust check as --force was set"))
90+
return nil
91+
}
92+
93+
result, err := adm_podman.CheckClientsCATrust(fingerprint)
94+
if err != nil {
95+
return utils.Error(err, L("failed to verify client readiness, rerun with --force to rotate without checking"))
96+
}
97+
98+
reportClientReadiness(result)
99+
if !result.AllMigrated() {
100+
return errors.New(
101+
L("some clients do not trust the new CA yet! Distribute it and retry, or use --force to skip this check"),
102+
)
103+
}
104+
return nil
105+
}
106+
107+
func reportClientReadiness(result adm_podman.ClientCheckResult) {
108+
log.Info().Msgf(L("Clients trusting the new CA: %d"), len(result.Migrated))
109+
if len(result.NotMigrated) > 0 {
110+
log.Warn().Msgf(L("Clients not yet trusting the new CA (%[1]d): %[2]s"),
111+
len(result.NotMigrated), strings.Join(result.NotMigrated, ", "))
112+
}
113+
if len(result.Unreachable) > 0 {
114+
log.Warn().Msgf(L("Unreachable minions (%[1]d): %[2]s"),
115+
len(result.Unreachable), strings.Join(result.Unreachable, ", "))
116+
}
117+
log.Warn().Msg(L("Only Salt-managed clients are checked! Non-managed systems and proxy " +
118+
"configurations are not verified and must be handled manually."))
119+
}

0 commit comments

Comments
 (0)