diff --git a/cipher/aes.go b/cipher/aes.go index 1cc5c77..f714bad 100644 --- a/cipher/aes.go +++ b/cipher/aes.go @@ -10,31 +10,20 @@ import ( ) type ( - AesKeyFunc func(*[AesKeySize]byte) error + AesKey [32]byte AesGcm struct { - key [AesKeySize]byte + key AesKey } ) -const ( - AesKeySize = 32 -) - var ( ErrCipher = errors.New("cipher failure") ) // Non-nil returned error wraps [ErrCipher]. -func NewAesGcm(fn AesKeyFunc) (*AesGcm, error) { - aes := AesGcm{} - - err := fn(&aes.key) - if err != nil { - return nil, fmt.Errorf("%w: failed to get encryption key: %s", ErrCipher, err.Error()) - } - - return &aes, nil +func NewAesGcm(key AesKey) *AesGcm { + return &AesGcm{key: key} } // Non-nil returned error wraps [ErrCipher]. diff --git a/cmd/toolkit-assume-role/main.go b/cmd/toolkit-assume-role/main.go index 36dca7f..94ccc56 100644 --- a/cmd/toolkit-assume-role/main.go +++ b/cmd/toolkit-assume-role/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "flag" "fmt" "os" @@ -9,11 +10,12 @@ import ( "github.com/aws/aws-sdk-go-v2/config" "github.com/kxue43/cli-toolkit/creds" + "github.com/kxue43/cli-toolkit/key" "github.com/kxue43/cli-toolkit/terminal" ) var ( - cmd = creds.AssumeRoleCmd{} + input = creds.ProcessInput{} helpMsg = `Usage: %s -mfa-serial=STRING -profile=STRING [flags] @@ -27,11 +29,11 @@ Flags: ) func registerFlagsAndHelp() { - flag.StringVar(&cmd.MFASerial, "mfa-serial", "", "ARN of the virtual MFA to use when assuming the role.") - flag.StringVar(&cmd.Profile, "profile", "", "Source profile used for assuming the role.") - flag.StringVar(&cmd.Region, "region", "us-east-1", "The regional STS service endpoint to call.") - flag.StringVar(&cmd.RoleSessionName, "role-session-name", "ToolkitCLI", "Role session name.") - flag.Int64Var(&cmd.DurationSeconds, "duration-seconds", 3600, "Role session duration seconds.") + flag.StringVar(&input.MFASerial, "mfa-serial", "", "ARN of the virtual MFA to use when assuming the role.") + flag.StringVar(&input.Profile, "profile", "", "Source profile used for assuming the role.") + flag.StringVar(&input.Region, "region", "us-east-1", "The regional STS service endpoint to call.") + flag.StringVar(&input.RoleSessionName, "role-session-name", "ToolkitCLI", "Role session name.") + flag.Int64Var(&input.DurationSeconds, "duration-seconds", 3600, "Role session duration seconds.") flag.Usage = func() { _, _ = fmt.Fprintf(flag.CommandLine.Output(), helpMsg, os.Args[0]) @@ -40,15 +42,31 @@ func registerFlagsAndHelp() { } } +func validateInput(input creds.ProcessInput) error { + if input.MFASerial == "" { + return errors.New("-mfa-serial is required") + } + + if input.Profile == "" { + return errors.New("-profile is required") + } + + if input.DurationSeconds > 14400 { + return errors.New("-duration-seconds cannot exceed 14400, i.e. 4 hours") + } + + if input.RoleArn == "" { + return errors.New("the argument is required") + } + + return nil +} + func main() { exitCode := 0 defer func() { os.Exit(exitCode) }() - registerFlagsAndHelp() - - flag.Parse() - device, err := os.OpenFile("/dev/tty", os.O_RDWR|os.O_SYNC, 0600) if err != nil { exitCode = 1 @@ -65,18 +83,26 @@ func main() { } }() - ctx := context.Background() + registerFlagsAndHelp() + + flag.Parse() + + if args := flag.Args(); len(args) > 0 { + input.RoleArn = args[0] + } - err = cmd.ValidateInputs(flag.Args()) + err = validateInput(input) if err != nil { - tty.Print(err.Error()) + tty.Println(err.Error()) exitCode = 1 return } - cfg, err := config.LoadDefaultConfig(ctx, config.WithSharedConfigProfile(cmd.Profile), config.WithRegion(cmd.Region)) + ctx := context.Background() + + cfg, err := config.LoadDefaultConfig(ctx, config.WithSharedConfigProfile(input.Profile), config.WithRegion(input.Region)) if err != nil { tty.Printf("failed to load AWS SDK configuration: %s\n", err) @@ -85,14 +111,13 @@ func main() { return } - err = cmd.Init(tty, cfg) - if err != nil { - tty.Print(err.Error()) - } + kp := key.NewKeyringProvider("kxue43.toolkit.assume-role", "cache-encryption-key") + + processor := creds.NewProcessor(input, tty, cfg, kp) - err = cmd.Run(ctx, os.Stdout) + err = processor.Run(ctx, os.Stdout) if err != nil { - tty.Print(err.Error()) + tty.Println(err.Error()) exitCode = 1 diff --git a/creds/cache.go b/creds/cache.go index 30bb7d3..0107d67 100644 --- a/creds/cache.go +++ b/creds/cache.go @@ -17,14 +17,6 @@ import ( ) type ( - CredentialProcessOutput struct { - AccessKeyId string `json:"AccessKeyId"` - SecretAccessKey string `json:"SecretAccessKey"` - SessionToken string `json:"SessionToken"` - Expiration string `json:"Expiration"` - Version int `json:"Version"` - } - cacher struct { logger logger cipher *cipher.AesGcm @@ -86,17 +78,21 @@ func decodeFromFileName(roleArn, fileName string) (ts time.Time, err error) { } // Non-nil returned error wraps [ErrCacheInit]. -func newCacher(logger logger, fn cipher.AesKeyFunc) (*cacher, error) { - aes, err := cipher.NewAesGcm(fn) +func newCacher(logger logger, kp KeyProvider) (*cacher, error) { + home, err := os.UserHomeDir() if err != nil { - return nil, fmt.Errorf("%w: %s", ErrCacheInit, err.Error()) + return nil, fmt.Errorf("%w: could not locate user home directory", ErrCacheInit) } - home, err := os.UserHomeDir() + var key cipher.AesKey + + err = kp.Write(key[:]) if err != nil { - return nil, fmt.Errorf("%w: could not locate user home directory", ErrCacheInit) + return nil, fmt.Errorf("%w: failed to obtain encryption key for cache file: %s", ErrCacheInit, err.Error()) } + aes := cipher.NewAesGcm(key) + cacheDir := filepath.Join(home, ".aws", "toolkit-cache") info, err := os.Stat(cacheDir) @@ -119,7 +115,7 @@ func newCacher(logger logger, fn cipher.AesKeyFunc) (*cacher, error) { // Non-nil returned error wraps [ErrInvalidCredential] or [ErrCacheSave]. // contents is valid for use as long as it's not nil. -func (c *cacher) Save(roleArn string, output *CredentialProcessOutput) (contents []byte, err error) { +func (c *cacher) save(roleArn string, output *ProcessOutput) (contents []byte, err error) { ts, err := time.Parse(time.RFC3339, output.Expiration) if err != nil { return nil, fmt.Errorf("%w: expiration %q is not of the right format: %s", ErrInvalidCredential, output.Expiration, err.Error()) @@ -144,9 +140,9 @@ func (c *cacher) Save(roleArn string, output *CredentialProcessOutput) (contents return contents, nil } -// Retrieve tries to retrieve AWS credentials from cache files. +// retrieve tries to retrieve AWS credentials from cache files. // It succeeded if and only if the returned byte slice is not nil. -func (c *cacher) Retrieve(roleArn string) (contents []byte) { +func (c *cacher) retrieve(roleArn string) (contents []byte) { max := time.Now().Add(time.Minute * 10) actives := make(cacheFileSlice, 0) pattern := filepath.Join(c.cacheDir, fmt.Sprintf(`%s-*`, getPrefix(roleArn))) diff --git a/creds/command.go b/creds/command.go index 7e61988..2cdecd1 100644 --- a/creds/command.go +++ b/creds/command.go @@ -6,8 +6,6 @@ package creds import ( "bytes" "context" - "crypto/rand" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -17,23 +15,12 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/credentials/stscreds" "github.com/aws/aws-sdk-go-v2/service/sts" - "github.com/zalando/go-keyring" - "github.com/kxue43/cli-toolkit/cipher" "github.com/kxue43/cli-toolkit/terminal" ) type ( - logger interface { - Printf(string, ...any) - Print(...any) - } - - AssumeRoleCmd struct { - logger logger - prompter prompter - cacher *cacher - client *sts.Client + ProcessInput struct { RoleArn string MFASerial string Profile string @@ -42,59 +29,36 @@ type ( DurationSeconds int64 } - prompter struct { - io.ReadWriter + ProcessOutput struct { + AccessKeyId string `json:"AccessKeyId"` + SecretAccessKey string `json:"SecretAccessKey"` + SessionToken string `json:"SessionToken"` + Expiration string `json:"Expiration"` + Version int `json:"Version"` } -) - -const ( - service = "kxue43.toolkit.assume-role" - user = "cache-encryption-key" -) - -var ( - ErrInvalidInput = errors.New("invalid CLI input") - - fromKeyring cipher.AesKeyFunc = keyringGet -) -func generateKey(key *[cipher.AesKeySize]byte) (string, error) { - if _, err := io.ReadFull(rand.Reader, key[:]); err != nil { - return "", fmt.Errorf("failed to generate encryption key: %s", err.Error()) + Processor struct { + logger logger + cacher *cacher + retriever *stscreds.AssumeRoleProvider + roleArn string } - return base64.StdEncoding.EncodeToString(key[:]), nil -} - -func keyringGet(key *[cipher.AesKeySize]byte) error { - secret, err := keyring.Get(service, user) - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - return fmt.Errorf("failed to retrieve encryption key: secret exists but cannot be read: %s", err.Error()) - } else if errors.Is(err, keyring.ErrNotFound) { - secret, err = generateKey(key) - if err != nil { - return err - } - - err = keyring.Set(service, user, secret) - if err != nil { - return fmt.Errorf("failed to save newly generated encryption key: %s", err.Error()) - } - - return nil + logger interface { + Printf(string, ...any) + Println(...any) } - decoded, err := base64.StdEncoding.DecodeString(secret) - if err != nil || len(decoded) != len(key) { - return fmt.Errorf("saved encryption key has been corrupted: %s", err.Error()) + KeyProvider interface { + Write([]byte) error } - copy(key[:], decoded) - - return nil -} + mfaPrompter struct { + io.ReadWriter + } +) -func (c prompter) MFAToken() (code string, err error) { +func (c mfaPrompter) token() (code string, err error) { _, err = io.WriteString(c, "MFA code: ") if err != nil { return "", fmt.Errorf("failed to prompt for MFA code: %w", err) @@ -110,49 +74,39 @@ func (c prompter) MFAToken() (code string, err error) { return string(bytes.TrimSpace(buf[:n])), nil } -// Non-nil returned error wraps [ErrInvalidInput]. -func (a *AssumeRoleCmd) ValidateInputs(args []string) error { - if a.MFASerial == "" { - return fmt.Errorf("%w: -mfa-serial is required", ErrInvalidInput) - } +func NewProcessor(input ProcessInput, tty *terminal.TTY, cfg aws.Config, kp KeyProvider) *Processor { + var err error - if a.Profile == "" { - return fmt.Errorf("%w: -profile is required", ErrInvalidInput) - } + p := Processor{} - if a.DurationSeconds > 14400 { - return fmt.Errorf("%w: -duration-seconds cannot exceed 14400, i.e. 4 hours", ErrInvalidInput) - } + prompter := mfaPrompter{ReadWriter: tty} - if len(args) == 0 { - return fmt.Errorf("%w: the argument is required", ErrInvalidInput) - } + p.logger = tty - a.RoleArn = args[0] - - return nil -} - -// Non-nil returned error wraps [ErrCacheInit]. -func (a *AssumeRoleCmd) Init(tty *terminal.TTY, cfg aws.Config) (err error) { - a.prompter = prompter{ReadWriter: tty} - - a.logger = tty + p.cacher, err = newCacher(p.logger, kp) + if err != nil { + p.logger.Println(err.Error()) + } - a.client = sts.NewFromConfig(cfg) + p.retriever = stscreds.NewAssumeRoleProvider(sts.NewFromConfig(cfg), input.RoleArn, func(o *stscreds.AssumeRoleOptions) { + o.RoleSessionName = input.RoleSessionName + o.Duration = time.Second * time.Duration(input.DurationSeconds) + o.SerialNumber = aws.String(input.MFASerial) + o.TokenProvider = prompter.token + }) - a.cacher, err = newCacher(a.logger, fromKeyring) + p.roleArn = input.RoleArn - return err + return &p } // Non-nil returned error means failure. -func (a *AssumeRoleCmd) Run(ctx context.Context, dest io.Writer) (err error) { +func (a *Processor) Run(ctx context.Context, dest io.Writer) (err error) { // Output of the AWS CLI credential process. var output []byte if a.cacher != nil { - output = a.cacher.Retrieve(a.RoleArn) + output = a.cacher.retrieve(a.roleArn) if output != nil { _, err = dest.Write(output) if err != nil { @@ -163,20 +117,13 @@ func (a *AssumeRoleCmd) Run(ctx context.Context, dest io.Writer) (err error) { } } - provider := stscreds.NewAssumeRoleProvider(a.client, a.RoleArn, func(o *stscreds.AssumeRoleOptions) { - o.RoleSessionName = a.RoleSessionName - o.Duration = time.Second * time.Duration(a.DurationSeconds) - o.SerialNumber = aws.String(a.MFASerial) - o.TokenProvider = a.prompter.MFAToken - }) - - stsCreds, err := provider.Retrieve(ctx) + stsCreds, err := a.retriever.Retrieve(ctx) if err != nil { return fmt.Errorf("failed to retrieve STS credentials: %s", err.Error()) } // structured output - soutput := CredentialProcessOutput{ + soutput := ProcessOutput{ AccessKeyId: stsCreds.AccessKeyID, SecretAccessKey: stsCreds.SecretAccessKey, SessionToken: stsCreds.SessionToken, @@ -185,11 +132,11 @@ func (a *AssumeRoleCmd) Run(ctx context.Context, dest io.Writer) (err error) { } if a.cacher != nil { - output, err = a.cacher.Save(a.RoleArn, &soutput) + output, err = a.cacher.save(a.roleArn, &soutput) if errors.Is(err, ErrInvalidCredential) { return err } else if err != nil { - a.logger.Print(err.Error()) + a.logger.Println(err.Error()) } } diff --git a/creds/command_test.go b/creds/command_test.go index 42763e6..0d20813 100644 --- a/creds/command_test.go +++ b/creds/command_test.go @@ -3,14 +3,15 @@ package creds import ( "bytes" "context" + "crypto/rand" "encoding/json" + "fmt" + "io" "os" "path/filepath" "testing" "time" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/aws/aws-sdk-go-v2/service/sts/types" "github.com/awsdocs/aws-doc-sdk-examples/gov2/testtools" @@ -31,8 +32,32 @@ type ( TempDir string home string } + + AesKeyProvider struct { + key cipher.AesKey + } ) +func NewAesKeyProvider() (*AesKeyProvider, error) { + p := AesKeyProvider{} + + if _, err := io.ReadFull(rand.Reader, p.key[:]); err != nil { + return nil, fmt.Errorf("failed to generate encryption key: %s", err.Error()) + } + + return &p, nil +} + +func (p *AesKeyProvider) Write(key []byte) error { + if len(key) != len(p.key) { + return fmt.Errorf("the input byte slice should have length %d, but its length is %d.", len(p.key), len(key)) + } + + copy(key, p.key[:]) + + return nil +} + func (fd *MockTerminal) Read(p []byte) (n int, err error) { return fd.r.Read(p) } @@ -67,26 +92,17 @@ func (m *HomeDirMocker) TearDown(t *testing.T) { } func TestAssumeRoleCmdRun(t *testing.T) { - var aesKey [cipher.AesKeySize]byte - - _, err := generateKey(&aesKey) - require.NoError(t, err, "should be able to generate a random encryption key") - - fromKeyring = func(key *[cipher.AesKeySize]byte) error { - t.Helper() - - copy(key[:], aesKey[:]) - - return nil - } - - defer func() { fromKeyring = keyringGet }() - hdm := HomeDirMocker{} hdm.SetUp(t) defer hdm.TearDown(t) + kp, err1 := NewAesKeyProvider() + require.NoError(t, err1, "should be able to create AesKeyProvider during tests") + + stubber := testtools.NewStubber() + defer testtools.ExitTest(stubber, t) + roleArn := "role-arn" expiration := time.Now().Add(10 * time.Hour) @@ -96,7 +112,8 @@ func TestAssumeRoleCmdRun(t *testing.T) { t.Run("Happy path no cache", func(t *testing.T) { // Create an AssumeRoleCmd with some fields already filled with valid values. // We don't test CLI parsing in unit tests. - cmd := AssumeRoleCmd{ + input := ProcessInput{ + RoleArn: roleArn, MFASerial: "mfa-serial", Profile: "profile", Region: "us-east-1", @@ -115,7 +132,7 @@ func TestAssumeRoleCmdRun(t *testing.T) { dest := MockTerminal{} - soutput := CredentialProcessOutput{ + soutput := ProcessOutput{ AccessKeyId: "access-key-id", SecretAccessKey: "secret-access-key", SessionToken: "session-token", @@ -123,16 +140,13 @@ func TestAssumeRoleCmdRun(t *testing.T) { Version: 1, } - stubber := testtools.NewStubber() - defer testtools.ExitTest(stubber, t) - stubber.Add(testtools.Stub{ OperationName: "AssumeRole", Input: &sts.AssumeRoleInput{ DurationSeconds: &duration, RoleArn: &roleArn, - RoleSessionName: &cmd.RoleSessionName, - SerialNumber: &cmd.MFASerial, + RoleSessionName: &input.RoleSessionName, + SerialNumber: &input.MFASerial, TokenCode: &token, }, Output: &sts.AssumeRoleOutput{ @@ -148,22 +162,9 @@ func TestAssumeRoleCmdRun(t *testing.T) { ctx := context.Background() - err = cmd.ValidateInputs([]string{roleArn}) - require.NoError(t, err, "fields of AssumeRoleCmd should validate without error") - - // Use a mocked credentials provider so that we can load config without error during tests. - mockCredsProvider := credentials.NewStaticCredentialsProvider("dummy", "dummy", "dummy") + processor := NewProcessor(input, tty, *stubber.SdkConfig, kp) - cfg, err := config.LoadDefaultConfig(ctx, config.WithCredentialsProvider(mockCredsProvider), config.WithRegion(cmd.Region)) - require.NoError(t, err, "should be able to load config using a mocked credentials provider") - - err = cmd.Init(tty, cfg) - require.NoError(t, err, "should be able to init caching without error") - - // Stub the STS client object. - cmd.client = sts.NewFromConfig(*stubber.SdkConfig) - - err = cmd.Run(ctx, &dest) + err = processor.Run(ctx, &dest) require.NoError(t, err, "should be able to run command without error") cacheFilePath := filepath.Join(hdm.TempDir, ".aws", "toolkit-cache", encodeToFileName(roleArn, expiration)) @@ -176,10 +177,10 @@ func TestAssumeRoleCmdRun(t *testing.T) { rawContents, err := os.ReadFile(filepath.Clean(cacheFilePath)) require.NoError(t, err, "should be able to read cache file raw content without error") - rawContents, err = cmd.cacher.cipher.Decrypt(rawContents) + rawContents, err = processor.cacher.cipher.Decrypt(rawContents) require.NoError(t, err, "should be able to decrypt cache file without error") - var sCachedContents CredentialProcessOutput + var sCachedContents ProcessOutput err = json.Unmarshal(rawContents, &sCachedContents) require.NoError(t, err, "should be able to unmarshal decrypted cache file without error") @@ -198,7 +199,8 @@ func TestAssumeRoleCmdRun(t *testing.T) { }) t.Run("Happy path cache hits", func(t *testing.T) { - cmd := AssumeRoleCmd{ + input := ProcessInput{ + RoleArn: roleArn, MFASerial: "mfa-serial", Profile: "profile", Region: "us-east-1", @@ -214,22 +216,9 @@ func TestAssumeRoleCmdRun(t *testing.T) { ctx := context.Background() - err := cmd.ValidateInputs([]string{roleArn}) - require.NoError(t, err, "fields of AssumeRoleCmd should validate without error") - - // Use a mocked credentials provider so that we can load config without error during tests. - mockCredsProvider := credentials.NewStaticCredentialsProvider("dummy", "dummy", "dummy") - - cfg, err := config.LoadDefaultConfig(ctx, config.WithCredentialsProvider(mockCredsProvider), config.WithRegion(cmd.Region)) - require.NoError(t, err, "should be able to load config using a mocked credentials provider") - - err = cmd.Init(tty, cfg) - require.NoError(t, err, "should be able to init caching without error") - - // Stub the STS client object. When cache hits, the STS client shouldn't have been used at all. - cmd.client = nil + processor := NewProcessor(input, tty, *stubber.SdkConfig, kp) - err = cmd.Run(ctx, &dest) + err := processor.Run(ctx, &dest) require.NoError(t, err, "should be able to run command without error") cacheFilePath := filepath.Join(hdm.TempDir, ".aws", "toolkit-cache", encodeToFileName(roleArn, expiration)) @@ -242,15 +231,15 @@ func TestAssumeRoleCmdRun(t *testing.T) { rawContents, err := os.ReadFile(filepath.Clean(cacheFilePath)) require.NoError(t, err, "should be able to read cache file raw content without error") - rawContents, err = cmd.cacher.cipher.Decrypt(rawContents) + rawContents, err = processor.cacher.cipher.Decrypt(rawContents) require.NoError(t, err, "should be able to decrypt cache file without error") - var sCachedContents CredentialProcessOutput + var sCachedContents ProcessOutput err = json.Unmarshal(rawContents, &sCachedContents) require.NoError(t, err, "should be able to unmarshal decrypted cache file without error") - var stdoutContents CredentialProcessOutput + var stdoutContents ProcessOutput err = json.Unmarshal(dest.w.Bytes(), &stdoutContents) require.NoError(t, err, "should be able to unmarshal outputs to stdout without error") diff --git a/key/key.go b/key/key.go new file mode 100644 index 0000000..5c6a33b --- /dev/null +++ b/key/key.go @@ -0,0 +1,66 @@ +package key + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + + "github.com/zalando/go-keyring" +) + +type ( + KeyringProvider struct { + service string + user string + } +) + +func NewKeyringProvider(service, user string) KeyringProvider { + return KeyringProvider{service: service, user: user} +} + +func (p KeyringProvider) Write(key []byte) (err error) { + var encoded string + + encoded, err = keyring.Get(p.service, p.user) + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("failed to retrieve encryption key: secret exists but cannot be read: %s", err.Error()) + } else if errors.Is(err, keyring.ErrNotFound) { + internal := make([]byte, len(key)) + + encoded, err = generateKey(internal) + if err != nil { + return err + } + + err = keyring.Set(p.service, p.user, encoded) + if err != nil { + return fmt.Errorf("failed to save newly generated encryption key: %s", err.Error()) + } + + copy(key, internal) + + return nil + } + + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return fmt.Errorf("failed to base64 decode saved encryption key: %s", err.Error()) + } else if len(decoded) != len(key) { + return fmt.Errorf("saved encryption key has length %d while the input byte slice has length %d", len(decoded), len(key)) + } + + copy(key, decoded) + + return nil +} + +func generateKey(key []byte) (string, error) { + if _, err := io.ReadFull(rand.Reader, key); err != nil { + return "", fmt.Errorf("failed to generate encryption key: %s", err.Error()) + } + + return base64.StdEncoding.EncodeToString(key), nil +} diff --git a/terminal/tty.go b/terminal/tty.go index 1ba7944..0c6611c 100644 --- a/terminal/tty.go +++ b/terminal/tty.go @@ -45,11 +45,11 @@ func (t *TTY) Printf(format string, v ...any) { t.logger.Printf(format, v...) } -func (t *TTY) Print(v ...any) { +func (t *TTY) Println(v ...any) { t.mux.Lock() defer t.mux.Unlock() - t.logger.Print(v...) + t.logger.Println(v...) } func (t *TTY) FlushLogs() error {