From d2599d8d5482303a93845002adb2386f84677602 Mon Sep 17 00:00:00 2001 From: Scott Frazer Date: Thu, 18 Sep 2025 10:14:07 -0400 Subject: [PATCH 1/6] proof of concept OAuth2 signin --- lib/config.go | 2 +- notehub/main.go | 244 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 244 insertions(+), 2 deletions(-) diff --git a/lib/config.go b/lib/config.go index bbf988b..d3dbdfe 100644 --- a/lib/config.go +++ b/lib/config.go @@ -329,7 +329,7 @@ func ConfigAuthenticationHeader(httpReq *http.Request) (err error) { } // Set the header - httpReq.Header.Set("X-Session-Token", token) + httpReq.Header.Set("Authorization", "Bearer "+token) // Done return diff --git a/notehub/main.go b/notehub/main.go index b950d3d..46d4f83 100644 --- a/notehub/main.go +++ b/notehub/main.go @@ -5,10 +5,24 @@ package main import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" "flag" "fmt" + "io" + "log" + "math/rand" + "net/http" + "net/url" "os" + "os/exec" + "os/signal" + "runtime" "strings" + "time" "github.com/blues/note-cli/lib" "github.com/blues/note-go/note" @@ -100,9 +114,237 @@ func getFlagGroups() []lib.FlagGroup { } } +// open opens the specified URL in the default browser of the user. +func open(url string) error { + var cmd string + var args []string + + switch runtime.GOOS { + case "windows": + cmd = "cmd" + args = []string{"/c", "start"} + case "darwin": + cmd = "open" + default: // "linux", "freebsd", "openbsd", "netbsd" + cmd = "xdg-open" + } + args = append(args, url) + return exec.Command(cmd, args...).Start() +} + +type AccessToken struct { + Host string + Email string + AccessToken string + ExpiresAt time.Time +} + +func login() (*AccessToken, error) { + // these are configured on the OAuth Client within Hydra + clientId := "notehub_cli" + port := 58766 + + // this is per-environment + notehubHost := "scott.blues.tools" + + // return value + var accessToken *AccessToken + var accessTokenErr error + + randString := func(n int) string { + letterRunes := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + b := make([]rune, n) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return string(b) + } + + state := randString(16) + codeVerifier := randString(50) // must be at least 43 characters + hash := sha256.Sum256([]byte(codeVerifier)) + codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:]) + done := make(chan bool, 1) + quit := make(chan os.Signal, 1) + + signal.Notify(quit, os.Interrupt) + defer signal.Reset(os.Interrupt) + + router := http.NewServeMux() + router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + authorizationCode := r.URL.Query().Get("code") + callbackState := r.URL.Query().Get("state") + + errHandler := func(msg string) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "error: %s", msg) + fmt.Printf("error: %s\n", msg) + accessTokenErr = errors.New(msg) + } + + if callbackState != state { + errHandler("state mismatch") + return + } + + /////////////////////////////////////////// + // Get the access token from the authorization code + /////////////////////////////////////////// + + tokenResp, err := http.Post( + (&url.URL{ + Scheme: "https", + Host: notehubHost, + Path: "/oauth2/token", + }).String(), + "application/x-www-form-urlencoded", + strings.NewReader(url.Values{ + "client_id": {clientId}, + "code": {authorizationCode}, + "code_verifier": {codeVerifier}, + "grant_type": {"authorization_code"}, + "redirect_uri": {fmt.Sprintf("http://localhost:%d", port)}, + }.Encode()), + ) + + if err != nil { + errHandler("error on /oauth2/token: " + err.Error()) + return + } + + body, err := io.ReadAll(tokenResp.Body) + if err != nil { + errHandler("could not read body from /oauth2/token: " + err.Error()) + return + } + defer tokenResp.Body.Close() + + var tokenData map[string]interface{} + if err := json.Unmarshal(body, &tokenData); err != nil { + errHandler("could not unmarshal body from /oauth2/token: " + err.Error()) + return + } + + accessTokenString := tokenData["access_token"].(string) + expiresIn := time.Duration(tokenData["expires_in"].(float64)) * time.Second + + /////////////////////////////////////////// + // Get user's information (specifically email) + /////////////////////////////////////////// + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://%s/userinfo", notehubHost), nil) + if err != nil { + errHandler("could not create request for /userinfo: " + err.Error()) + return + } + req.Header.Set("Authorization", "Bearer "+accessTokenString) + userinfoResp, err := http.DefaultClient.Do(req) + if err != nil { + errHandler("could not get userinfo: " + err.Error()) + return + } + + userinfoBody, err := io.ReadAll(userinfoResp.Body) + if err != nil { + errHandler("could not read body from /userinfo: " + err.Error()) + return + } + defer userinfoResp.Body.Close() + + var userinfoData map[string]interface{} + if err := json.Unmarshal(userinfoBody, &userinfoData); err != nil { + errHandler("could not unmarshal body from /userinfo: " + err.Error()) + return + } + + email := userinfoData["email"].(string) + + /////////////////////////////////////////// + // Build the access token response + /////////////////////////////////////////// + + accessToken = &AccessToken{ + Host: notehubHost, + Email: email, + AccessToken: accessTokenString, + ExpiresAt: time.Now().Add(expiresIn), + } + + /////////////////////////////////////////// + // respond to the browser and quit + /////////////////////////////////////////// + + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "

Token exchange completed successfully

You may now close this window and return to the CLI application

