Skip to content
2 changes: 2 additions & 0 deletions docs/pages/reference/cli/tbot.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ specific section for details when using a YAML config file or legacy output.
| `--registration-secret` | An optional joining secret to use on first join with the `bound_keypair` join method. This can also be provided via the `TBOT_REGISTRATION_SECRET` environment variable. |
| `--registration-secret-path` | An optional path to a file containing a joining secret to use on first join with the `bound_keypair` join method. |
| `--static-key-path` | An optional path to a file containing a static private key for use with the `bound_keypair` join method. A base64-encoded key can also be provided via the `TBOT_BOUND_KEYPAIR_STATIC_KEY` environment variable. |
| `--pid-file` | Full path to the PID file. By default no PID file will be created. |

## tbot start legacy

Expand Down Expand Up @@ -321,6 +322,7 @@ another dedicated mode instead.
| `--join-method` | Method to use to join the cluster. Can be `token`, `azure`, `circleci`, `gcp`, `github`, `gitlab` or `iam`. |
| `--oneshot` | If set, quit after the first renewal. |
| `--log-format` | Controls the format of output logs. Can be `json` or `text`. Defaults to `text`. |
| `--pid-file` | Full path to the PID file. By default no PID file will be created. |

### Examples
<Tabs>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ Content-Type: application/json
"status": "unhealthy",
"reason": "access denied to perform action \"read\" on \"workload_identity\""
}
}
},
"pid": 42344
}
```

Expand Down
36 changes: 2 additions & 34 deletions lib/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ import (
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
"golang.org/x/crypto/ssh"
"golang.org/x/sys/unix"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/keepalive"
Expand Down Expand Up @@ -181,6 +180,7 @@ import (
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/lib/utils/cert"
logutils "github.com/gravitational/teleport/lib/utils/log"
procutils "github.com/gravitational/teleport/lib/utils/process"
"github.com/gravitational/teleport/lib/versioncontrol/endpoint"
uw "github.com/gravitational/teleport/lib/versioncontrol/upgradewindow"
"github.com/gravitational/teleport/lib/web"
Expand Down Expand Up @@ -1646,7 +1646,7 @@ func NewTeleport(cfg *servicecfg.Config) (_ *TeleportProcess, err error) {

// create the new pid file only after started successfully
if cfg.PIDFile != "" {
if err := createLockedPIDFile(cfg.PIDFile); err != nil {
if err := procutils.CreateLockedPIDFile(cfg.PIDFile); err != nil {
return nil, trace.Wrap(err, "creating pidfile")
}
}
Expand Down Expand Up @@ -7378,35 +7378,3 @@ func (process *TeleportProcess) newExternalAuditStorageConfigurator() (*external
statusService := local.NewStatusService(process.backend)
return externalauditstorage.NewConfigurator(process.ExitContext(), easSvc, integrationSvc, statusService)
}

// createLockedPIDFile creates a PID file in the path specified by pidFile
// containing the current PID, atomically swapping it in the final place and
// leaving it with an exclusive advisory lock that will get released when the
// process ends, for the benefit of "pkill -L".
func createLockedPIDFile(pidFile string) error {
pending, err := renameio.NewPendingFile(pidFile, renameio.WithPermissions(0o644))
if err != nil {
return trace.ConvertSystemError(err)
}
defer pending.Cleanup()
if _, err := fmt.Fprintf(pending, "%v\n", os.Getpid()); err != nil {
return trace.ConvertSystemError(err)
}

const minimumDupFD = 3 // skip stdio
locker, err := unix.FcntlInt(pending.Fd(), unix.F_DUPFD_CLOEXEC, minimumDupFD)
runtime.KeepAlive(pending)
if err != nil {
return trace.ConvertSystemError(err)
}
if err := unix.Flock(locker, unix.LOCK_EX|unix.LOCK_NB); err != nil {
_ = unix.Close(locker)
return trace.ConvertSystemError(err)
}
// deliberately leak the fd to hold the lock until the process dies

if err := pending.CloseAtomicallyReplace(); err != nil {
return trace.ConvertSystemError(err)
}
return nil
}
4 changes: 2 additions & 2 deletions lib/service/signals.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ func (process *TeleportProcess) createListener(typ ListenerType, address string)
return nil, trace.BadParameter("listening is blocked")
}

// When the process exists, the socket files are left behind (to cover
// When the process exits, the socket files are left behind (to cover
// forking scenarios). To guarantee there won't be errors like "address
// already in use", delete the file before starting the listener.
if typ.Network() == "unix" {
Expand All @@ -318,7 +318,7 @@ func (process *TeleportProcess) createListener(typ ListenerType, address string)

// The default behavior for unix listeners is to delete the file when the
// listener closes (unlinking). However, if the process forks, the file
// descriptor will be gone when its parent process exists, causing the new
// descriptor will be gone when its parent process exits, causing the new
// listener to have no socket file.
if unixListener, ok := listener.(*net.UnixListener); ok {
unixListener.SetUnlinkOnClose(false)
Expand Down
12 changes: 12 additions & 0 deletions lib/tbot/cli/start_legacy.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,13 @@ type LegacyCommand struct {
// If not set, no diagnostics listener is created.
DiagAddr string

// DiagSocketForUpdater specifies the diagnostics http service address that
// should be exposed to the updater via UNIX domain socket.
DiagSocketForUpdater string

// PIDFile is the path to the PID file. If not set, no PID file will be created.
PIDFile string

oneshotSetByUser bool
}

Expand All @@ -158,6 +165,8 @@ func NewLegacyCommand(parentCmd *kingpin.CmdClause, action MutatorAction, mode C
c.cmd.Flag("join-method", "Method to use to join the cluster. "+joinMethodList).EnumVar(&c.JoinMethod, onboarding.SupportedJoinMethods...)
c.cmd.Flag("oneshot", "If set, quit after the first renewal.").IsSetByUser(&c.oneshotSetByUser).BoolVar(&c.Oneshot)
c.cmd.Flag("diag-addr", "If set and the bot is in debug mode, a diagnostics service will listen on specified address.").StringVar(&c.DiagAddr)
c.cmd.Flag("diag-socket-for-updater", "If set, run the diagnostics service on the specified socket path for teleport-update to consume.").Hidden().StringVar(&c.DiagSocketForUpdater)
c.cmd.Flag("pid-file", "Full path to the PID file. By default no PID file will be created.").StringVar(&c.PIDFile)

return c
}
Expand Down Expand Up @@ -273,5 +282,8 @@ func (c *LegacyCommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error
cfg.DiagAddr = c.DiagAddr
}

cfg.DiagSocketForUpdater = c.DiagSocketForUpdater
cfg.PIDFile = c.PIDFile

return nil
}
4 changes: 4 additions & 0 deletions lib/tbot/cli/start_legacy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ func TestLegacyCommand(t *testing.T) {
"--data-dir=/foo",
"--destination-dir=/bar",
"--auth-server=example.com:3024",
"--pid-file=/run/tbot.pid",
"--diag-socket-for-updater=/var/lib/teleport/bot/debug.sock",
},
assertConfig: func(t *testing.T, cfg *config.BotConfig) {
token, err := cfg.Onboarding.Token()
Expand All @@ -61,6 +63,8 @@ func TestLegacyCommand(t *testing.T) {
require.True(t, cfg.Oneshot)
require.Equal(t, "0.0.0.0:8080", cfg.DiagAddr)
require.Equal(t, "example.com:3024", cfg.AuthServer)
require.Equal(t, "/run/tbot.pid", cfg.PIDFile)
require.Equal(t, "/var/lib/teleport/bot/debug.sock", cfg.DiagSocketForUpdater)

dir, ok := cfg.Storage.Destination.(*destination.Directory)
require.True(t, ok)
Expand Down
11 changes: 9 additions & 2 deletions lib/tbot/cli/start_shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,10 @@ type sharedStartArgs struct {
StaticKeyPath string
Keypair string

Oneshot bool
DiagAddr string
Oneshot bool
DiagAddr string
DiagSocketForUpdater string
PIDFile string

oneshotSetByUser bool
}
Expand All @@ -146,6 +148,8 @@ func newSharedStartArgs(cmd *kingpin.CmdClause) *sharedStartArgs {
cmd.Flag("registration-secret-path", "For bound keypair joining, specifies a file containing a registration secret for use at first join.").StringVar(&args.RegistrationSecretPath)
cmd.Flag("static-key-path", "For bound keypair joining, specifies a path to a static key.").StringVar(&args.StaticKeyPath)
cmd.Flag("join-uri", "An optional URI with joining and authentication parameters. Individual flags for proxy, join method, token, etc may be used instead.").StringVar(&args.JoiningURI)
cmd.Flag("diag-socket-for-updater", "If set, run the diagnostics service on the specified socket path for teleport-update to consume.").Hidden().StringVar(&args.DiagSocketForUpdater)
cmd.Flag("pid-file", "Full path to the PID file. By default no PID file will be created.").StringVar(&args.PIDFile)

return args
}
Expand Down Expand Up @@ -264,6 +268,9 @@ func (s *sharedStartArgs) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) err
cfg.Onboarding.BoundKeypair.StaticPrivateKeyPath = s.StaticKeyPath
}

cfg.DiagSocketForUpdater = s.DiagSocketForUpdater
cfg.PIDFile = s.PIDFile

return nil
}

Expand Down
4 changes: 4 additions & 0 deletions lib/tbot/cli/start_shared_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ func TestSharedStartArgs(t *testing.T) {
"--diag-addr=0.0.0.0:8080",
"--storage=file:///foo/bar",
"--proxy-server=example.teleport.sh:443",
"--pid-file=/run/tbot.pid",
"--diag-socket-for-updater=/var/lib/teleport/bot/debug.sock",
})
require.NoError(t, err)

Expand All @@ -56,6 +58,8 @@ func TestSharedStartArgs(t *testing.T) {
require.Equal(t, "0.0.0.0:8080", args.DiagAddr)
require.Equal(t, "file:///foo/bar", args.Storage)
require.Equal(t, "example.teleport.sh:443", args.ProxyServer)
require.Equal(t, "/run/tbot.pid", args.PIDFile)
require.Equal(t, "/var/lib/teleport/bot/debug.sock", args.DiagSocketForUpdater)

// Convert these args to a BotConfig.
cfg, err := LoadConfigWithMutators(&GlobalArgs{}, args)
Expand Down
7 changes: 7 additions & 0 deletions lib/tbot/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ type BotConfig struct {
// If not set, no diagnostics listener is created.
DiagAddr string `yaml:"diag_addr,omitempty"`

// DiagSocketForUpdater specifies the path to the diagnostics http service socket that
// should be exposed to the updater.
DiagSocketForUpdater string `yaml:"-"`

// PIDFile is the path to the PID file that should be created by the bot.
PIDFile string `yaml:"-"`

// ReloadCh allows a channel to be injected into the bot to trigger a
// renewal.
ReloadCh <-chan struct{} `yaml:"-"`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Group={{ .Group }}
Restart=always
RestartSec=5
Environment="TELEPORT_ANONYMOUS_TELEMETRY={{ if .AnonymousTelemetry }}1{{ else }}0{{ end }}"
ExecStart={{ .TBotPath }} start -c {{ .ConfigPath }}
ExecStart={{ .TBotPath }} start -c {{ .ConfigPath }}{{ with .DiagSocketForUpdater }} --diag-socket-for-updater={{ . }}{{ end }} --pid-file=/run/{{ .UnitName }}.pid
ExecReload=/bin/kill -HUP $MAINPID
PIDFile=/run/{{ .UnitName }}.pid
LimitNOFILE=524288
Expand Down
50 changes: 50 additions & 0 deletions lib/tbot/config/systemd/template.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Teleport
* Copyright (C) 2025 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 systemd

import (
_ "embed"
"text/template"
)

var (
//go:embed systemd.tmpl
templateData string

// Template is the systemd unit template for tbot..
Template = template.Must(template.New("").Parse(templateData))
)

// TemplateParams are the parameters for the systemd unit template.
type TemplateParams struct {
// UnitName is the name of the systemd unit.
UnitName string
// User is the user to run the service as.
User string
// Group is the group to run the service as.
Group string
// AnonymousTelemetry is whether to enable anonymous telemetry.
AnonymousTelemetry bool
// ConfigPath is the path to the tbot config file.
ConfigPath string
// TBotPath is the path to the tbot binary.
TBotPath string
// DiagSocketForUpdater is the path to the diag socket for the updater.
DiagSocketForUpdater string
}
33 changes: 32 additions & 1 deletion lib/tbot/internal/diagnostics/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"net/http/pprof"
"os"

"github.com/gravitational/trace"
"github.com/prometheus/client_golang/prometheus/promhttp"
Expand All @@ -45,6 +47,7 @@ func ServiceBuilder(cfg Config) bot.ServiceBuilder {
// Config contains configuration for the diagnostics service.
type Config struct {
Address string
Network string
PProfEnabled bool
Logger *slog.Logger
}
Expand All @@ -53,6 +56,9 @@ func (cfg *Config) CheckAndSetDefaults() error {
if cfg.Address == "" {
return trace.BadParameter("Address is required")
}
if cfg.Network == "" {
cfg.Network = "tcp"
}
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
Expand All @@ -70,6 +76,7 @@ func NewService(cfg Config, registry readyz.ReadOnlyRegistry) (*Service, error)
return &Service{
log: cfg.Logger,
diagAddr: cfg.Address,
diagNetwork: cfg.Network,
pprofEnabled: cfg.PProfEnabled,
statusRegistry: registry,
}, nil
Expand All @@ -80,6 +87,7 @@ func NewService(cfg Config, registry readyz.ReadOnlyRegistry) (*Service, error)
type Service struct {
log *slog.Logger
diagAddr string
diagNetwork string
pprofEnabled bool
statusRegistry readyz.ReadOnlyRegistry
}
Expand Down Expand Up @@ -131,7 +139,30 @@ func (s *Service) Run(ctx context.Context) error {
}
}()

if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
// When the process exits, the socket files are left behind (to cover
// forking scenarios). To guarantee there won't be errors like "address
// already in use", delete the file before starting the listener.
if s.diagNetwork == "unix" {
s.log.DebugContext(ctx, "Cleaning up socket file", "path", s.diagAddr)
if err := trace.ConvertSystemError(os.Remove(s.diagAddr)); err != nil && !trace.IsNotFound(err) {
s.log.WarnContext(ctx, "Failed to cleanup existing socket file", "error", err)
}
}

listener, err := net.Listen(s.diagNetwork, s.diagAddr)
if err != nil {
return trace.Wrap(err)
}

// The default behavior for unix listeners is to delete the file when the
// listener closes (unlinking). However, if the process forks, the file
// descriptor will be gone when its parent process exits, causing the new
// listener to have no socket file.
if unixListener, ok := listener.(*net.UnixListener); ok {
unixListener.SetUnlinkOnClose(false)
}

if err := srv.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
return err
}

Expand Down
5 changes: 5 additions & 0 deletions lib/tbot/readyz/readyz.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
package readyz

import (
"os"
"sync"
"time"

Expand Down Expand Up @@ -116,6 +117,7 @@ func (r *Registry) OverallStatus() *OverallStatus {

return &OverallStatus{
Status: status,
PID: os.Getpid(),
Services: services,
}
}
Expand Down Expand Up @@ -172,6 +174,9 @@ type OverallStatus struct {
// will be Unhealthy.
Status Status `json:"status"`

// PID is the process PID.
PID int `json:"pid"`

// Services contains the service-specific statuses.
Services map[string]*ServiceStatus `json:"services"`
}
Loading
Loading