teleport reconfigure (#250)

Export-Source-Commit: e06a63c176c6e97c5f87ed832a6d4aae6d1b22e6
This commit is contained in:
randy
2026-08-25 11:35:10 -05:00
committed by Gus Rivera
parent 44ba4e6053
commit fc3d8bd714
7 changed files with 527 additions and 0 deletions
+5
View File
@@ -851,6 +851,11 @@ const (
// CreatedByIaCLabel is a resource metadata label name used to identify if resource was created by IaC tooling.
CreatedByIaCLabel = TeleportNamespace + "/iac-tool"
// ReconfigureSourceUUIDLabel is used to identify original source uuid
// of node where `teleport reconfigure` was used.
ReconfigureSourceUUIDLabel = TeleportNamespace + "/reconfigure-source-uuid"
// OriginDefaults is an origin value indicating that the resource was
// constructed as a default value.
OriginDefaults = common.OriginDefaults
+297
View File
@@ -0,0 +1,297 @@
/*
* Teleport
* Copyright (C) 2026 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package config
import (
"fmt"
"maps"
"slices"
"strings"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/types"
apiutils "github.com/gravitational/teleport/api/utils"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/utils"
)
// ReconfigureRequest describes the changes Reconfigure applies to a parsed
// FileConfig. A zero-valued field leaves the corresponding setting untouched;
// none of the fields has a meaningful empty value.
type ReconfigureRequest struct {
// ProxyServer sets proxy_server. Valid only for v3 configs.
ProxyServer string
// AuthServer sets auth_server (v3) or replaces auth_servers with the
// single given address (v1/v2). Mutually exclusive with ProxyServer.
AuthServer string
// CAPins replaces ca_pin with the target cluster's pins. Required when
// ProxyServer or AuthServer retargets a config that already pins a CA.
CAPins []string
// TokenName sets join_params.token_name and clears the legacy auth_token.
TokenName string
// JoinMethod sets join_params.method. It is never inferred from the
// other join fields.
JoinMethod string
// BoundKeypairRegistrationSecret sets the bound_keypair registration secret
// value and clears the incompatible registration_secret_path.
BoundKeypairRegistrationSecret string
// BoundKeypairRegistrationSecretPath sets the bound_keypair registration
// secret path and clears the incompatible registration_secret_value.
// Mutually exclusive with BoundKeypairRegistrationSecret.
BoundKeypairRegistrationSecretPath string
// NodeLabels is a comma-separated k=v list merged additively into
// ssh_service labels; on a key conflict the last value wins.
NodeLabels string
// SourceHostUUID sets the node label types.ReconfigureSourceUUIDLabel,
// recording the host the input config was read on.
SourceHostUUID string
// DataDir sets teleport.data_dir.
DataDir string
// PIDFile sets teleport.pid_file.
PIDFile string
// DiagAddr sets teleport.diag_addr.
DiagAddr string
// SSHListenAddr sets ssh_service.listen_addr.
SSHListenAddr string
// KubeListenAddr sets kubernetes_service.listen_addr.
KubeListenAddr string
// MetricsListenAddr sets metrics_service.listen_addr.
MetricsListenAddr string
}
// Reconfigure applies req to fc in place, leaving everything not named by a
// request field untouched. It rejects cases where an unspecified request
// field would cause a collision with a second agent on the same host.
func Reconfigure(fc *FileConfig, req ReconfigureRequest) error {
if req.ProxyServer != "" && req.AuthServer != "" {
return trace.BadParameter("--proxy-server and --auth-server are mutually exclusive")
}
if req.BoundKeypairRegistrationSecret != "" && req.BoundKeypairRegistrationSecretPath != "" {
return trace.BadParameter("--registration-secret and --registration-secret-path are mutually exclusive")
}
if req.ProxyServer != "" && fc.Version != defaults.TeleportConfigVersionV3 {
return trace.BadParameter("--proxy-server requires a v3 config; use --auth-server for v1/v2 configs")
}
if req.ProxyServer != "" && fc.Proxy.Enabled() {
return trace.BadParameter("--proxy-server cannot be specified if the proxy_service is enabled")
}
// Mirror the parsers the agent runs at start (applyConfig and the
// per-service apply functions), or the tool writes a config the agent rejects.
if req.ProxyServer != "" {
if _, err := utils.ParseHostPortAddr(req.ProxyServer, defaults.HTTPListenPort); err != nil {
return trace.Wrap(err, "parsing --proxy-server")
}
}
if req.AuthServer != "" {
if _, err := utils.ParseHostPortAddr(req.AuthServer, defaults.AuthListenPort); err != nil {
return trace.Wrap(err, "parsing --auth-server")
}
}
if len(req.CAPins) > 0 {
// Nil certs: format check only; the pins are verified against the
// target cluster's CA when the agent joins.
if err := utils.CheckSPKI(req.CAPins, nil); err != nil {
return trace.Wrap(err, "parsing --ca-pin")
}
}
if req.JoinMethod != "" {
if err := types.ValidateJoinMethod(types.JoinMethod(req.JoinMethod)); err != nil {
return trace.Wrap(err, "parsing --join-method")
}
}
if req.DiagAddr != "" {
if _, err := utils.ParseAddr(req.DiagAddr); err != nil {
return trace.Wrap(err, "parsing --diag-addr")
}
}
if req.SSHListenAddr != "" {
if _, err := utils.ParseHostPortAddr(req.SSHListenAddr, defaults.SSHServerListenPort); err != nil {
return trace.Wrap(err, "parsing --ssh-listen-addr")
}
}
if req.KubeListenAddr != "" {
// Not a typo: applyKubeConfig uses the SSH proxy port as kube's default.
if _, err := utils.ParseHostPortAddr(req.KubeListenAddr, defaults.SSHProxyListenPort); err != nil {
return trace.Wrap(err, "parsing --kube-listen-addr")
}
}
if req.MetricsListenAddr != "" {
if _, err := utils.ParseHostPortAddr(req.MetricsListenAddr, defaults.MetricsListenPort); err != nil {
return trace.Wrap(err, "parsing --metrics-listen-addr")
}
}
// The join method comes from the request or the config, never inference.
// Reject what the agent would reject at startup (join_params without a
// method) or silently ignore (a registration secret without bound_keypair).
joinMethod := fc.JoinParams.Method
if req.JoinMethod != "" {
joinMethod = types.JoinMethod(req.JoinMethod)
}
if (req.BoundKeypairRegistrationSecret != "" || req.BoundKeypairRegistrationSecretPath != "") && joinMethod != types.JoinMethodBoundKeypair {
flag := "--registration-secret"
if req.BoundKeypairRegistrationSecretPath != "" {
flag = "--registration-secret-path"
}
return trace.BadParameter("%s requires the %s join method; pass --join-method %s", flag, types.JoinMethodBoundKeypair, types.JoinMethodBoundKeypair)
}
joinParamsInUse := !fc.JoinParams.IsEqual(&JoinParams{}) ||
req.TokenName != "" || req.BoundKeypairRegistrationSecret != "" || req.BoundKeypairRegistrationSecretPath != ""
if joinParamsInUse && joinMethod == "" {
return trace.BadParameter("the output config would set join_params without a join method, which the agent rejects at startup; pass --join-method")
}
if req.JoinMethod != "" && !joinParamsInUse && fc.AuthToken == "" {
return trace.BadParameter("--join-method would create join_params without a token name; pass --token as well")
}
// ca_pin validates the CA of the cluster the agent joins, so pins carried
// over from the old cluster cannot be reused after a retarget. Requiring
// new ones keeps a pinned config from silently becoming an unpinned one.
if (req.ProxyServer != "" || req.AuthServer != "") && len(fc.CAPin) > 0 && len(req.CAPins) == 0 {
return trace.BadParameter("the input config sets ca_pin, and retargeting would leave the next join unpinned; pass --ca-pin with the target cluster's pins, which `tctl status` prints")
}
if req.ProxyServer != "" {
fc.ProxyServer = req.ProxyServer
fc.AuthServer = ""
fc.AuthServers = nil
}
if req.AuthServer != "" {
if fc.Version == defaults.TeleportConfigVersionV3 {
fc.AuthServer = req.AuthServer
fc.ProxyServer = ""
fc.AuthServers = nil
} else {
fc.AuthServers = []string{req.AuthServer}
}
}
if len(req.CAPins) > 0 {
fc.CAPin = apiutils.Strings(slices.Clone(req.CAPins))
}
if req.TokenName != "" {
fc.JoinParams.TokenName = req.TokenName
fc.AuthToken = ""
}
if req.BoundKeypairRegistrationSecret != "" {
fc.JoinParams.BoundKeypair.RegistrationSecretValue = req.BoundKeypairRegistrationSecret
fc.JoinParams.BoundKeypair.RegistrationSecretPath = ""
}
if req.BoundKeypairRegistrationSecretPath != "" {
fc.JoinParams.BoundKeypair.RegistrationSecretPath = req.BoundKeypairRegistrationSecretPath
fc.JoinParams.BoundKeypair.RegistrationSecretValue = ""
}
if req.JoinMethod != "" {
fc.JoinParams.Method = types.JoinMethod(req.JoinMethod)
// Leaving the bound_keypair block under another method would carry a
// possibly live registration secret into the output.
if fc.JoinParams.Method != types.JoinMethodBoundKeypair {
fc.JoinParams.BoundKeypair = BoundKeypairParams{}
}
}
// The agent rejects a config that sets both join_params and the legacy
// auth_token.
if !fc.JoinParams.IsEqual(&JoinParams{}) && fc.AuthToken != "" {
if fc.JoinParams.TokenName == "" {
fc.JoinParams.TokenName = fc.AuthToken
}
fc.AuthToken = ""
}
if req.NodeLabels != "" {
static, dynamic, err := parseLabels(req.NodeLabels)
if err != nil {
return trace.Wrap(err, "parsing --node-labels")
}
if len(dynamic) > 0 {
return trace.BadParameter("--node-labels only accepts static labels")
}
if fc.SSH.Labels == nil {
fc.SSH.Labels = make(map[string]string, len(static))
}
maps.Copy(fc.SSH.Labels, static)
}
// After the node-labels merge, so the recorded source wins over both a
// stale label carried in the input and a --node-labels value.
if req.SourceHostUUID != "" {
if fc.SSH.Labels == nil {
fc.SSH.Labels = make(map[string]string, 1)
}
fc.SSH.Labels[types.ReconfigureSourceUUIDLabel] = req.SourceHostUUID
}
if req.DataDir != "" {
fc.DataDir = req.DataDir
}
if req.PIDFile != "" {
fc.PIDFile = req.PIDFile
}
if req.DiagAddr != "" {
fc.DiagAddr = req.DiagAddr
}
if req.SSHListenAddr != "" {
fc.SSH.ListenAddress = req.SSHListenAddr
}
if req.KubeListenAddr != "" {
fc.Kube.ListenAddress = req.KubeListenAddr
}
if req.MetricsListenAddr != "" {
fc.Metrics.ListenAddress = req.MetricsListenAddr
}
return trace.Wrap(collisionError(fc, req))
}
// collisionError rejects values kept from the input that a second agent on
// the same host would contend for; any explicitly requested value passes.
// Only values the input sets are checked; implicit defaults (like SSH's
// 3022) cannot be detected here.
func collisionError(fc *FileConfig, req ReconfigureRequest) error {
var collisions []string
add := func(field, value, flag string) {
collisions = append(collisions, fmt.Sprintf("%s %q (%s)", field, value, flag))
}
if req.PIDFile == "" && fc.PIDFile != "" {
add("pid_file", fc.PIDFile, "--pid-file")
}
if req.DiagAddr == "" && fc.DiagAddr != "" {
add("diag_addr", fc.DiagAddr, "--diag-addr")
}
if req.SSHListenAddr == "" && listenAddrCollides(fc.SSH.Service) {
add("ssh_service.listen_addr", fc.SSH.ListenAddress, "--ssh-listen-addr")
}
if req.KubeListenAddr == "" && listenAddrCollides(fc.Kube.Service) {
add("kubernetes_service.listen_addr", fc.Kube.ListenAddress, "--kube-listen-addr")
}
if req.MetricsListenAddr == "" && listenAddrCollides(fc.Metrics.Service) {
add("metrics_service.listen_addr", fc.Metrics.ListenAddress, "--metrics-listen-addr")
}
if len(collisions) == 0 {
return nil
}
return trace.BadParameter(
"the output would keep these values from the input, and two agents on one host would collide on them:\n\t%s\npass each listed flag with a new value, or repeat the current value to keep it",
strings.Join(collisions, "\n\t"))
}
// listenAddrCollides reports whether a service pins a listen address a second
// agent would contend for: explicitly set, on an enabled service.
func listenAddrCollides(s Service) bool {
return s.ListenAddress != "" && s.Enabled()
}
@@ -0,0 +1,9 @@
version: v3
teleport:
data_dir: /var/lib/teleport
join_params:
token_name: bk-token
method: token
proxy_server: old.example.com:443
ca_pin: []
diag_addr: ""
@@ -0,0 +1,11 @@
version: v2
teleport:
nodename: node-04
data_dir: /var/lib/teleport
join_params:
token_name: new-token
method: token
auth_servers:
- new.example.com:3025
ca_pin: sha256:bbb
diag_addr: ""
@@ -0,0 +1,21 @@
version: v3
teleport:
nodename: node-04
data_dir: /var/lib/teleport_new
join_params:
token_name: new-token
method: token
proxy_server: new.example.com:443
ca_pin: sha256:bbb
diag_addr: ""
ssh_service:
enabled: "yes"
labels:
env: dev
team: a
teleport.dev/reconfigure-source-uuid: 8a2b5c70-97d8-4d97-b3c4-6e54b4a63cfe
proxy_service:
enabled: "no"
https_keypairs: []
https_keypairs_reload_interval: 0s
acme: {}
+162
View File
@@ -0,0 +1,162 @@
/*
* Teleport
* Copyright (C) 2026 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package common
import (
"fmt"
"io"
"os"
"strings"
"github.com/google/renameio/v2"
"github.com/gravitational/trace"
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/lib/config"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/utils/hostid"
)
// reconfigureFlags holds the command line flags for `teleport reconfigure`.
type reconfigureFlags struct {
input string
output string
overwrite bool
proxyServer string
authServer string
caPins []string
tokenName string
joinMethod string
boundKeypairRegistrationSecret string
boundKeypairRegistrationSecretPath string
nodeLabels string
dataDir string
pidFile string
diagAddr string
sshListenAddr string
kubeListenAddr string
metricsListenAddr string
}
// onReconfigure is the handler for the "reconfigure" CLI command.
func onReconfigure(flags reconfigureFlags) error {
return trace.Wrap(runReconfigure(flags, os.Stdout))
}
func runReconfigure(flags reconfigureFlags, stdout io.Writer) error {
if flags.overwrite && flags.output == "" {
return trace.BadParameter("--overwrite requires --output")
}
fc, err := config.ReadFromFile(flags.input)
if err != nil {
return trace.Wrap(err)
}
// The uuid names the host the input config was read on, if present. Best effort: when the uuid is
// unreadable (a machine that never ran an agent, an unprivileged user),
// ReadFile returns "" and the label is not added.
sourceDataDir := fc.DataDir
if sourceDataDir == "" {
sourceDataDir = defaults.DataDir
}
sourceHostUUID, _ := hostid.ReadFile(sourceDataDir)
if err := config.Reconfigure(fc, config.ReconfigureRequest{
ProxyServer: flags.proxyServer,
AuthServer: flags.authServer,
CAPins: flags.caPins,
TokenName: flags.tokenName,
JoinMethod: flags.joinMethod,
BoundKeypairRegistrationSecret: flags.boundKeypairRegistrationSecret,
BoundKeypairRegistrationSecretPath: flags.boundKeypairRegistrationSecretPath,
NodeLabels: flags.nodeLabels,
SourceHostUUID: sourceHostUUID,
DataDir: flags.dataDir,
PIDFile: flags.pidFile,
DiagAddr: flags.diagAddr,
SSHListenAddr: flags.sshListenAddr,
KubeListenAddr: flags.kubeListenAddr,
MetricsListenAddr: flags.metricsListenAddr,
}); err != nil {
return trace.Wrap(err)
}
out, err := fc.YAMLString()
if err != nil {
return trace.Wrap(err)
}
// Round-trip the output through the same reader the agent uses at
// start, so an invalid file is never written.
if _, err := config.ReadConfig(strings.NewReader(out)); err != nil {
return trace.Wrap(err, "internal error: generated config failed validation, nothing written")
}
if flags.output == "" {
if _, err := fmt.Fprint(stdout, out); err != nil {
return trace.ConvertSystemError(err)
}
return nil
}
return trace.Wrap(writeReconfigureOutput(flags.input, flags.output, flags.overwrite, []byte(out)))
}
// writeReconfigureOutput writes data to output with owner-only permissions.
// It refuses to write over the input config and refuses an existing output
// unless overwrite is set.
func writeReconfigureOutput(input, output string, overwrite bool, data []byte) error {
inInfo, err := os.Stat(input)
if err != nil {
return trace.ConvertSystemError(err)
}
if outInfo, err := os.Stat(output); err == nil && os.SameFile(inInfo, outInfo) {
return trace.BadParameter("refusing to write over the input config %q", input)
}
if overwrite {
// Temp file + atomic rename, forcing owner-only permissions:
// renameio.WriteFile would keep an existing file's broader mode.
pending, err := renameio.NewPendingFile(output, renameio.WithPermissions(teleport.FileMaskOwnerOnly))
if err != nil {
return trace.ConvertSystemError(err)
}
defer pending.Cleanup()
if _, err := pending.Write(data); err != nil {
return trace.ConvertSystemError(err)
}
return trace.ConvertSystemError(pending.CloseAtomicallyReplace())
}
// O_EXCL atomically rejects anything already at the path, symlinks included.
f, err := os.OpenFile(output, os.O_WRONLY|os.O_CREATE|os.O_EXCL, teleport.FileMaskOwnerOnly)
if err != nil {
if os.IsExist(err) {
return trace.AlreadyExists("output file %q already exists; use --overwrite to replace it", output)
}
return trace.ConvertSystemError(err)
}
// On failure, remove the partial file so a retry is not blocked by the
// already-exists guard.
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(output)
return trace.ConvertSystemError(err)
}
// Close can carry the real write failure.
if err := f.Close(); err != nil {
os.Remove(output)
return trace.ConvertSystemError(err)
}
return nil
}
+22
View File
@@ -97,6 +97,7 @@ func Run(options Options) (app *kingpin.Application, executedCommand string, con
ccf config.CommandLineFlags
scpFlags scp.Flags
dumpFlags dumpFlags
reconfigureFlags reconfigureFlags
configureDatabaseAWSPrintFlags configureDatabaseAWSPrintFlags
configureDatabaseAWSCreateFlags configureDatabaseAWSCreateFlags
configureDiscoveryBootstrapFlags configureDiscoveryBootstrapFlags
@@ -457,6 +458,25 @@ func Run(options Options) (app *kingpin.Application, executedCommand string, con
dump.Flag("node-name", "Name for the Teleport node.").StringVar(&dumpFlags.NodeName)
dump.Flag("node-labels", "Comma-separated list of labels to add to newly created nodes, for example env=staging,cloud=aws.").StringVar(&dumpFlags.NodeLabels)
reconf := app.Command("reconfigure", "Generate a new agent config file from an existing one, applying changes given by flags.").Hidden()
reconf.Flag("input", "Path to the source config file.").Default(defaults.ConfigFilePath).StringVar(&reconfigureFlags.input)
reconf.Flag("output", "Path to write the new config file to. Writes to stdout when omitted.").StringVar(&reconfigureFlags.output)
reconf.Flag("overwrite", "Replace the output file if it already exists.").BoolVar(&reconfigureFlags.overwrite)
reconf.Flag("proxy-server", "Proxy address of the target cluster; sets proxy_server (v3 configs only).").StringVar(&reconfigureFlags.proxyServer)
reconf.Flag("auth-server", "Auth server address of the target cluster; sets auth_server (v3) or auth_servers (v1/v2).").StringVar(&reconfigureFlags.authServer)
reconf.Flag("ca-pin", "CA pin of the target cluster (can be repeated for multiple pins).").StringsVar(&reconfigureFlags.caPins)
reconf.Flag("token", "Name of the join token to register with the target cluster.").StringVar(&reconfigureFlags.tokenName)
reconf.Flag("join-method", "Join method to set in join_params. Defaults to the method already in the config.").EnumVar(&reconfigureFlags.joinMethod, joinMethods...)
reconf.Flag("registration-secret", "Bound keypair registration secret. Prefer --registration-secret-path, which keeps the secret off the command line.").StringVar(&reconfigureFlags.boundKeypairRegistrationSecret)
reconf.Flag("registration-secret-path", "Path to a file containing the bound keypair registration secret; the agent reads it at join time.").StringVar(&reconfigureFlags.boundKeypairRegistrationSecretPath)
reconf.Flag("node-labels", "Comma-separated labels to merge into ssh_service labels, for example env=dev,team=a; new values win on conflict.").StringVar(&reconfigureFlags.nodeLabels)
reconf.Flag("data-dir", "Path to the new agent's data directory.").StringVar(&reconfigureFlags.dataDir)
reconf.Flag("pid-file", "Full path to the new agent's PID file.").StringVar(&reconfigureFlags.pidFile)
reconf.Flag("diag-addr", "Address for the new agent's diagnostic endpoint.").StringVar(&reconfigureFlags.diagAddr)
reconf.Flag("ssh-listen-addr", "Listen address for the new agent's SSH service.").StringVar(&reconfigureFlags.sshListenAddr)
reconf.Flag("kube-listen-addr", "Listen address for the new agent's Kubernetes service.").StringVar(&reconfigureFlags.kubeListenAddr)
reconf.Flag("metrics-listen-addr", "Listen address for the new agent's metrics service.").StringVar(&reconfigureFlags.metricsListenAddr)
ver.Flag("raw", "Print the raw teleport version string.").BoolVar(&rawVersion)
dumpNode := app.Command("node", "SSH Node configuration commands")
@@ -752,6 +772,8 @@ Examples:
err = onStatus()
case dump.FullCommand():
err = onConfigDump(dumpFlags)
case reconf.FullCommand():
err = onReconfigure(reconfigureFlags)
case dumpNodeConfigure.FullCommand():
dumpFlags.Roles = defaults.RoleNode
err = onConfigDump(dumpFlags)