") + + quit <- os.Interrupt + }) + + server := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: router, + } + + // Wait for OAuth callback to be hit, then shutdown HTTP server + go func(server *http.Server, quit <-chan os.Signal, done chan<- bool) { + <-quit + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + server.SetKeepAlivesEnabled(false) + if err := server.Shutdown(ctx); err != nil { + log.Printf("error: %v", err) + } + close(done) + }(server, quit, done) + + // Start HTTP server waiting for OAuth callback + go func(server *http.Server) { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("error: %v", err) + } + }(server) + + // Open this URL to start the process of authentication + authorizeUrl := url.URL{ + Scheme: "https", + Host: notehubHost, + Path: "/oauth2/auth", + RawQuery: url.Values{ + "client_id": {clientId}, + "code_challenge": {codeChallenge}, + "code_challenge_method": {"S256"}, + "redirect_uri": {fmt.Sprintf("http://localhost:%d", port)}, + "response_type": {"code"}, + "scope": {"openid email"}, + "state": {state}, + }.Encode(), + } + + // Open web browser to authorize + fmt.Printf("Opening web browser to initiate authentication...\n") + open(authorizeUrl.String()) + + // Wait for exchange to finish + <-done + + return accessToken, accessTokenErr +} + // Main entry point func main() { + accessToken, err := login() + if err != nil { + fmt.Printf("error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Access Token: %+v\n", accessToken) + fmt.Printf("Exiting\n") + os.Exit(0) + // Override the default usage function to use our grouped format flag.Usage = func() { lib.PrintGroupedFlags(getFlagGroups(), "notehub") @@ -160,7 +402,7 @@ func main() { flag.BoolVar(&flagProvision, "provision", false, "provision devices") // Parse these flags and also the note tool config flags - err := lib.FlagParse(false, true) + err = lib.FlagParse(false, true) if err != nil { fmt.Printf("%s\n", err) os.Exit(exitFail) From 8628a6362554c4655108d924244f267ef3d95785 Mon Sep 17 00:00:00 2001 From: Scott Frazer Date: Mon, 22 Sep 2025 13:47:33 -0400 Subject: [PATCH 2/6] wip --- lib/config.go | 422 ++++++++++++++++++++++++----------------- notecard/main.go | 20 +- notehub/auth.go | 480 ++++++++++++++++++++++++++++++----------------- notehub/main.go | 282 ++++------------------------ notehub/trace.go | 3 +- 5 files changed, 606 insertions(+), 601 deletions(-) diff --git a/lib/config.go b/lib/config.go index d3dbdfe..b8ee471 100644 --- a/lib/config.go +++ b/lib/config.go @@ -5,8 +5,10 @@ package lib import ( + "errors" "flag" "fmt" + "io" "math/rand" "net/http" "os" @@ -21,8 +23,69 @@ import ( // ConfigCreds are the credentials for a given Notehub type ConfigCreds struct { - User string `json:"user,omitempty"` - Token string `json:"token,omitempty"` + User string `json:"user,omitempty"` + Token string `json:"token,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Hub string `json:"-"` +} + +func (creds ConfigCreds) IsOAuthAccessToken() bool { + personalAccessTokenPrefixes := []string{"ory_st_", "api_key_"} + for _, prefix := range personalAccessTokenPrefixes { + if strings.HasPrefix(creds.Token, prefix) { + return false + } + } + return true +} + +func (creds ConfigCreds) AddHttpAuthHeader(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+creds.Token) +} + +func IntrospectToken(hub string, token string) (string, error) { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://%s/userinfo", hub), nil) + if err != nil { + return "", err + } + + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + userinfo := map[string]interface{}{} + if err := note.JSONUnmarshal(body, &userinfo); err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + err := userinfo["err"] + return "", fmt.Errorf("%s (http %d)", err, resp.StatusCode) + } + + if email, ok := userinfo["email"].(string); !ok || email == "" { + fmt.Printf("response: %s\n", userinfo) + return "", fmt.Errorf("error introspecting token: no email in response") + } else { + return email, nil + } +} + +func (creds *ConfigCreds) Validate() error { + if creds == nil { + return errors.New("no credentials specified") + } + _, err := IntrospectToken(creds.Hub, creds.Token) + return err } // Port/PortConfig on a per-interface basis @@ -41,169 +104,206 @@ type ConfigSettings struct { } // Config are the master config settings -var Config ConfigSettings +var config *ConfigSettings var configFlagHub string var configFlagInterface string var configFlagPort string var configFlagPortConfig int -// ConfigRead reads the current info from config file -func ConfigRead() error { - - // As a convenience to all tools, generate a new random seed for each iteration - rand.Seed(time.Now().UnixNano()) - rand.Seed(rand.Int63() ^ time.Now().UnixNano()) - - // Read the config file - contents, err := os.ReadFile(configSettingsPath()) - if os.IsNotExist(err) { - // If no interface has been provided and no saved config, set defaults - if Config.Interface == "" { - ConfigReset() - newConfigPort := Config.IPort[Config.Interface] - Config.Interface, newConfigPort.Port, newConfigPort.PortConfig = notecard.Defaults() - Config.IPort[Config.Interface] = newConfigPort - ConfigWrite() - } else { - ConfigReset() - } - err = nil - } else if err == nil { - err = note.JSONUnmarshal(contents, &Config) - if err != nil || Config.When == "" { - ConfigReset() - if err != nil { - err = fmt.Errorf("can't read configuration: %s", err) - } - } - } - - return err - -} - -// ConfigWrite updates the file with the current config info -func ConfigWrite() error { - +func (config *ConfigSettings) Write() error { // Marshal it - configJSON, _ := note.JSONMarshalIndent(Config, "", " ") + configJSON, err := note.JSONMarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("can't marshal configuration: %s", err) + } // Write the file - fd, err := os.OpenFile(configSettingsPath(), os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0666) + configPath := configSettingsPath() + fd, err := os.OpenFile(configPath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0666) if err != nil { return err } - fd.Write(configJSON) - fd.Close() - - // Done - return err - -} - -// Reset the comms to default -func configResetInterface() { - Config = ConfigSettings{} - Config.HubCreds = map[string]ConfigCreds{} - Config.IPort = map[string]ConfigPort{} -} - -// ConfigReset updates the file with the default info -func ConfigReset() { - configResetInterface() - ConfigSetHub("-") - Config.When = time.Now().UTC().Format("2006-01-02T15:04:05Z") + if _, err := fd.Write(configJSON); err != nil { + return fmt.Errorf("can't write %s: %s", configPath, err) + } + return fd.Close() } -// ConfigShow displays all current config parameters -func ConfigShow() error { - +func (config *ConfigSettings) Print() { fmt.Printf("\nCurrently saved values:\n") - if Config.Hub != "" { - fmt.Printf(" hub: %s\n", Config.Hub) - } - if Config.IPort == nil { - Config.IPort = map[string]ConfigPort{} - } - if Config.HubCreds == nil { - Config.HubCreds = map[string]ConfigCreds{} + if config.Hub != "" { + fmt.Printf(" hub: %s\n", config.Hub) } - if len(Config.HubCreds) != 0 { + if len(config.HubCreds) != 0 { fmt.Printf(" creds:\n") - for hub, cred := range Config.HubCreds { - fmt.Printf(" %s: %s\n", hub, cred.User) + for hub, cred := range config.HubCreds { + tokenType := "PAT" + if cred.IsOAuthAccessToken() { + tokenType = "OAuth" + } + + expires := "" + if cred.ExpiresAt != nil { + if cred.ExpiresAt.Before(time.Now()) { + expires = fmt.Sprintf(" (expired)") + } else { + expires = fmt.Sprintf(" (expires at %s)", cred.ExpiresAt.Format("2006-01-02 15:04:05 MST")) + } + } + fmt.Printf(" %s: %s (%s)%s\n", hub, cred.User, tokenType, expires) } } - if Config.Interface != "" { - fmt.Printf(" -interface %s\n", Config.Interface) - if Config.IPort[Config.Interface].Port == "" { + if config.Interface != "" { + fmt.Printf(" -interface %s\n", config.Interface) + + configPort := config.IPort[config.Interface] + if configPort.Port == "" { fmt.Printf(" -port -\n") fmt.Printf(" -portconfig -\n") } else { - fmt.Printf(" -port %s\n", Config.IPort[Config.Interface].Port) - fmt.Printf(" -portconfig %d\n", Config.IPort[Config.Interface].PortConfig) + fmt.Printf(" -port %s\n", configPort.Port) + fmt.Printf(" -portconfig %d\n", configPort.PortConfig) } } +} +func (config *ConfigSettings) DefaultCredentials() *ConfigCreds { + if creds, present := config.HubCreds[config.Hub]; present && creds.Token != "" && creds.User != "" { + creds.Hub = config.Hub + return &creds + } return nil +} +func (config *ConfigSettings) SetDefaultCredentials(token string, email string, expiresAt *time.Time) { + config.HubCreds[config.Hub] = ConfigCreds{ + Hub: config.Hub, + User: email, + Token: token, + ExpiresAt: expiresAt, + } } -// ConfigFlagsProcess processes the registered config flags -func ConfigFlagsProcess() (err error) { +func defaultConfig() *ConfigSettings { + iface, port, portConfig := notecard.Defaults() + return &ConfigSettings{ + When: time.Now().UTC().Format("2006-01-02T15:04:05Z"), + Hub: notehub.DefaultAPIService, + HubCreds: map[string]ConfigCreds{}, + IPort: map[string]ConfigPort{ + iface: { + Port: port, + PortConfig: portConfig, + }, + }, + } +} - // Create maps if they don't exist - if Config.IPort == nil { - Config.IPort = map[string]ConfigPort{} +// returns (nil, nil) if there's no config file +// returns (non-nil, nil) if a config file was read from the filesystem successfully +// returns (nil, non-nil) if there was some any other error +func readConfigFromFile() (*ConfigSettings, error) { + // Read the config file + configPath := configSettingsPath() + contents, err := os.ReadFile(configPath) + + if os.IsNotExist(err) { + return nil, nil + } else if err != nil { + return nil, fmt.Errorf("can't read %s: %s", configPath, err) } - if Config.HubCreds == nil { - Config.HubCreds = map[string]ConfigCreds{} + + var configFromFile ConfigSettings + if err := note.JSONUnmarshal(contents, &configFromFile); err != nil { + return nil, fmt.Errorf("can't parse %s: %s", configPath, err) } - // Read if not yet read - if Config.When == "" { - err = ConfigRead() - if err != nil { - return + return &configFromFile, nil +} + +func GetConfig() (*ConfigSettings, error) { + if config == nil { + // try reading it from the filesystem + // otherwise, use a new default config + if configFromFile, err := readConfigFromFile(); err != nil { + return nil, err + } else if configFromFile != nil { + config = configFromFile + } else { + config = defaultConfig() } } + return config, nil +} + +// ConfigRead reads the current info from config file +func ConfigRead() error { + + // As a convenience to all tools, generate a new random seed for each iteration + rand.Seed(time.Now().UnixNano()) + rand.Seed(rand.Int63() ^ time.Now().UnixNano()) + + // Read the config file + configPath := configSettingsPath() + contents, err := os.ReadFile(configPath) + if os.IsNotExist(err) { + // If no interface has been provided and no saved config, + // set it to a default value and write it + config = defaultConfig() + return config.Write() + } else if err != nil { + return fmt.Errorf("can't read %s: %s", configPath, err) + } + + var newConfig ConfigSettings + if err := note.JSONUnmarshal(contents, &newConfig); err != nil { + return fmt.Errorf("can't parse %s: %s", configPath, err) + } + config = &newConfig + + return nil +} + +// load current config, apply CLI flag values, and save if appropriate +func ConfigFlagsProcess() (err error) { + config, err := GetConfig() + if err != nil { + return + } + + if configFlagInterface == "-" { + config = defaultConfig() + } else if configFlagInterface != "" { + config.Interface = configFlagInterface + } // Set or reset the flags as desired if configFlagHub != "" { ConfigSetHub(configFlagHub) } - if configFlagInterface == "-" { - configResetInterface() - } else if configFlagInterface != "" { - Config.Interface = configFlagInterface + if config.Hub == "" { + config.Hub = notehub.DefaultAPIService } + + defaultPort := config.IPort[config.Interface] + if configFlagPort == "-" { - temp := Config.IPort[Config.Interface] - temp.Port = "" - Config.IPort[Config.Interface] = temp + defaultPort.Port = "" } else if configFlagPort != "" { - temp := Config.IPort[Config.Interface] - temp.Port = configFlagPort - Config.IPort[Config.Interface] = temp + defaultPort.Port = configFlagPort } + if configFlagPortConfig < 0 { - temp := Config.IPort[Config.Interface] - temp.PortConfig = 0 - Config.IPort[Config.Interface] = temp + defaultPort.PortConfig = 0 } else if configFlagPortConfig != 0 { - temp := Config.IPort[Config.Interface] - temp.PortConfig = configFlagPortConfig - Config.IPort[Config.Interface] = temp - } - if Config.Interface == "" { - configFlagPort = "" - configFlagPortConfig = 0 + defaultPort.PortConfig = configFlagPortConfig } + config.IPort[config.Interface] = defaultPort + // Done return nil - } // ConfigFlagsRegister registers the config-related flags @@ -256,32 +356,28 @@ func FlagParse(notecardFlags bool, notehubFlags bool) (err error) { } } } - if configOnly && Config.Interface != "lease" { - fmt.Printf("*** saving configuration ***") - ConfigWrite() - ConfigShow() + + if configOnly && config.Interface != "lease" { + if err := config.Write(); err != nil { + return fmt.Errorf("could not write config file: %w", err) + } + fmt.Printf("configuration file saved\n\n") + config.Print() } // Override, just for this session, with env vars - str := os.Getenv("NOTE_INTERFACE") - if str != "" { - Config.Interface = str + if iface := os.Getenv("NOTE_INTERFACE"); iface != "" { + config.Interface = iface } // Override via env vars if specified - str = os.Getenv("NOTE_PORT") - if str != "" { - temp := Config.IPort[Config.Interface] - temp.Port = str - Config.IPort[Config.Interface] = temp - str := os.Getenv("NOTE_PORT_CONFIG") - strint, err2 := strconv.Atoi(str) - if err2 != nil { - strint = Config.IPort[Config.Interface].PortConfig + if port := os.Getenv("NOTE_PORT"); port != "" { + temp := config.IPort[config.Interface] + temp.Port = port + if portConfig, err := strconv.Atoi(os.Getenv("NOTE_PORT_CONFIG")); err == nil { + temp.PortConfig = portConfig } - temp = Config.IPort[Config.Interface] - temp.PortConfig = strint - Config.IPort[Config.Interface] = temp + config.IPort[config.Interface] = temp } // Done @@ -290,49 +386,31 @@ func FlagParse(notecardFlags bool, notehubFlags bool) (err error) { } // ConfigSignedIn returns info about whether or not we're signed in -func ConfigSignedIn() (username string, token string, authenticated bool) { - if Config.IPort == nil { - Config.IPort = map[string]ConfigPort{} - } - if Config.HubCreds == nil { - Config.HubCreds = map[string]ConfigCreds{} - } - hub := Config.Hub - if hub == "" { - hub = notehub.DefaultAPIService +// +// TODO: check credentials by issuing an HTTP request +// maybe this should return an error if a PAT is expired? +// what happens if an access token is expired? +func ConfigSignedIn() *ConfigCreds { + if creds, present := config.HubCreds[config.Hub]; present && creds.Token != "" && creds.User != "" { + creds.Hub = config.Hub + return &creds } - creds, present := Config.HubCreds[hub] - if present { - if creds.Token != "" && creds.User != "" { - authenticated = true - username = creds.User - token = creds.Token - } - } - - return - + return nil } // ConfigAuthenticationHeader sets the authorization field in the header as appropriate -func ConfigAuthenticationHeader(httpReq *http.Request) (err error) { - +func ConfigAuthenticationHeader(httpReq *http.Request) error { // Exit if not signed in - _, token, authenticated := ConfigSignedIn() - if !authenticated { - hub := Config.Hub - if hub == "" { - hub = notehub.DefaultAPIService - } - err = fmt.Errorf("not authenticated to %s: please use 'notehub -signin' to sign into the Notehub service", hub) - return + credentials := ConfigSignedIn() + if credentials == nil { + return fmt.Errorf("not authenticated to %s: please use 'notehub -signin' to sign into the Notehub service", config.Hub) } // Set the header - httpReq.Header.Set("Authorization", "Bearer "+token) + httpReq.Header.Set("Authorization", "Bearer "+credentials.Token) // Done - return + return nil } @@ -340,7 +418,7 @@ func ConfigAuthenticationHeader(httpReq *http.Request) (err error) { // the default Blues API service. Regardless, it always makes sure that the host has "api." as a prefix. // This enables flexibility in what's configured. func ConfigAPIHub() (hub string) { - hub = Config.Hub + hub = config.Hub if hub == "" || hub == "-" { hub = notehub.DefaultAPIService } @@ -353,7 +431,7 @@ func ConfigAPIHub() (hub string) { // ConfigNotecardHub returns the configured notehub, for use as the Notecard host. If none is configured // it returns "". Regardless, it always makes sure that the host does NOT have "api." as a prefix. func ConfigNotecardHub() (hub string) { - hub = Config.Hub + hub = config.Hub if hub == "" || hub == "-" { hub = notehub.DefaultAPIService } @@ -363,8 +441,8 @@ func ConfigNotecardHub() (hub string) { // ConfigSetHub clears the hub func ConfigSetHub(hub string) { - if hub == "-" { - hub = "" + if hub == "-" || hub == "" { + hub = notehub.DefaultAPIService } - Config.Hub = hub + config.Hub = hub } diff --git a/notecard/main.go b/notecard/main.go index 6083b8a..d905539 100644 --- a/notecard/main.go +++ b/notecard/main.go @@ -243,10 +243,16 @@ func main() { exitFailAndCloseCard() } + config, err := lib.GetConfig() + if err != nil { + fmt.Printf("%s\n", err) + exitFailAndCloseCard() + } + // If no action specified (i.e. just -port x), exit so that we don't touch the wrong port if len(os.Args) == 1 { lib.PrintGroupedFlags(getFlagGroups(), "notecard") - lib.ConfigShow() + config.Print() fmt.Printf("\n") fmt.Printf("PCAP Usage:\n") fmt.Printf(" notecard -port -pcap -output \n") @@ -255,9 +261,9 @@ func main() { fmt.Printf(" Example: notecard -port /dev/ttyAMA0 -pcap aux -portconfig 115200 -output capture.pcap\n") fmt.Printf("\n") nInterface, nPort, _ := notecard.Defaults() - if lib.Config.Interface != "" { - nInterface = lib.Config.Interface - nPort = lib.Config.IPort[lib.Config.Interface].Port + if config.Interface != "" { + nInterface = config.Interface + nPort = config.IPort[config.Interface].Port } var ports []string if nInterface == notecard.NotecardInterfaceSerial { @@ -270,7 +276,7 @@ func main() { fmt.Printf("Ports on '%s':\n", nInterface) for _, port := range ports { if port == nPort { - nPortConfig := lib.Config.IPort[lib.Config.Interface].PortConfig + nPortConfig := config.IPort[config.Interface].PortConfig if nPortConfig == 0 { fmt.Printf(" %s ***\n", port) } else { @@ -314,14 +320,14 @@ func main() { } // Open the card, just to make sure errors are reported early - configVal := lib.Config.IPort[lib.Config.Interface].PortConfig + configVal := config.IPort[config.Interface].PortConfig if actionPlaytime != 0 { configVal = actionPlaytime actionPlayground = true } notecard.InitialDebugMode = actionVerbose notecard.InitialTraceMode = actionTrace - card, err = notecard.Open(lib.Config.Interface, lib.Config.IPort[lib.Config.Interface].Port, configVal) + card, err = notecard.Open(config.Interface, config.IPort[config.Interface].Port, configVal) // Process non-config commands var rsp notecard.Request diff --git a/notehub/auth.go b/notehub/auth.go index afb9bf1..446d51c 100644 --- a/notehub/auth.go +++ b/notehub/auth.go @@ -5,234 +5,360 @@ package main import ( - "bufio" - "bytes" + "context" + "crypto/sha256" + "encoding/base64" "encoding/json" + "errors" "fmt" "io" + "log" + "math/rand" "net/http" + "net/url" "os" + "os/signal" "strings" + "time" "github.com/blues/note-cli/lib" - "github.com/blues/note-go/notehub" - terminal "golang.org/x/term" + "github.com/blues/note-go/note" ) -// Sign into the notehub account with a token -func authSignInToken(token string) (err error) { +func authIntrospectToken(config *lib.ConfigSettings, personalAccessToken string) (string, error) { + req, err := http.NewRequest(http.MethodGet, "https://"+config.Hub+"/userinfo", nil) + if err != nil { + return "", err + } - // Print hub if not the default - if lib.Config.Hub != "" { - fmt.Printf("notehub %s\n", lib.Config.Hub) + req.Header.Set("Authorization", "Bearer "+personalAccessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err } + defer resp.Body.Close() - // Extract the token and save it - var creds lib.ConfigCreds - creds.Token = token - creds.User = "(token)" - if lib.Config.HubCreds == nil { - lib.Config.HubCreds = map[string]lib.ConfigCreds{} + userinfo := map[string]interface{}{} + if err := note.JSONUnmarshal(body, &userinfo); err != nil { + return "", err } - hub := lib.Config.Hub - if hub == "" { - hub = notehub.DefaultAPIService + + if resp.StatusCode != http.StatusOK { + err := userinfo["err"] + return "", fmt.Errorf("error introspecting token: %s (http %d)", err, resp.StatusCode) } - lib.Config.HubCreds[hub] = creds - err = lib.ConfigWrite() + + if email, ok := userinfo["email"].(string); !ok || email == "" { + return "", fmt.Errorf("error introspecting token: no email in response") + } else { + return email, nil + } +} + +// Sign into the notehub account with a personal access token +func authSignInToken(personalAccessToken string) error { + // TODO: maybe call configInit() to set defaults? + config, err := lib.GetConfig() if err != nil { - return + return err + } + + // Print hub if not the default + fmt.Printf("notehub: %s\n", config.Hub) + + email, err := authIntrospectToken(config, personalAccessToken) + if err != nil { + return err + } + + config.SetDefaultCredentials(personalAccessToken, email, nil) + + if err := config.Write(); err != nil { + return err } // Done fmt.Printf("signed in successfully with token\n") - return - + return nil } -// Sign into the notehub account -func authSignIn() (err error) { +func authRevokeAccessToken(ctx context.Context, credentials *lib.ConfigCreds) error { + // TODO: assert that it's an access token and not a PAT - // Sign out - _, _, authenticated := lib.ConfigSignedIn() - if authenticated { - authSignOut() + form := url.Values{ + "token": {credentials.Token}, + "token_type_hint": {"access_token"}, + "client_id": {"notehub_cli"}, } - // Print banner - fmt.Printf("%s", banner()) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://"+credentials.Hub+"/oauth2/revoke", strings.NewReader(form.Encode())) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } - // Print hub if not the default - if lib.Config.Hub != "" { - fmt.Printf("notehub %s\n", lib.Config.Hub) - } - - // Read the account - var username string - for username == "" { - fmt.Printf("account email@address.com > ") - scanner := bufio.NewScanner(os.Stdin) - ok := scanner.Scan() - if ok { - username = strings.TrimRight(scanner.Text(), "\r\n") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("making request: %w", err) + } + defer resp.Body.Close() + + // Per RFC 7009: 200 OK even if token is already invalid; treat as success + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return nil +} + +type AccessToken struct { + Host string + Email string + AccessToken string + ExpiresAt time.Time +} + +func initiateBrowserBasedLogin(hub string) (*AccessToken, error) { + // these are configured on the OAuth Client within Hydra + clientId := "notehub_cli" + port := 58766 + + // return value + var accessToken *AccessToken + var accessTokenErr error + + randString := func(n int) string { + letterRunes := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + b := make([]rune, n) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] } + return string(b) } - // Read the password - var password string - for password == "" { - fmt.Printf("account password > ") - var pw []byte - pw, err = terminal.ReadPassword(int(os.Stdin.Fd())) - fmt.Printf("\n") + state := randString(16) + codeVerifier := randString(50) // must be at least 43 characters + hash := sha256.Sum256([]byte(codeVerifier)) + codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:]) + done := make(chan bool, 1) + quit := make(chan os.Signal, 1) + + signal.Notify(quit, os.Interrupt) + defer signal.Reset(os.Interrupt) + + router := http.NewServeMux() + router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + authorizationCode := r.URL.Query().Get("code") + callbackState := r.URL.Query().Get("state") + + errHandler := func(msg string) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "error: %s", msg) + fmt.Printf("error: %s\n", msg) + accessTokenErr = errors.New(msg) + } + + if callbackState != state { + errHandler("state mismatch") + return + } + + /////////////////////////////////////////// + // Get the access token from the authorization code + /////////////////////////////////////////// + + tokenResp, err := http.Post( + (&url.URL{ + Scheme: "https", + Host: hub, + Path: "/oauth2/token", + }).String(), + "application/x-www-form-urlencoded", + strings.NewReader(url.Values{ + "client_id": {clientId}, + "code": {authorizationCode}, + "code_verifier": {codeVerifier}, + "grant_type": {"authorization_code"}, + "redirect_uri": {fmt.Sprintf("http://localhost:%d", port)}, + }.Encode()), + ) + if err != nil { + errHandler("error on /oauth2/token: " + err.Error()) return } - password = string(pw) - } - // Do the sign-in HTTP request - req := map[string]interface{}{} - req["username"] = username - req["password"] = password - reqJSON, err2 := json.Marshal(req) - if err2 != nil { - err = err2 - return - } - httpURL := "https://" + lib.ConfigAPIHub() + "/auth/login" - httpReq, err2 := http.NewRequest("POST", httpURL, bytes.NewBuffer(reqJSON)) - if err != nil { - err = err2 - return - } - httpReq.Header.Set("User-Agent", "notehub-client") - httpReq.Header.Set("Content-Type", "application/json") - httpClient := &http.Client{} - httpRsp, err2 := httpClient.Do(httpReq) - if err2 != nil { - err = err2 - return - } - if httpRsp.StatusCode == http.StatusUnauthorized || httpRsp.StatusCode == http.StatusBadRequest { - err = fmt.Errorf("unrecognized username or password") - return - } - if httpRsp.StatusCode != http.StatusOK { - err = fmt.Errorf("status %d", httpRsp.StatusCode) - return - } - rspJSON, err2 := io.ReadAll(httpRsp.Body) - if err2 != nil { - err = err2 - return - } - rsp := map[string]interface{}{} - err = json.Unmarshal(rspJSON, &rsp) - if err != nil { - err = fmt.Errorf("%s: '%s'", err, string(rspJSON)) - return - } - token := "" - if rsp["session_token"] != nil { - token = rsp["session_token"].(string) - } - if token == "" { - err = fmt.Errorf("%s authentication error", lib.ConfigAPIHub()) - return - } + body, err := io.ReadAll(tokenResp.Body) + if err != nil { + errHandler("could not read body from /oauth2/token: " + err.Error()) + return + } + defer tokenResp.Body.Close() - // Extract the token and save it - var creds lib.ConfigCreds - creds.Token = token - creds.User = username - if lib.Config.HubCreds == nil { - lib.Config.HubCreds = map[string]lib.ConfigCreds{} - } - hub := lib.Config.Hub - if hub == "" { - hub = notehub.DefaultAPIService + var tokenData map[string]interface{} + if err := json.Unmarshal(body, &tokenData); err != nil { + errHandler("could not unmarshal body from /oauth2/token: " + err.Error()) + return + } + + // TODO: check for error in response + // or this could panic when an error is returned + accessTokenString := tokenData["access_token"].(string) + expiresIn := time.Duration(tokenData["expires_in"].(float64)) * time.Second + + /////////////////////////////////////////// + // Get user's information (specifically email) + /////////////////////////////////////////// + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://%s/userinfo", hub), nil) + if err != nil { + errHandler("could not create request for /userinfo: " + err.Error()) + return + } + req.Header.Set("Authorization", "Bearer "+accessTokenString) + userinfoResp, err := http.DefaultClient.Do(req) + if err != nil { + errHandler("could not get userinfo: " + err.Error()) + return + } + + userinfoBody, err := io.ReadAll(userinfoResp.Body) + if err != nil { + errHandler("could not read body from /userinfo: " + err.Error()) + return + } + defer userinfoResp.Body.Close() + + var userinfoData map[string]interface{} + if err := json.Unmarshal(userinfoBody, &userinfoData); err != nil { + errHandler("could not unmarshal body from /userinfo: " + err.Error()) + return + } + + email := userinfoData["email"].(string) + + /////////////////////////////////////////// + // Build the access token response + /////////////////////////////////////////// + + accessToken = &AccessToken{ + Host: hub, + Email: email, + AccessToken: accessTokenString, + ExpiresAt: time.Now().Add(expiresIn), + } + + /////////////////////////////////////////// + // respond to the browser and quit + /////////////////////////////////////////// + + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "

