diff --git a/cmd/bbctl/authconfig.go b/cmd/bbctl/authconfig.go index 33bc77b..ed4e73e 100644 --- a/cmd/bbctl/authconfig.go +++ b/cmd/bbctl/authconfig.go @@ -24,15 +24,20 @@ var envs = map[string]string{ } type EnvConfig struct { - ClusterID string `json:"cluster_id"` - Username string `json:"username"` - AccessToken string `json:"access_token"` - BridgeDataDir string `json:"bridge_data_dir"` - DatabaseDir string `json:"database_dir,omitempty"` + ClusterID string `json:"cluster_id"` + Username string `json:"username"` + AccessToken string `json:"access_token"` + BridgeDataDir string `json:"bridge_data_dir"` + DatabaseDir string `json:"database_dir,omitempty"` + DesktopDataDir string `json:"desktop_data_dir,omitempty"` } func (ec *EnvConfig) HasCredentials() bool { - return strings.HasPrefix(ec.AccessToken, "syt_") + return strings.HasPrefix(ec.AccessToken, "syt_") || strings.HasPrefix(ec.AccessToken, "bat_") +} + +func (ec *EnvConfig) UsesDesktopLogin() bool { + return ec.DesktopDataDir != "" } type EnvConfigs map[string]*EnvConfig diff --git a/cmd/bbctl/desktopauth.go b/cmd/bbctl/desktopauth.go new file mode 100644 index 0000000..73a18b1 --- /dev/null +++ b/cmd/bbctl/desktopauth.go @@ -0,0 +1,186 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/urfave/cli/v2" + "go.mau.fi/util/dbutil" + + "github.com/beeper/bridge-manager/api/beeperapi" + + _ "go.mau.fi/util/dbutil/litestream" +) + +func desktopLoginFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "profile", + EnvVars: []string{"BEEPER_PROFILE"}, + Usage: "Beeper Desktop profile name, equivalent to BEEPER_PROFILE in Desktop", + }, + &cli.StringFlag{ + Name: "desktop-data-dir", + EnvVars: []string{"BBCTL_DESKTOP_DATA_DIR"}, + Usage: "Read credentials from this Beeper Desktop user data directory", + }, + } +} + +type DesktopAccount struct { + UserID string + AccessToken string + Homeserver string +} + +func getDesktopDataDir(ctx *cli.Context) (string, error) { + if dataDir := ctx.String("desktop-data-dir"); dataDir != "" { + return dataDir, nil + } + return resolveDesktopDataDir(ctx.String("profile")) +} + +func resolveDesktopDataDir(profile string) (string, error) { + appName := "BeeperTexts" + if profile != "" { + appName += "-" + profile + } + dataDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dataDir, appName), nil +} + +func getLoginDesktopAccountDBPath(ctx *cli.Context) (string, error) { + dataDir, err := getDesktopDataDir(ctx) + if err != nil { + return "", fmt.Errorf("failed to resolve desktop data directory: %w", err) + } + return filepath.Join(dataDir, "account.db"), nil +} + +func readDesktopAccount(ctx context.Context, dbPath string) (account *DesktopAccount, err error) { + dbURI := (&url.URL{ + Scheme: "file", + Path: filepath.ToSlash(dbPath), + RawQuery: "mode=ro", + }).String() + db, err := dbutil.NewWithDialect(dbURI, "sqlite3-fk-wal") + if err != nil { + return nil, fmt.Errorf("failed to open desktop account database: %w", err) + } + defer func() { + if closeErr := db.Close(); closeErr != nil { + if err != nil { + err = fmt.Errorf("%w; failed to close desktop account database: %v", err, closeErr) + } else { + err = fmt.Errorf("failed to close desktop account database: %w", closeErr) + } + } + }() + + var desktopAccount DesktopAccount + err = db.QueryRow(ctx, "SELECT user_id, access_token, homeserver FROM account LIMIT 1"). + Scan(&desktopAccount.UserID, &desktopAccount.AccessToken, &desktopAccount.Homeserver) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("desktop account database has no logged-in account") + } else if err != nil { + return nil, fmt.Errorf("failed to read desktop account database: %w", err) + } else if desktopAccount.UserID == "" || desktopAccount.AccessToken == "" { + return nil, fmt.Errorf("desktop account database has incomplete credentials") + } + return &desktopAccount, nil +} + +func desktopAccountHomeserverDomain(account *DesktopAccount) (string, error) { + if account.Homeserver == "" { + return "", nil + } + parsed, err := url.Parse(account.Homeserver) + if err != nil { + return "", fmt.Errorf("desktop account has invalid homeserver URL %q: %w", account.Homeserver, err) + } + return strings.TrimPrefix(parsed.Host, "matrix."), nil +} + +func envForHomeserverDomain(domain string) string { + for env, envDomain := range envs { + if domain == envDomain { + return env + } + } + return "" +} + +func configureDesktopLogin(ctx *cli.Context, account *DesktopAccount) (string, string, error) { + homeserver, err := desktopAccountHomeserverDomain(account) + if err != nil { + return "", "", err + } + env := ctx.String("env") + if homeserverEnv := envForHomeserverDomain(homeserver); homeserverEnv != "" { + env = homeserverEnv + homeserver = envs[env] + } else if homeserver == "" { + homeserver = ctx.String("homeserver") + } + + whoami, err := beeperapi.Whoami(homeserver, account.AccessToken) + if err != nil { + return "", "", fmt.Errorf("failed to verify desktop credentials with whoami: %w", err) + } + + cfg := GetConfig(ctx) + envCfg := cfg.Environments.Get(env) + envCfg.ClusterID = whoami.UserInfo.BridgeClusterID + envCfg.Username = whoami.UserInfo.Username + envCfg.AccessToken = account.AccessToken + envCfg.BridgeDataDir = filepath.Join(UserDataDir, "bbctl", env) + dataDir, err := getDesktopDataDir(ctx) + if err != nil { + return "", "", fmt.Errorf("failed to resolve desktop data directory: %w", err) + } + envCfg.DesktopDataDir = dataDir + err = cfg.Save() + if err != nil { + return "", "", fmt.Errorf("failed to save config: %w", err) + } + + return env, homeserver, nil +} + +func loadDesktopLogin(ctx *cli.Context, envConfig *EnvConfig) error { + if envConfig.DesktopDataDir == "" { + return nil + } + dbPath := filepath.Join(envConfig.DesktopDataDir, "account.db") + account, err := readDesktopAccount(ctx.Context, dbPath) + if err != nil { + return err + } + homeserver, err := desktopAccountHomeserverDomain(account) + if err != nil { + return err + } + if homeserver == "" { + homeserver = ctx.String("homeserver") + } + whoami, err := beeperapi.Whoami(homeserver, account.AccessToken) + if err != nil { + return fmt.Errorf("failed to verify desktop credentials with whoami: %w", err) + } + envConfig.ClusterID = whoami.UserInfo.BridgeClusterID + envConfig.Username = whoami.UserInfo.Username + envConfig.AccessToken = account.AccessToken + if envConfig.BridgeDataDir == "" { + envConfig.BridgeDataDir = filepath.Join(UserDataDir, "bbctl", ctx.String("env")) + } + return nil +} diff --git a/cmd/bbctl/login-email.go b/cmd/bbctl/login-email.go index 51ac065..6c439bd 100644 --- a/cmd/bbctl/login-email.go +++ b/cmd/bbctl/login-email.go @@ -11,29 +11,85 @@ import ( "maunium.net/go/mautrix" "github.com/beeper/bridge-manager/api/beeperapi" - "github.com/beeper/bridge-manager/cli/interactive" ) var loginCommand = &cli.Command{ Name: "login", Aliases: []string{"l"}, Usage: "Log into the Beeper server", - Before: interactive.Ask, Action: beeperLogin, Flags: []cli.Flag{ - interactive.Flag{Flag: &cli.StringFlag{ + &cli.StringFlag{ Name: "email", EnvVars: []string{"BEEPER_EMAIL"}, Usage: "The Beeper account email to log in with", - }, Survey: &survey.Input{ - Message: "Email:", - }}, + }, + &cli.BoolFlag{ + Name: "no-desktop", + EnvVars: []string{"BBCTL_NO_DESKTOP_LOGIN"}, + Usage: "Skip checking for an existing Beeper Desktop login", + }, }, } +func init() { + loginCommand.Flags = append(loginCommand.Flags, desktopLoginFlags()...) +} + +func maybeUseDesktopLogin(ctx *cli.Context) (bool, error) { + if ctx.Bool("no-desktop") { + return false, nil + } + dbPath, err := getLoginDesktopAccountDBPath(ctx) + if err != nil { + return false, err + } + account, err := readDesktopAccount(ctx.Context, dbPath) + if err != nil { + if ctx.IsSet("desktop-data-dir") { + return false, err + } + return false, nil + } + + useDesktop := false + err = survey.AskOne(&survey.Confirm{ + Message: fmt.Sprintf("Use Beeper Desktop login for %s?", account.UserID), + Default: true, + }, &useDesktop) + if err != nil { + return false, err + } + if !useDesktop { + return false, nil + } + + env, homeserver, err := configureDesktopLogin(ctx, account) + if err != nil { + return false, err + } + fmt.Printf("Using Beeper Desktop login for %s in bbctl env %q (%s)\n", account.UserID, env, homeserver) + return true, nil +} + func beeperLogin(ctx *cli.Context) error { + didLogin, err := maybeUseDesktopLogin(ctx) + if err != nil { + return err + } else if didLogin { + return nil + } + homeserver := ctx.String("homeserver") email := ctx.String("email") + if email == "" { + err = survey.AskOne(&survey.Input{ + Message: "Email:", + }, &email) + if err != nil { + return err + } + } startLogin, err := beeperapi.StartLogin(homeserver) if err != nil { @@ -91,6 +147,7 @@ func doMatrixLogin(ctx *cli.Context, req *mautrix.ReqLogin, whoami *beeperapi.Re envCfg.ClusterID = whoami.UserInfo.BridgeClusterID envCfg.Username = whoami.UserInfo.Username envCfg.AccessToken = resp.AccessToken + envCfg.DesktopDataDir = "" envCfg.BridgeDataDir = filepath.Join(UserDataDir, "bbctl", ctx.String("env")) err = cfg.Save() if err != nil { diff --git a/cmd/bbctl/logout.go b/cmd/bbctl/logout.go index 7ec88b3..aa2b0c0 100644 --- a/cmd/bbctl/logout.go +++ b/cmd/bbctl/logout.go @@ -22,16 +22,22 @@ var logoutCommand = &cli.Command{ } func beeperLogout(ctx *cli.Context) error { - _, err := GetMatrixClient(ctx).Logout(ctx.Context) - if err != nil && !ctx.Bool("force") { - return fmt.Errorf("error logging out: %w", err) + envCfg := GetEnvConfig(ctx) + if !envCfg.UsesDesktopLogin() { + _, err := GetMatrixClient(ctx).Logout(ctx.Context) + if err != nil && !ctx.Bool("force") { + return fmt.Errorf("error logging out: %w", err) + } } cfg := GetConfig(ctx) delete(cfg.Environments, ctx.String("env")) - err = cfg.Save() - if err != nil { + if err := cfg.Save(); err != nil { return fmt.Errorf("error saving config: %w", err) } + if envCfg.UsesDesktopLogin() { + fmt.Println("Logged out of bbctl successfully. Your Beeper Desktop session was not affected.") + return nil + } fmt.Println("Logged out successfully") return nil } diff --git a/cmd/bbctl/main.go b/cmd/bbctl/main.go index 2c127f5..879ebf8 100644 --- a/cmd/bbctl/main.go +++ b/cmd/bbctl/main.go @@ -81,6 +81,12 @@ func prepareApp(ctx *cli.Context) error { envConfig := cfg.Environments.Get(env) ctx.Context = context.WithValue(ctx.Context, contextKeyConfig, cfg) ctx.Context = context.WithValue(ctx.Context, contextKeyEnvConfig, envConfig) + if envConfig.UsesDesktopLogin() && !isRecoveryCommand(ctx) { + err = loadDesktopLogin(ctx, envConfig) + if err != nil { + return fmt.Errorf("failed to use Beeper Desktop login: %w", err) + } + } if envConfig.HasCredentials() { if envConfig.Username == "" { log.Printf("Fetching whoami to fill missing env config details") @@ -95,6 +101,15 @@ func prepareApp(ctx *cli.Context) error { return nil } +func isRecoveryCommand(ctx *cli.Context) bool { + switch ctx.Args().First() { + case "login", "l", "login-password", "p", "logout": + return true + default: + return false + } +} + var app = &cli.App{ Name: "bbctl", Usage: "Manage self-hosted bridges for Beeper", diff --git a/go.mod b/go.mod index 29fe6b9..ca4f28c 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,9 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect + github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/tidwall/match v1.1.1 // indirect diff --git a/go.sum b/go.sum index 8e76255..34b141d 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= @@ -34,10 +36,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=