Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func main() {

cfg, fields, err := agentd.LoadConfigFromEnv()
if err != nil {
log.WithError(err).WithFields(fields).Fatal("Failed to load de configuration from the environmental variables")
agentd.FatalInvalidConfig[agentd.Config](fields, err)
}

cfg.Version = AgentVersion
Expand Down Expand Up @@ -113,9 +113,11 @@ func main() {

if err := ag.Authorize(); err != nil {
log.WithError(err).WithFields(log.Fields{
"version": AgentVersion,
"configuration": cfg,
}).Fatal("Failed to initialize agent")
"version": AgentVersion,
"server_address": cfg.ServerAddress,
"tenant_id": cfg.TenantID,
"tenant_origin": cfg.TenantOrigin,
}).Fatal("Failed to authorize the device")
}

ctx := cmd.Context()
Expand Down Expand Up @@ -225,9 +227,7 @@ func main() {

cfg, fields, err := LoadConfigConnectorFromEnv()
if err != nil {
log.WithError(err).
WithFields(fields).
Fatal("Failed to load de configuration from the environmental variables")
agentd.FatalInvalidConfig[ConfigConnector](fields, err)
}

logger := log.WithFields(
Expand Down Expand Up @@ -343,7 +343,7 @@ waits until the device is accepted, rejected, or the code expires.`,

cfg, fields, err := agentd.LoadConfigFromEnv()
if err != nil {
log.WithError(err).WithFields(fields).Fatal("Failed to load the configuration from the environmental variables")
agentd.FatalInvalidConfig[agentd.Config](fields, err)
}

cfg.Version = AgentVersion
Expand Down
115 changes: 105 additions & 10 deletions agent/pkg/agentd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"sort"
"strings"
"sync/atomic"
"time"
Expand Down Expand Up @@ -93,7 +95,11 @@ type Config struct {
// It is optional: when empty (and no tenant was persisted from a previous
// pairing), the agent boots into pairing mode and waits for a user to
// accept it into a namespace, learning the tenant from the server.
TenantID string `env:"TENANT_ID"`
TenantID string `env:"TENANT_ID" validate:"omitempty,uuid"`

// TenantOrigin records where TenantID came from. It is not read from the environment;
// [LoadConfigFromEnv] and [Agent.SetTenantID] set it as they resolve the tenant.
TenantOrigin TenantOrigin

// PairingCode is a pre-authorized pairing code handed to the agent at install
// time (minted from the console's Add Device page). When set and no tenant is
Expand Down Expand Up @@ -164,30 +170,52 @@ func (c *Config) HasNamespaceCredential() bool {
return c.TenantID != "" || c.InstallKey != ""
}

func (c *Config) credential() string {
if c.TenantID == "" && c.InstallKey != "" {
return "the install key"
}

switch c.TenantOrigin {
case TenantFromEnvironment:
return fmt.Sprintf("the tenant %s from SHELLHUB_TENANT_ID", c.TenantID)
case TenantFromFile:
return fmt.Sprintf("the tenant %s persisted at %s", c.TenantID, TenantFilePath(c.PrivateKey))
case TenantFromPairing:
return fmt.Sprintf("the tenant %s learned from pairing", c.TenantID)
case TenantFromNowhere:
return "no namespace credential"
}

return "the tenant " + c.TenantID
}

// LoadConfigFromEnv reads the agent's configuration from SHELLHUB_-prefixed environment
// variables, falling back to the .env file next to the binary when one is present.
//
// The second return value carries the environment as parsed, for callers that log it.
// A tenant persisted by a previous pairing is adopted before validation, so a malformed tenant is
// refused whether it came from the environment or from the file, rather than being carried into an
// authorization the server can only reject.
//
// The second return value carries the fields that failed validation, for callers that log them.
func LoadConfigFromEnv() (*Config, map[string]any, error) {
applyEnvFileFallback(defaultEnvFilePath)

cfg, err := envs.ParseWithPrefix[Config]("SHELLHUB_")
cfg, err := envs.ParseWithPrefix[Config](envPrefix)
if err != nil {
log.Error("failed to parse the configuration")

return nil, nil, err
}

if ok, fields, err := validator.New().StructWithFields(cfg); err != nil || !ok {
log.WithFields(fields).Error("failed to validate the configuration loaded from envs")

return nil, fields, err
if cfg.TenantID != "" {
cfg.TenantOrigin = TenantFromEnvironment
}

if persisted, err := ReadPersistedTenant(TenantFilePath(cfg.PrivateKey)); err == nil && persisted != "" {
switch {
case cfg.TenantID == "":
cfg.TenantID = persisted
cfg.TenantOrigin = TenantFromFile
case cfg.TenantID != persisted:
log.WithFields(log.Fields{
"env_tenant": cfg.TenantID,
Expand All @@ -196,9 +224,75 @@ func LoadConfigFromEnv() (*Config, map[string]any, error) {
}
}

if ok, fields, err := validator.New().StructWithFields(cfg); err != nil || !ok {
return nil, fields, err
}

return cfg, nil, nil
}

const envPrefix = "SHELLHUB_"

// FatalInvalidConfig reports every invalid setting in fields by the environment variable an
// operator sets, then exits the process. T is the configuration the fields came from. It does not
// return.
func FatalInvalidConfig[T any](fields map[string]any, err error) {
for _, message := range InvalidConfigMessages[T](fields) {
log.Error(message)
}

log.WithError(err).Fatal("Failed to load the configuration from the environment variables")
}

// InvalidConfigMessages turns the field map [LoadConfigFromEnv] returns into one message per
// invalid setting, naming the environment variable an operator sets rather than the struct field
// the validator reported. T is the configuration the map came from, read for its env tags; a field
// it does not carry is named as it stands. No value is ever included, because some settings are
// credentials and a log line is not where those belong.
func InvalidConfigMessages[T any](fields map[string]any) []string {
if len(fields) == 0 {
return nil
}

structure := reflect.TypeFor[T]()
messages := make([]string, 0, len(fields))

for field, rule := range fields {
messages = append(messages, configEnvName(structure, field)+" "+requirementOf(rule))
}

sort.Strings(messages)

return messages
}

func configEnvName(structure reflect.Type, field string) string {
structField, ok := structure.FieldByName(field)
if !ok {
return field
}

name, _, _ := strings.Cut(structField.Tag.Get("env"), ",")
if name == "" {
return field
}

return envPrefix + name
}

func requirementOf(rule any) string {
switch rule {
case "required":
return "is required"
case "uuid", "uuid4":
return "must be a UUID"
case "min", "max":
return "is out of range"
default:
return fmt.Sprintf("is invalid (%v)", rule)
}
}

// Agent is a device's connection to a ShellHub server: it authenticates, keeps the device
// record current, and serves the SSH sessions the server routes to it.
//
Expand Down Expand Up @@ -340,7 +434,7 @@ func (a *Agent) Authorize() error {
}

if err := a.authorize(); err != nil {
return errors.Wrap(err, "failed to authorize device")
return errors.Wrap(err, "failed to authorize device with "+a.config.credential())
}

if a.config.TenantID == "" {
Expand All @@ -363,10 +457,11 @@ func (a *Agent) Authorize() error {
return nil
}

// SetTenantID injects the tenant learned from a pairing so the agent can be
// authorized.
// SetTenantID injects the tenant learned from a pairing so the agent can be authorized, and
// attributes it to that pairing so a later recovery can tell it from a tenant an operator set.
func (a *Agent) SetTenantID(tenant string) {
a.config.TenantID = tenant
a.config.TenantOrigin = TenantFromPairing
}

// ClearPairingCode drops a pre-authorized pairing code after the server rejected
Expand Down
Loading
Loading