Token exchange completed successfully

You may now close this window and return to the CLI application

") + + quit <- os.Interrupt + }) + + server := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: router, } - lib.Config.HubCreds[hub] = creds - err = lib.ConfigWrite() - if err != nil { - return + + // Wait for OAuth callback to be hit, then shutdown HTTP server + go func(server *http.Server, quit <-chan os.Signal, done chan<- bool) { + <-quit + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + server.SetKeepAlivesEnabled(false) + if err := server.Shutdown(ctx); err != nil { + log.Printf("error: %v", err) + } + close(done) + }(server, quit, done) + + // Start HTTP server waiting for OAuth callback + go func(server *http.Server) { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("error: %v", err) + } + }(server) + + // Open this URL to start the process of authentication + authorizeUrl := url.URL{ + Scheme: "https", + Host: hub, + Path: "/oauth2/auth", + RawQuery: url.Values{ + "client_id": {clientId}, + "code_challenge": {codeChallenge}, + "code_challenge_method": {"S256"}, + "redirect_uri": {fmt.Sprintf("http://localhost:%d", port)}, + "response_type": {"code"}, + "scope": {"openid email"}, + "state": {state}, + }.Encode(), } - // Done - fmt.Printf("signed in successfully as %s\n", username) - return + // Open web browser to authorize + fmt.Printf("Opening web browser to initiate authentication...\n") + open(authorizeUrl.String()) + + // Wait for exchange to finish + <-done + return accessToken, accessTokenErr } -// Sign out of the API -func authSignOut() (err error) { +// Sign into the Notehub account with browser-based OAuth2 flow +func authSignIn() error { - // Exit if not signed in - user, token, authenticated := lib.ConfigSignedIn() - if !authenticated { - err = fmt.Errorf("not currently signed in") - return + // load config + config, err := lib.GetConfig() + if err != nil { + return err } - // Get the token, and clear it - if lib.Config.HubCreds != nil { - hub := lib.Config.Hub - if hub == "" { - delete(lib.Config.HubCreds, "") - hub = notehub.DefaultAPIService + credentials := config.DefaultCredentials() + + // if signed in and it's an access token, then revoke it + // we don't want to revoke a PAT because the user explicitly set an + // expiration date on that token + if credentials != nil && credentials.IsOAuthAccessToken() { + if err := authRevokeAccessToken(context.Background(), credentials); err != nil { + return err } - delete(lib.Config.HubCreds, hub) } - err = lib.ConfigWrite() + + // initiate the browser-based OAuth2 login flow + accessToken, err := initiateBrowserBasedLogin(config.Hub) if err != nil { - return + return fmt.Errorf("authentication failed: %w", err) } - // Hit the logout endpoint in the API to revoke the session - httpURL := "https://" + lib.ConfigAPIHub() + "/auth/logout" - httpReq, err2 := http.NewRequest("POST", httpURL, bytes.NewBuffer([]byte{})) - if err != nil { - err = err2 - return - } - httpReq.Header.Set("User-Agent", "notehub-client") - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("X-Session-Token", token) - httpClient := &http.Client{} - httpRsp, err2 := httpClient.Do(httpReq) - if err2 != nil { - err = err2 - return - } - if httpRsp.StatusCode == http.StatusUnauthorized || httpRsp.StatusCode == http.StatusBadRequest { - err = fmt.Errorf("user is not signed in") - return - } - rspJSON, err2 := io.ReadAll(httpRsp.Body) - if err2 != nil { - err = err2 - return - } - - response := string(rspJSON) - if response == "" { - fmt.Printf("%s signed out successfully\n", user) - } else { - fmt.Printf("%s signed out successfully: %s\n", user, response) + config.SetDefaultCredentials(accessToken.AccessToken, accessToken.Email, &accessToken.ExpiresAt) + + // save the config with the new credentials + if err := config.Write(); err != nil { + return err } - return -} -// Get the token for use in the API -func authToken() (user string, token string, err error) { - var authenticated bool - user, token, authenticated = lib.ConfigSignedIn() - if !authenticated { - err = fmt.Errorf("not currently signed in") - return + // print out information about the session + if accessToken != nil { + fmt.Printf("%s\n", banner()) + fmt.Printf("signed in as %s\n", accessToken.Email) + fmt.Printf("token expires at %s\n", accessToken.ExpiresAt.Format("2006-01-02 15:04:05 MST")) } - return + + // Done + return nil } // Banner for authentication diff --git a/notehub/main.go b/notehub/main.go index 46d4f83..c465413 100644 --- a/notehub/main.go +++ b/notehub/main.go @@ -6,23 +6,12 @@ package main import ( "context" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" "flag" "fmt" - "io" - "log" - "math/rand" - "net/http" - "net/url" "os" "os/exec" - "os/signal" "runtime" "strings" - "time" "github.com/blues/note-cli/lib" "github.com/blues/note-go/note" @@ -132,219 +121,9 @@ func open(url string) error { return exec.Command(cmd, args...).Start() } -type AccessToken struct { - Host string - Email string - AccessToken string - ExpiresAt time.Time -} - -func login() (*AccessToken, error) { - // these are configured on the OAuth Client within Hydra - clientId := "notehub_cli" - port := 58766 - - // this is per-environment - notehubHost := "scott.blues.tools" - - // return value - var accessToken *AccessToken - var accessTokenErr error - - randString := func(n int) string { - letterRunes := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") - b := make([]rune, n) - for i := range b { - b[i] = letterRunes[rand.Intn(len(letterRunes))] - } - return string(b) - } - - state := randString(16) - codeVerifier := randString(50) // must be at least 43 characters - hash := sha256.Sum256([]byte(codeVerifier)) - codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:]) - done := make(chan bool, 1) - quit := make(chan os.Signal, 1) - - signal.Notify(quit, os.Interrupt) - defer signal.Reset(os.Interrupt) - - router := http.NewServeMux() - router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - authorizationCode := r.URL.Query().Get("code") - callbackState := r.URL.Query().Get("state") - - errHandler := func(msg string) { - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, "error: %s", msg) - fmt.Printf("error: %s\n", msg) - accessTokenErr = errors.New(msg) - } - - if callbackState != state { - errHandler("state mismatch") - return - } - - /////////////////////////////////////////// - // Get the access token from the authorization code - /////////////////////////////////////////// - - tokenResp, err := http.Post( - (&url.URL{ - Scheme: "https", - Host: notehubHost, - Path: "/oauth2/token", - }).String(), - "application/x-www-form-urlencoded", - strings.NewReader(url.Values{ - "client_id": {clientId}, - "code": {authorizationCode}, - "code_verifier": {codeVerifier}, - "grant_type": {"authorization_code"}, - "redirect_uri": {fmt.Sprintf("http://localhost:%d", port)}, - }.Encode()), - ) - - if err != nil { - errHandler("error on /oauth2/token: " + err.Error()) - return - } - - body, err := io.ReadAll(tokenResp.Body) - if err != nil { - errHandler("could not read body from /oauth2/token: " + err.Error()) - return - } - defer tokenResp.Body.Close() - - var tokenData map[string]interface{} - if err := json.Unmarshal(body, &tokenData); err != nil { - errHandler("could not unmarshal body from /oauth2/token: " + err.Error()) - return - } - - accessTokenString := tokenData["access_token"].(string) - expiresIn := time.Duration(tokenData["expires_in"].(float64)) * time.Second - - /////////////////////////////////////////// - // Get user's information (specifically email) - /////////////////////////////////////////// - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://%s/userinfo", notehubHost), nil) - if err != nil { - errHandler("could not create request for /userinfo: " + err.Error()) - return - } - req.Header.Set("Authorization", "Bearer "+accessTokenString) - userinfoResp, err := http.DefaultClient.Do(req) - if err != nil { - errHandler("could not get userinfo: " + err.Error()) - return - } - - userinfoBody, err := io.ReadAll(userinfoResp.Body) - if err != nil { - errHandler("could not read body from /userinfo: " + err.Error()) - return - } - defer userinfoResp.Body.Close() - - var userinfoData map[string]interface{} - if err := json.Unmarshal(userinfoBody, &userinfoData); err != nil { - errHandler("could not unmarshal body from /userinfo: " + err.Error()) - return - } - - email := userinfoData["email"].(string) - - /////////////////////////////////////////// - // Build the access token response - /////////////////////////////////////////// - - accessToken = &AccessToken{ - Host: notehubHost, - Email: email, - AccessToken: accessTokenString, - ExpiresAt: time.Now().Add(expiresIn), - } - - /////////////////////////////////////////// - // respond to the browser and quit - /////////////////////////////////////////// - - w.Header().Set("Content-Type", "text/html") - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "

Token exchange completed successfully

You may now close this window and return to the CLI application

") - - quit <- os.Interrupt - }) - - server := &http.Server{ - Addr: fmt.Sprintf(":%d", port), - Handler: router, - } - - // Wait for OAuth callback to be hit, then shutdown HTTP server - go func(server *http.Server, quit <-chan os.Signal, done chan<- bool) { - <-quit - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - server.SetKeepAlivesEnabled(false) - if err := server.Shutdown(ctx); err != nil { - log.Printf("error: %v", err) - } - close(done) - }(server, quit, done) - - // Start HTTP server waiting for OAuth callback - go func(server *http.Server) { - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Printf("error: %v", err) - } - }(server) - - // Open this URL to start the process of authentication - authorizeUrl := url.URL{ - Scheme: "https", - Host: notehubHost, - Path: "/oauth2/auth", - RawQuery: url.Values{ - "client_id": {clientId}, - "code_challenge": {codeChallenge}, - "code_challenge_method": {"S256"}, - "redirect_uri": {fmt.Sprintf("http://localhost:%d", port)}, - "response_type": {"code"}, - "scope": {"openid email"}, - "state": {state}, - }.Encode(), - } - - // Open web browser to authorize - fmt.Printf("Opening web browser to initiate authentication...\n") - open(authorizeUrl.String()) - - // Wait for exchange to finish - <-done - - return accessToken, accessTokenErr -} - // Main entry point func main() { - accessToken, err := login() - if err != nil { - fmt.Printf("error: %v\n", err) - os.Exit(1) - } - - fmt.Printf("Access Token: %+v\n", accessToken) - fmt.Printf("Exiting\n") - os.Exit(0) - // Override the default usage function to use our grouped format flag.Usage = func() { lib.PrintGroupedFlags(getFlagGroups(), "notehub") @@ -402,7 +181,14 @@ func main() { flag.BoolVar(&flagProvision, "provision", false, "provision devices") // Parse these flags and also the note tool config flags - err = lib.FlagParse(false, true) + err := lib.FlagParse(false, true) + if err != nil { + fmt.Printf("%s\n", err) + os.Exit(exitFail) + } + + // after flags are parsed, get the resulting configuration + config, err := lib.GetConfig() if err != nil { fmt.Printf("%s\n", err) os.Exit(exitFail) @@ -411,11 +197,11 @@ func main() { // If no commands found, just show the config if len(os.Args) == 1 { lib.PrintGroupedFlags(getFlagGroups(), "notehub") - lib.ConfigShow() + config.Print() os.Exit(exitOk) } - // Process the sign-in request + // Process the interactive sign-in if flagSignIn { err = authSignIn() if err != nil { @@ -423,6 +209,8 @@ func main() { os.Exit(exitFail) } } + + // Process the sign-in with explicit personal access token if flagSignInToken != "" { err = authSignInToken(flagSignInToken) if err != nil { @@ -430,36 +218,42 @@ func main() { os.Exit(exitFail) } } + + // Get the current API credentials + credentials := config.DefaultCredentials() + + // Process the sign-out if flagSignOut { - err = authSignOut() - if err != nil { - fmt.Printf("%s\n", err) - os.Exit(exitFail) + if credentials != nil { + if err := authRevokeAccessToken(context.Background(), credentials); err != nil { + fmt.Printf("%s\n", err) + os.Exit(exitFail) + } } + os.Exit(exitOk) } - // See if we did something - didSomething := false - // Display the token if flagToken { - _, _, authenticated := lib.ConfigSignedIn() - if !authenticated { - fmt.Printf("please sign in using -signin\n") + if credentials == nil { + fmt.Printf("please sign in using -signin or -signin-token\n") os.Exit(exitFail) } - var token, username string - username, token, err = authToken() - if err != nil { - fmt.Printf("%s\n", err) - os.Exit(exitFail) - } else { - fmt.Printf("To issue HTTP API requests on behalf of %s place the token into the X-Session-Token header field\n", username) - fmt.Printf("%s\n", token) - } - didSomething = true + + fmt.Printf("%s\n", credentials.Token) + os.Exit(exitOk) + } + + // Past this point, we need valid credentials, so validate them here + if err := credentials.Validate(); err != nil { + fmt.Printf("invalid credentials for %s: %s\n\n", config.Hub, err) + fmt.Printf("please use 'notehub -signin' or 'notehub -signin-token' to sign into Notehub\n") + os.Exit(exitFail) } + // See if we did something + didSomething := false + // Create an output function that will be used during -req processing outq := make(chan string) go func() { diff --git a/notehub/trace.go b/notehub/trace.go index ffdc785..67d85bf 100644 --- a/notehub/trace.go +++ b/notehub/trace.go @@ -128,7 +128,8 @@ traceloop: if args[1] == "-" { args[1] = "" } - lib.Config.Hub = args[1] + config, _ := lib.GetConfig() + config.Hub = args[1] } fmt.Printf("hub is %s\n", flagApp) From 4b45aecafcb838c647fc69514a2acf6837270165 Mon Sep 17 00:00:00 2001 From: Scott Frazer Date: Tue, 23 Sep 2025 13:55:29 -0400 Subject: [PATCH 3/6] wip --- go.mod | 6 +- go.sum | 12 -- lib/config.go | 17 +++ notehub/auth.go | 289 +----------------------------------------------- notehub/main.go | 9 +- 5 files changed, 28 insertions(+), 305 deletions(-) diff --git a/go.mod b/go.mod index 56f2801..0f99a5e 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.15 replace github.com/blues/note-cli/lib => ./lib -// replace github.com/blues/note-go => ./note-go +replace github.com/blues/note-go => ../hub/note-go require ( github.com/blues/note-cli/lib v0.0.0-20240515194341-6ba45582741d @@ -12,7 +12,6 @@ require ( github.com/fatih/color v1.17.0 github.com/peterh/liner v1.2.2 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 - golang.org/x/term v0.20.0 ) require ( @@ -24,6 +23,7 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/shirou/gopsutil/v3 v3.24.4 // indirect github.com/tklauser/go-sysconf v0.3.14 // indirect - go.bug.st/serial v1.6.2 // indirect + go.bug.st/serial v1.6.2 + golang.org/x/sys v0.20.0 // indirect periph.io/x/host/v3 v3.8.2 // indirect ) diff --git a/go.sum b/go.sum index a2f377c..dff7332 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,4 @@ github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= -github.com/blues/note-go v1.5.0/go.mod h1:F66ZqObdOhxRRXIwn9+YhVGqB93jMAnqlO2ibwMa998= -github.com/blues/note-go v1.7.2 h1:hasFsMNTnvyp5MPpnhqL46LC1tqzxQ5sGg+NOcJUV8E= -github.com/blues/note-go v1.7.2/go.mod h1:GfslvbmFus7z05P1YykcbMedTKTuDNTf8ryBb1Qjq/4= github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -20,10 +17,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg= github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= @@ -35,8 +30,6 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -60,7 +53,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -85,7 +77,6 @@ go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210316164454-77fc1eacc6aa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -98,9 +89,6 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/lib/config.go b/lib/config.go index b8ee471..354079d 100644 --- a/lib/config.go +++ b/lib/config.go @@ -176,6 +176,23 @@ func (config *ConfigSettings) DefaultCredentials() *ConfigCreds { return nil } +// clear credentials for the currently config.Hub value +// if the credentials are from OAuth, then revoke the access token +func (config *ConfigSettings) RemoveDefaultCredentials() error { + credentials, present := config.HubCreds[config.Hub] + if !present { + return fmt.Errorf("not signed in to %s", config.Hub) + } + + if !credentials.IsOAuthAccessToken() { + notehub.RevokeAccessToken(credentials.Hub, credentials.Token) + } + + // remove the credentials, and write the credentials file + delete(config.HubCreds, config.Hub) + return config.Write() +} + func (config *ConfigSettings) SetDefaultCredentials(token string, email string, expiresAt *time.Time) { config.HubCreds[config.Hub] = ConfigCreds{ Hub: config.Hub, diff --git a/notehub/auth.go b/notehub/auth.go index 446d51c..443e9f0 100644 --- a/notehub/auth.go +++ b/notehub/auth.go @@ -5,62 +5,12 @@ package main import ( - "context" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" "fmt" - "io" - "log" - "math/rand" - "net/http" - "net/url" - "os" - "os/signal" - "strings" - "time" "github.com/blues/note-cli/lib" - "github.com/blues/note-go/note" + "github.com/blues/note-go/notehub" ) -func authIntrospectToken(config *lib.ConfigSettings, personalAccessToken string) (string, error) { - req, err := http.NewRequest(http.MethodGet, "https://"+config.Hub+"/userinfo", nil) - if err != nil { - return "", err - } - - req.Header.Set("Authorization", "Bearer "+personalAccessToken) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", err - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - defer resp.Body.Close() - - userinfo := map[string]interface{}{} - if err := note.JSONUnmarshal(body, &userinfo); err != nil { - return "", err - } - - if resp.StatusCode != http.StatusOK { - err := userinfo["err"] - return "", fmt.Errorf("error introspecting token: %s (http %d)", err, resp.StatusCode) - } - - if email, ok := userinfo["email"].(string); !ok || email == "" { - return "", fmt.Errorf("error introspecting token: no email in response") - } else { - return email, nil - } -} - // Sign into the notehub account with a personal access token func authSignInToken(personalAccessToken string) error { // TODO: maybe call configInit() to set defaults? @@ -72,7 +22,7 @@ func authSignInToken(personalAccessToken string) error { // Print hub if not the default fmt.Printf("notehub: %s\n", config.Hub) - email, err := authIntrospectToken(config, personalAccessToken) + email, err := lib.IntrospectToken(config.Hub, personalAccessToken) if err != nil { return err } @@ -88,235 +38,6 @@ func authSignInToken(personalAccessToken string) error { return nil } -func authRevokeAccessToken(ctx context.Context, credentials *lib.ConfigCreds) error { - // TODO: assert that it's an access token and not a PAT - - form := url.Values{ - "token": {credentials.Token}, - "token_type_hint": {"access_token"}, - "client_id": {"notehub_cli"}, - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://"+credentials.Hub+"/oauth2/revoke", strings.NewReader(form.Encode())) - if err != nil { - return fmt.Errorf("creating request: %w", err) - } - - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("making request: %w", err) - } - defer resp.Body.Close() - - // Per RFC 7009: 200 OK even if token is already invalid; treat as success - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - return nil -} - -type AccessToken struct { - Host string - Email string - AccessToken string - ExpiresAt time.Time -} - -func initiateBrowserBasedLogin(hub string) (*AccessToken, error) { - // these are configured on the OAuth Client within Hydra - clientId := "notehub_cli" - port := 58766 - - // return value - var accessToken *AccessToken - var accessTokenErr error - - randString := func(n int) string { - letterRunes := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") - b := make([]rune, n) - for i := range b { - b[i] = letterRunes[rand.Intn(len(letterRunes))] - } - return string(b) - } - - state := randString(16) - codeVerifier := randString(50) // must be at least 43 characters - hash := sha256.Sum256([]byte(codeVerifier)) - codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:]) - done := make(chan bool, 1) - quit := make(chan os.Signal, 1) - - signal.Notify(quit, os.Interrupt) - defer signal.Reset(os.Interrupt) - - router := http.NewServeMux() - router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - authorizationCode := r.URL.Query().Get("code") - callbackState := r.URL.Query().Get("state") - - errHandler := func(msg string) { - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, "error: %s", msg) - fmt.Printf("error: %s\n", msg) - accessTokenErr = errors.New(msg) - } - - if callbackState != state { - errHandler("state mismatch") - return - } - - /////////////////////////////////////////// - // Get the access token from the authorization code - /////////////////////////////////////////// - - tokenResp, err := http.Post( - (&url.URL{ - Scheme: "https", - Host: hub, - Path: "/oauth2/token", - }).String(), - "application/x-www-form-urlencoded", - strings.NewReader(url.Values{ - "client_id": {clientId}, - "code": {authorizationCode}, - "code_verifier": {codeVerifier}, - "grant_type": {"authorization_code"}, - "redirect_uri": {fmt.Sprintf("http://localhost:%d", port)}, - }.Encode()), - ) - - if err != nil { - errHandler("error on /oauth2/token: " + err.Error()) - return - } - - body, err := io.ReadAll(tokenResp.Body) - if err != nil { - errHandler("could not read body from /oauth2/token: " + err.Error()) - return - } - defer tokenResp.Body.Close() - - var tokenData map[string]interface{} - if err := json.Unmarshal(body, &tokenData); err != nil { - errHandler("could not unmarshal body from /oauth2/token: " + err.Error()) - return - } - - // TODO: check for error in response - // or this could panic when an error is returned - accessTokenString := tokenData["access_token"].(string) - expiresIn := time.Duration(tokenData["expires_in"].(float64)) * time.Second - - /////////////////////////////////////////// - // Get user's information (specifically email) - /////////////////////////////////////////// - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://%s/userinfo", hub), nil) - if err != nil { - errHandler("could not create request for /userinfo: " + err.Error()) - return - } - req.Header.Set("Authorization", "Bearer "+accessTokenString) - userinfoResp, err := http.DefaultClient.Do(req) - if err != nil { - errHandler("could not get userinfo: " + err.Error()) - return - } - - userinfoBody, err := io.ReadAll(userinfoResp.Body) - if err != nil { - errHandler("could not read body from /userinfo: " + err.Error()) - return - } - defer userinfoResp.Body.Close() - - var userinfoData map[string]interface{} - if err := json.Unmarshal(userinfoBody, &userinfoData); err != nil { - errHandler("could not unmarshal body from /userinfo: " + err.Error()) - return - } - - email := userinfoData["email"].(string) - - /////////////////////////////////////////// - // Build the access token response - /////////////////////////////////////////// - - accessToken = &AccessToken{ - Host: hub, - Email: email, - AccessToken: accessTokenString, - ExpiresAt: time.Now().Add(expiresIn), - } - - /////////////////////////////////////////// - // respond to the browser and quit - /////////////////////////////////////////// - - w.Header().Set("Content-Type", "text/html") - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "

Token exchange completed successfully

You may now close this window and return to the CLI application

") - - quit <- os.Interrupt - }) - - server := &http.Server{ - Addr: fmt.Sprintf(":%d", port), - Handler: router, - } - - // Wait for OAuth callback to be hit, then shutdown HTTP server - go func(server *http.Server, quit <-chan os.Signal, done chan<- bool) { - <-quit - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - server.SetKeepAlivesEnabled(false) - if err := server.Shutdown(ctx); err != nil { - log.Printf("error: %v", err) - } - close(done) - }(server, quit, done) - - // Start HTTP server waiting for OAuth callback - go func(server *http.Server) { - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Printf("error: %v", err) - } - }(server) - - // Open this URL to start the process of authentication - authorizeUrl := url.URL{ - Scheme: "https", - Host: hub, - Path: "/oauth2/auth", - RawQuery: url.Values{ - "client_id": {clientId}, - "code_challenge": {codeChallenge}, - "code_challenge_method": {"S256"}, - "redirect_uri": {fmt.Sprintf("http://localhost:%d", port)}, - "response_type": {"code"}, - "scope": {"openid email"}, - "state": {state}, - }.Encode(), - } - - // Open web browser to authorize - fmt.Printf("Opening web browser to initiate authentication...\n") - open(authorizeUrl.String()) - - // Wait for exchange to finish - <-done - - return accessToken, accessTokenErr -} - // Sign into the Notehub account with browser-based OAuth2 flow func authSignIn() error { @@ -328,17 +49,17 @@ func authSignIn() error { credentials := config.DefaultCredentials() - // if signed in and it's an access token, then revoke it + // if signed in with an access token via OAuth, then revoke the access token // we don't want to revoke a PAT because the user explicitly set an // expiration date on that token if credentials != nil && credentials.IsOAuthAccessToken() { - if err := authRevokeAccessToken(context.Background(), credentials); err != nil { + if err := config.RemoveDefaultCredentials(); err != nil { return err } } // initiate the browser-based OAuth2 login flow - accessToken, err := initiateBrowserBasedLogin(config.Hub) + accessToken, err := notehub.InitiateBrowserBasedLogin(config.Hub) if err != nil { return fmt.Errorf("authentication failed: %w", err) } diff --git a/notehub/main.go b/notehub/main.go index c465413..f933ec3 100644 --- a/notehub/main.go +++ b/notehub/main.go @@ -5,7 +5,6 @@ package main import ( - "context" "flag" "fmt" "os" @@ -224,11 +223,9 @@ func main() { // Process the sign-out if flagSignOut { - if credentials != nil { - if err := authRevokeAccessToken(context.Background(), credentials); err != nil { - fmt.Printf("%s\n", err) - os.Exit(exitFail) - } + if err := config.RemoveDefaultCredentials(); err != nil { + fmt.Printf("%s\n", err) + os.Exit(exitFail) } os.Exit(exitOk) } From 0e61add844da2717c32712106c7af5396b45587b Mon Sep 17 00:00:00 2001 From: Scott Frazer Date: Tue, 23 Sep 2025 14:00:59 -0400 Subject: [PATCH 4/6] remove unused function --- notehub/main.go | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/notehub/main.go b/notehub/main.go index f933ec3..a53813d 100644 --- a/notehub/main.go +++ b/notehub/main.go @@ -8,8 +8,6 @@ import ( "flag" "fmt" "os" - "os/exec" - "runtime" "strings" "github.com/blues/note-cli/lib" @@ -102,24 +100,6 @@ func getFlagGroups() []lib.FlagGroup { } } -// open opens the specified URL in the default browser of the user. -func open(url string) error { - var cmd string - var args []string - - switch runtime.GOOS { - case "windows": - cmd = "cmd" - args = []string{"/c", "start"} - case "darwin": - cmd = "open" - default: // "linux", "freebsd", "openbsd", "netbsd" - cmd = "xdg-open" - } - args = append(args, url) - return exec.Command(cmd, args...).Start() -} - // Main entry point func main() { From a75f3310d9b3dfe96d6f6dd0c2d3ddafe80fa7cf Mon Sep 17 00:00:00 2001 From: Scott Frazer Date: Mon, 6 Oct 2025 17:32:00 -0400 Subject: [PATCH 5/6] update go.mod --- go.mod | 5 +++-- go.sum | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 0f99a5e..fc3e96b 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,12 @@ go 1.15 replace github.com/blues/note-cli/lib => ./lib -replace github.com/blues/note-go => ../hub/note-go +// uncomment this for easier testing locally +// replace github.com/blues/note-go => ../hub/note-go require ( github.com/blues/note-cli/lib v0.0.0-20240515194341-6ba45582741d - github.com/blues/note-go v1.7.2 + github.com/blues/note-go v1.7.3 github.com/fatih/color v1.17.0 github.com/peterh/liner v1.2.2 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 diff --git a/go.sum b/go.sum index dff7332..8d970de 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,7 @@ github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= +github.com/blues/note-go v1.5.0/go.mod h1:F66ZqObdOhxRRXIwn9+YhVGqB93jMAnqlO2ibwMa998= +github.com/blues/note-go v1.7.3 h1:aj7kprrgadR6sgxJs0fNMQfZRk1J0Mi4L2ythRtcxus= +github.com/blues/note-go v1.7.3/go.mod h1:GfslvbmFus7z05P1YykcbMedTKTuDNTf8ryBb1Qjq/4= github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -17,8 +20,10 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg= github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= @@ -30,6 +35,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -53,6 +60,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -77,6 +85,7 @@ go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210316164454-77fc1eacc6aa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -89,6 +98,7 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 0db435cdc212bbce3e47903e84b536cd07712374 Mon Sep 17 00:00:00 2001 From: Scott Frazer Date: Mon, 6 Oct 2025 17:36:48 -0400 Subject: [PATCH 6/6] fix compile errors --- notecard/pcap.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/notecard/pcap.go b/notecard/pcap.go index 5c65b08..740605b 100644 --- a/notecard/pcap.go +++ b/notecard/pcap.go @@ -70,7 +70,13 @@ func pcapRecord(outputFile string, pcapType string, card *notecard.Context) erro // Use the explicit PCAP mode provided by the user pcapMode := "pcap-" + pcapType - fmt.Printf("Using PCAP mode: %s (port: %s)\n", pcapMode, lib.Config.IPort[lib.Config.Interface].Port) + config, err := lib.GetConfig() + if err != nil { + return fmt.Errorf("failed to get configuration: %w", err) + } + iport := config.IPort[config.Interface] + + fmt.Printf("Using PCAP mode: %s (port: %s)\n", pcapMode, iport.Port) // Enable PCAP mode _, err = card.TransactionRequest(notecard.Request{ @@ -88,12 +94,12 @@ func pcapRecord(outputFile string, pcapType string, card *notecard.Context) erro time.Sleep(500 * time.Millisecond) // Open the raw serial port for PCAP data streaming - fmt.Printf("Opening serial port for PCAP streaming: %s\n", lib.Config.IPort[lib.Config.Interface].Port) + fmt.Printf("Opening serial port for PCAP streaming: %s\n", iport.Port) // Configure serial port settings baudRate := 115200 // Default baud rate for PCAP - if lib.Config.IPort[lib.Config.Interface].PortConfig > 0 { - baudRate = lib.Config.IPort[lib.Config.Interface].PortConfig + if iport.PortConfig > 0 { + baudRate = iport.PortConfig } mode := &serial.Mode{ @@ -103,7 +109,7 @@ func pcapRecord(outputFile string, pcapType string, card *notecard.Context) erro StopBits: serial.OneStopBit, } - port, err := serial.Open(lib.Config.IPort[lib.Config.Interface].Port, mode) + port, err := serial.Open(iport.Port, mode) if err != nil { return fmt.Errorf("failed to open serial port: %w", err) }