From c9dd40c9cf35d3e59a8255782524c42b125e6703 Mon Sep 17 00:00:00 2001 From: Joe Crypto Date: Mon, 1 Dec 2025 17:38:53 +0000 Subject: [PATCH 1/3] Add multi-user monitoring, proxy support, and authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Support monitoring multiple Roblox users concurrently via ROBLOX_USER_IDS env var - Add selective notifications via NOTIFY_ROBLOX_USER_IDS to control which users trigger alerts - Implement HTTP proxy support for API requests (configurable via PROXY_* env vars) - Add Roblox authentication with cookie persistence using rbxauth library - Refactor metrics to use labeled GaugeVec for tracking multiple users - Add persistent data volume in docker-compose for storing authentication cookies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- auth.go | 83 ++ docker-compose.yml | 3 + go.mod | 2 + go.sum | 6 + main.go | 68 +- metrics.go | 6 +- roblox.go | 30 +- vendor/github.com/anaminus/rbxauth/LICENSE | 21 + vendor/github.com/anaminus/rbxauth/README.md | 5 + vendor/github.com/anaminus/rbxauth/config.go | 291 ++++++ vendor/github.com/anaminus/rbxauth/cookies.go | 36 + vendor/github.com/anaminus/rbxauth/doc.go | 6 + vendor/github.com/anaminus/rbxauth/model.go | 103 ++ vendor/github.com/anaminus/rbxauth/step.go | 83 ++ vendor/github.com/anaminus/rbxauth/stream.go | 216 ++++ vendor/golang.org/x/crypto/AUTHORS | 3 + vendor/golang.org/x/crypto/CONTRIBUTORS | 3 + vendor/golang.org/x/crypto/LICENSE | 27 + vendor/golang.org/x/crypto/PATENTS | 22 + .../x/crypto/ssh/terminal/terminal.go | 987 ++++++++++++++++++ .../golang.org/x/crypto/ssh/terminal/util.go | 114 ++ .../x/crypto/ssh/terminal/util_aix.go | 12 + .../x/crypto/ssh/terminal/util_bsd.go | 12 + .../x/crypto/ssh/terminal/util_linux.go | 10 + .../x/crypto/ssh/terminal/util_plan9.go | 58 + .../x/crypto/ssh/terminal/util_solaris.go | 124 +++ .../x/crypto/ssh/terminal/util_windows.go | 105 ++ vendor/modules.txt | 6 + 28 files changed, 2418 insertions(+), 24 deletions(-) create mode 100644 auth.go create mode 100644 vendor/github.com/anaminus/rbxauth/LICENSE create mode 100644 vendor/github.com/anaminus/rbxauth/README.md create mode 100644 vendor/github.com/anaminus/rbxauth/config.go create mode 100644 vendor/github.com/anaminus/rbxauth/cookies.go create mode 100644 vendor/github.com/anaminus/rbxauth/doc.go create mode 100644 vendor/github.com/anaminus/rbxauth/model.go create mode 100644 vendor/github.com/anaminus/rbxauth/step.go create mode 100644 vendor/github.com/anaminus/rbxauth/stream.go create mode 100644 vendor/golang.org/x/crypto/AUTHORS create mode 100644 vendor/golang.org/x/crypto/CONTRIBUTORS create mode 100644 vendor/golang.org/x/crypto/LICENSE create mode 100644 vendor/golang.org/x/crypto/PATENTS create mode 100644 vendor/golang.org/x/crypto/ssh/terminal/terminal.go create mode 100644 vendor/golang.org/x/crypto/ssh/terminal/util.go create mode 100644 vendor/golang.org/x/crypto/ssh/terminal/util_aix.go create mode 100644 vendor/golang.org/x/crypto/ssh/terminal/util_bsd.go create mode 100644 vendor/golang.org/x/crypto/ssh/terminal/util_linux.go create mode 100644 vendor/golang.org/x/crypto/ssh/terminal/util_plan9.go create mode 100644 vendor/golang.org/x/crypto/ssh/terminal/util_solaris.go create mode 100644 vendor/golang.org/x/crypto/ssh/terminal/util_windows.go diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..3b0a759 --- /dev/null +++ b/auth.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + + "github.com/anaminus/rbxauth" +) + +const cookieFilename = "/data/cookies.txt" + +func testAuth() { + cfg := &rbxauth.Config{} + + cookies, err := loadCookiesFromFile() + if err == nil { + // If cookies are successfully loaded from file, no need to login again + log.Println("Using cookies from file") + } else { + log.Println("No valid cookies found in file, logging in...") + username := "your_username" + password := []byte("your_password") + + cookies, step, err := cfg.Login(username, password) + if err != nil { + if step != nil { + // Handle multi-step verification. + log.Println("Two-step verification required.") + // You can use step.Resend() and step.Verify() methods to handle the verification process. + } else { + log.Fatalf("Error logging in: %v", err) + } + return + } + + err = saveCookiesToFile(cookies) + if err != nil { + log.Printf("Error saving cookies to file: %v", err) + } + } + + fmt.Println("Successfully logged in!") + printCookies(cookies) +} + +func loadCookiesFromFile() ([]*http.Cookie, error) { + file, err := os.Open(cookieFilename) + if err != nil { + return nil, err + } + defer file.Close() + + cookies, err := rbxauth.ReadCookies(file) + if err != nil { + return nil, err + } + + return cookies, nil +} + +func saveCookiesToFile(cookies []*http.Cookie) error { + file, err := os.Create(cookieFilename) + if err != nil { + return err + } + defer file.Close() + + err = rbxauth.WriteCookies(file, cookies) + if err != nil { + return err + } + + return nil +} + +func printCookies(cookies []*http.Cookie) { + fmt.Println("Cookies:") + for _, cookie := range cookies { + fmt.Printf("%s: %s\n", cookie.Name, cookie.Value) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index ddbb7ab..4d26553 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: - 8080/tcp networks: - roblox + volumes: + - robloxtracker-data:/data prometheus: image: prom/prometheus:v2.42.0 restart: unless-stopped @@ -31,3 +33,4 @@ networks: volumes: prometheus-data: + robloxtracker-data: diff --git a/go.mod b/go.mod index 8b732d0..00112a4 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/joecryptotoo/robloxtracker go 1.19 require ( + github.com/anaminus/rbxauth v0.4.0 github.com/gregdel/pushover v1.1.0 github.com/prometheus/client_golang v1.14.0 ) @@ -15,6 +16,7 @@ require ( github.com/prometheus/client_model v0.3.0 // indirect github.com/prometheus/common v0.37.0 // indirect github.com/prometheus/procfs v0.8.0 // indirect + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect google.golang.org/protobuf v1.28.1 // indirect ) diff --git a/go.sum b/go.sum index 5fdfd4e..b808bdb 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,9 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/anaminus/but v0.2.0/go.mod h1:44z5qYo/3MWnZDi6ifH3IgrFWa1VFfdTttL3IYN/9R4= +github.com/anaminus/rbxauth v0.4.0 h1:ifXqqGIJ3Ov9Z+Of38kqeFxT5KXm2XYjQtDTsLzNxyk= +github.com/anaminus/rbxauth v0.4.0/go.mod h1:5NdP/0UdIZ9oHk8IphCNLmPlKeLcewhr0i65ajQol4E= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -206,7 +209,9 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -297,6 +302,7 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/main.go b/main.go index 4eef219..c7a1476 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "strconv" + "strings" "time" "github.com/prometheus/client_golang/prometheus" @@ -12,17 +13,50 @@ import ( ) func main() { - // Create a non-global registry. reg := prometheus.NewRegistry() + // Get the metrics + metrics := robloxMetrics(reg) + // Check for required environment variables - userID, err := strconv.ParseInt(os.Getenv("ROBLOX_USER_ID"), 10, 64) - if err != nil { - log.Println(err) - return + userIDsStr := os.Getenv("ROBLOX_USER_IDS") + userIDsStrSlice := strings.Split(userIDsStr, ",") + userIDs := make([]int64, len(userIDsStrSlice)) + + for i, idStr := range userIDsStrSlice { + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + log.Println(err) + return + } + userIDs[i] = id + } + + notifyIDsStr := os.Getenv("NOTIFY_ROBLOX_USER_IDS") + notifyIDsStrSlice := strings.Split(notifyIDsStr, ",") + notifyIDs := make([]int64, len(notifyIDsStrSlice)) + + for i, idStr := range notifyIDsStrSlice { + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + log.Println(err) + return + } + notifyIDs[i] = id } + for _, userID := range userIDs { + go monitorUser(reg, userID, metrics, notifyIDs) + time.Sleep(time.Second * 1) // Rate limit requests to the API by staggering the requests + } + + // Expose metrics and custom registry via an HTTP server + http.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{Registry: reg})) + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +func monitorUser(reg *prometheus.Registry, userID int64, metrics *Metrics, notifyIDs []int64) { // Get the username string user, err := getUsernameFromID(userID) if err != nil { @@ -32,20 +66,11 @@ func main() { user.LastPresenceChange = time.Now().UTC() - user.Metrics = *robloxMetrics(reg) - // Start presence checker presenceState := 0 user.LastPresenceType = presenceState t := time.NewTicker(time.Second * 5) - // Expose metrics and custom registry via an HTTP server - // using the HandleFor function. "/metrics" is the usual endpoint for that. - http.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{Registry: reg})) - go func() { - log.Fatal(http.ListenAndServe(":8080", nil)) - }() - // Check presence every 5 seconds for range t.C { // Check presence @@ -66,16 +91,21 @@ func main() { log.Printf("Presence: %#v\n", user.Presence) - // Notify if user is online - notifyPresenceChange(user) - user.LastPresenceChange = time.Now().UTC() + // Check if the user is in the list of IDs that we want to receive notifications for + for _, id := range notifyIDs { + if user.ID == id { + // Notify if user is online + notifyPresenceChange(user) + user.LastPresenceChange = time.Now().UTC() + break + } + } } // Update metrics - user.Metrics.UserPresenceType.Set(float64(user.Presence.UserPresenceType)) + metrics.UserPresenceType.With(prometheus.Labels{"userid": user.Name}).Set(float64(user.Presence.UserPresenceType)) // Update presence state presenceState = user.Presence.UserPresenceType } - } diff --git a/metrics.go b/metrics.go index 6141fc2..49e6202 100644 --- a/metrics.go +++ b/metrics.go @@ -5,15 +5,15 @@ import ( ) type Metrics struct { - UserPresenceType prometheus.Gauge + UserPresenceType *prometheus.GaugeVec } func robloxMetrics(reg prometheus.Registerer) *Metrics { m := &Metrics{ - UserPresenceType: prometheus.NewGauge(prometheus.GaugeOpts{ + UserPresenceType: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "UserPresenceType", Help: "Offline, Online, InGame, InStudio, Unknown", - }), + }, []string{"userid"}), } reg.MustRegister(m.UserPresenceType) diff --git a/roblox.go b/roblox.go index 802193f..d85ffbc 100644 --- a/roblox.go +++ b/roblox.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "net/http" + "net/url" + "os" "time" ) @@ -38,8 +40,26 @@ type User struct { Metrics Metrics `json:"metrics"` } +func getHTTPClient() (*http.Client, error) { + proxyURL, err := url.Parse(fmt.Sprintf("http://%s:%s@%s:%s", os.Getenv("PROXY_USER"), os.Getenv("PROXY_PASSWORD"), os.Getenv("PROXY_HOST"), os.Getenv("PROXY_PORT"))) + if err != nil { + return nil, err + } + + return &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + }, + }, nil +} + func getUsernameFromID(id int64) (User, error) { - resp, err := http.Get(fmt.Sprintf("https://users.roblox.com/v1/users/%d", id)) + client, err := getHTTPClient() + if err != nil { + return User{}, err + } + + resp, err := client.Get(fmt.Sprintf("https://users.roblox.com/v1/users/%d", id)) if err != nil { return User{}, err } @@ -72,7 +92,13 @@ func checkPresence(userID int64) (UserPresence, error) { return UserPresence{}, err } - resp, err := http.Post("https://presence.roblox.com/v1/presence/users", "application/json", bytes.NewBuffer(reqBytes)) + client, err := getHTTPClient() + if err != nil { + fmt.Println("Error getting HTTP client:", err) + return UserPresence{}, err + } + + resp, err := client.Post("https://presence.roblox.com/v1/presence/users", "application/json", bytes.NewBuffer(reqBytes)) if err != nil { fmt.Println("Error making request:", err) return UserPresence{}, err diff --git a/vendor/github.com/anaminus/rbxauth/LICENSE b/vendor/github.com/anaminus/rbxauth/LICENSE new file mode 100644 index 0000000..3002e7b --- /dev/null +++ b/vendor/github.com/anaminus/rbxauth/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Anaminus + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/anaminus/rbxauth/README.md b/vendor/github.com/anaminus/rbxauth/README.md new file mode 100644 index 0000000..e0555d2 --- /dev/null +++ b/vendor/github.com/anaminus/rbxauth/README.md @@ -0,0 +1,5 @@ +[![GoDoc](https://godoc.org/github.com/Anaminus/rbxauth?status.svg)](https://godoc.org/github.com/Anaminus/rbxauth) + +# rbxauth + +The rbxauth package is a wrapper for the [Roblox authentication API (v2)](https://auth.roblox.com/docs#!/v2). diff --git a/vendor/github.com/anaminus/rbxauth/config.go b/vendor/github.com/anaminus/rbxauth/config.go new file mode 100644 index 0000000..b0e48d1 --- /dev/null +++ b/vendor/github.com/anaminus/rbxauth/config.go @@ -0,0 +1,291 @@ +package rbxauth + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" +) + +// Each of these constants define the default value used when the corresponding +// Endpoint field in Config is an empty string. +const ( + DefaultLoginEndpoint = "https://auth.roblox.com/v2/login" + DefaultLogoutEndpoint = "https://auth.roblox.com/v2/logout" + DefaultVerifyEndpoint = "https://auth.roblox.com/v2/twostepverification/verify" + DefaultResendEndpoint = "https://auth.roblox.com/v2/twostepverification/resend" + + // The %d verb is replaced with a user ID. + DefaultUserIDEndpoint = "https://api.roblox.com/users/%d" +) + +const tokenHeader = "X-CSRF-TOKEN" + +//////////////////////////////////////////////////////////////////////////////// + +// statusError represents an error derived from the status code of an HTTP +// response. It also wraps an API error response. +type statusError struct { + code int + resp error +} + +// Error implements the error interface. +func (err statusError) Error() string { + if err.resp == nil { + return "http status " + strconv.Itoa(err.code) + ": " + http.StatusText(err.code) + } + return "http status " + strconv.Itoa(err.code) + ": " + err.resp.Error() +} + +// Unwrap implements the Unwrap interface. +func (err statusError) Unwrap() error { + return err.resp +} + +// StatusCode returns the status code of the error. +func (err statusError) StatusCode() int { + return err.code +} + +// if Status wraps err in a statusError if code is not 2XX, and returns err +// otherwise. +func ifStatus(code int, err error) error { + if code < 200 || code >= 300 { + return &statusError{code: code, resp: err} + } + return err +} + +//////////////////////////////////////////////////////////////////////////////// + +// Config configures an authentication action. Authentication endpoints must +// implement Roblox's Auth v2 API. When an endpoint is an empty string, the +// value of the corresponding Default constant is used instead. +type Config struct { + // Client is used to make requests. If nil, the http.DefaultClient is used. + Client *http.Client + + // Token is a string passed through requests to prevent cross-site request + // forgery. The config automatically sets the this value from the previous + // request. + Token string + + // LoginEndpoint specifies the URL used for logging in. + LoginEndpoint string + // LogoutEndpoint specifies the URL used for logging out. + LogoutEndpoint string + // VerifyEndpoint specifies the URL used for verifying a two-step + // authentication code. + VerifyEndpoint string + // ResendEndpoint specifies the URL used for resending a two-step + // authentication code. + ResendEndpoint string + // UserIDEndpoint specifies the URL used to fetch a username from an ID. The + // URL must contain a "%d" format verb, which is replaced with the user ID. + UserIDEndpoint string +} + +func (c *Config) requestAPI(req *http.Request, apiResp interface{}) (resp *http.Response, err error) { + if c.Token != "" { + req.Header.Set(tokenHeader, c.Token) + } + + client := c.Client + if client == nil { + client = http.DefaultClient + } + + resp, err = client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if token := resp.Header.Get(tokenHeader); token != "" { + c.Token = token + } + + jd := json.NewDecoder(resp.Body) + if err = jd.Decode(apiResp); err != nil { + return resp, ifStatus(resp.StatusCode, err) + } + + if e, ok := apiResp.(interface{ errResp() errorsResponse }); ok && e != nil { + if errResp := e.errResp(); len(errResp.Errors) > 0 { + if resp.StatusCode == 403 && + errResp.Errors[0].Code == 0 && + req.Header.Get(tokenHeader) == "" { + // Failed token validation, retry with new token. + return c.requestAPI(req.Clone(context.Background()), apiResp) + } + return nil, ifStatus(resp.StatusCode, errResp) + } + } + + return resp, ifStatus(resp.StatusCode, nil) +} + +// LoginCred attempts to authenticate a user by using the provided credentials. +// +// The cred argument specifies the credentials associated with the account to be +// authenticated. As a special case, if the Type field is "UserID", then the +// Ident field is interpreted as an integer, indicating the user ID of the +// account. Note that an initial request must be made in order to associate the +// ID with its corresponding credentials. +// +// The password argument is specified as a slice for future compatibility, where +// the password may be handled within secured memory. +// +// On success, a list of HTTP cookies representing the session are returned. If +// multi-step authentication is required, then a Step object is additionally +// returned. +// +// If a response has a non-2XX status, then this function returns an error that +// implements `interface { StatusCode() int }`. +func (c Config) LoginCred(cred Cred, password []byte) (cookies []*http.Cookie, step *Step, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("login: %w", err) + } + }() + + if strings.ToLower(cred.Type) == "userid" { + userID, err := strconv.ParseInt(cred.Ident, 10, 64) + if err != nil { + return nil, nil, fmt.Errorf("parse user ID: %w", err) + } + cred.Type = "Username" + cred.Ident, err = c.getUsername(userID) + if err != nil { + return nil, nil, err + } + } + + body, _ := json.Marshal(&loginRequest{ + CredType: cred.Type, + CredValue: cred.Ident, + Password: string(password), + }) + + endpoint := c.LoginEndpoint + if endpoint == "" { + endpoint = DefaultLoginEndpoint + } + req, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + var apiResp loginResponse + resp, err := c.requestAPI(req, &apiResp) + if err != nil { + return nil, nil, err + } + + if apiResp.TwoStepVerificationData != nil { + step := &Step{ + cfg: c, + MediaType: apiResp.TwoStepVerificationData.MediaType, + req: twoStepVerificationVerifyRequest{ + twoStepVerificationTicketRequest: twoStepVerificationTicketRequest{ + Username: apiResp.User.Name, + Ticket: apiResp.TwoStepVerificationData.Ticket, + ActionType: "Login", + }, + }, + } + return resp.Cookies(), step, nil + } + + return resp.Cookies(), nil, nil +} + +// Login wraps LoginCred, using a username for the credentials. +func (c Config) Login(username string, password []byte) ([]*http.Cookie, *Step, error) { + return c.LoginCred(Cred{Type: Username, Ident: username}, password) +} + +// LoginID wraps LoginCred, deriving credentials from the given user ID. Note +// that an initial request must be made in order to associate the ID with its +// corresponding credentials. +func (c Config) LoginID(userID int64, password []byte) ([]*http.Cookie, *Step, error) { + username, err := c.getUsername(userID) + if err != nil { + return nil, nil, err + } + return c.LoginCred(Cred{Type: Username, Ident: username}, password) +} + +func (c Config) Logout(cookies []*http.Cookie) (err error) { + defer func() { + if err != nil { + err = fmt.Errorf("logout: %w", err) + } + }() + + endpoint := c.LogoutEndpoint + if endpoint == "" { + endpoint = DefaultLogoutEndpoint + } + req, err := http.NewRequest("POST", endpoint, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + for _, cookie := range cookies { + req.AddCookie(cookie) + } + + _, err = c.requestAPI(req, &errorsResponse{}) + return err +} + +func (c Config) getUsername(userID int64) (name string, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("user from ID: %w", err) + } + }() + client := c.Client + if client == nil { + client = http.DefaultClient + } + endpoint := c.UserIDEndpoint + if endpoint == "" { + endpoint = DefaultUserIDEndpoint + } + req, err := http.NewRequest("GET", fmt.Sprintf(endpoint, userID), nil) + if err != nil { + return "", err + } + var apiResp struct { + Username string + errorsResponse + } + if _, err = c.requestAPI(req, &apiResp); err != nil { + return "", err + } + return apiResp.Username, nil +} + +//////////////////////////////////////////////////////////////////////////////// + +// These constants define canonical strings used for Cred.Type, and are known to +// be accepted by the Auth v2 API. +const ( + Username string = "Username" // The username associated with the account. + Email string = "Email" // The email associated with the account. + PhoneNumber string = "PhoneNumber" // The phone number associated with the account. +) + +// Cred holds credentials used to identify an account. +type Cred struct { + Type string // Type specifies the kind of identifier. + Ident string // Ident is the identifier itself. +} diff --git a/vendor/github.com/anaminus/rbxauth/cookies.go b/vendor/github.com/anaminus/rbxauth/cookies.go new file mode 100644 index 0000000..3af9b73 --- /dev/null +++ b/vendor/github.com/anaminus/rbxauth/cookies.go @@ -0,0 +1,36 @@ +package rbxauth + +import ( + "bufio" + "fmt" + "io" + "net/http" + "net/textproto" +) + +// ReadCookies parses cookies from r and returns a list of http.Cookies. +// Cookies are parsed as a number of "Set-Cookie" HTTP headers. Returns an +// empty list if the reader is empty. +func ReadCookies(r io.Reader) (cookies []*http.Cookie, err error) { + // There's no direct way to parse cookies, so we have to cheat a little. + h, err := textproto.NewReader(bufio.NewReader(r)).ReadMIMEHeader() + if err != nil && err != io.EOF { + return nil, fmt.Errorf("read cookies: %w", err) + } + resp := http.Response{Header: http.Header(h)} + return resp.Cookies(), nil +} + +// WriteCookies formats a list of cookies as a number of "Set-Cookie" HTTP +// headers and writes them to w. +func WriteCookies(w io.Writer, cookies []*http.Cookie) (err error) { + // More cheating. + h := http.Header{} + for _, cookie := range cookies { + h.Add("Set-Cookie", cookie.String()) + } + if err = h.Write(w); err != nil { + return fmt.Errorf("write cookies: %w", err) + } + return nil +} diff --git a/vendor/github.com/anaminus/rbxauth/doc.go b/vendor/github.com/anaminus/rbxauth/doc.go new file mode 100644 index 0000000..ffe89f5 --- /dev/null +++ b/vendor/github.com/anaminus/rbxauth/doc.go @@ -0,0 +1,6 @@ +// The rbxauth package is a wrapper for the Roblox version 2 authentication API +// (Auth v2). +// +// https://auth.roblox.com/docs +// +package rbxauth diff --git a/vendor/github.com/anaminus/rbxauth/model.go b/vendor/github.com/anaminus/rbxauth/model.go new file mode 100644 index 0000000..d3e455b --- /dev/null +++ b/vendor/github.com/anaminus/rbxauth/model.go @@ -0,0 +1,103 @@ +package rbxauth + +import ( + "strconv" + "strings" +) + +// ErrorResponse implements the error response model of the API. +type ErrorResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Field string `json:"field,omitempty"` +} + +// Error implements the error interface. +func (err ErrorResponse) Error() string { + return "response code " + strconv.Itoa(err.Code) + ": " + err.Message +} + +// errorsResponse implements the errors response model of the API. +type errorsResponse struct { + Errors []ErrorResponse `json:"errors,omitempty"` +} + +// Error implements the error interface. +func (err errorsResponse) Error() string { + s := make([]string, len(err.Errors)) + for i, e := range err.Errors { + s[i] = e.Error() + } + return strings.Join(s, "; ") +} + +// Unwrap implements the Unwrap interface by returning the first error in the +// list. +func (err errorsResponse) Unwrap() error { + if len(err.Errors) == 0 { + return nil + } + return err.Errors[0] +} + +// errResp returns the errorsResponse. +func (err errorsResponse) errResp() errorsResponse { + return err +} + +// loginRequest implements the LoginRequest API model. +type loginRequest struct { + CredType string `json:"ctype,omitempty"` + CredValue string `json:"cvalue,omitempty"` + Password string `json:"password,omitempty"` + CaptchaToken string `json:"captchaToken,omitempty"` + CaptchaProvider string `json:"captchaProvider,omitempty"` +} + +// loginResponse implements the LoginResponse API model. +type loginResponse struct { + User *userResponseV2 `json:"user,omitempty"` + TwoStepVerificationData *twoStepVerificationSentResponse `json:"twoStepVerificationData,omitempty"` + errorsResponse +} + +// userResponseV2 implements the UserResponseV2 API model. +type userResponseV2 struct { + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +// twoStepVerificationSentResponse implements the +// TwoStepVerificationSentResponse API model. +type twoStepVerificationSentResponse struct { + // The media type the two step verification code was sent on (Email, SMS). + MediaType string `json:"mediaType,omitempty"` + // The two step verification ticket. + Ticket string `json:"ticket,omitempty"` +} + +// userResponse implements the response to a UserIDEndpoint request. +type userResponse struct { + ID int64 `json:"Id"` + Username string `json:"Username"` + AvatarURI *string `json:"AvatarUri,omitempty"` + AvatarFinal bool `json:"AvatarFinal"` + IsOnline bool `json:"IsOnline"` + errorsResponse +} + +// twoStepVerificationVerifyRequest implements the +// TwoStepVerificationVerifyRequest API model. +type twoStepVerificationVerifyRequest struct { + twoStepVerificationTicketRequest + Code string `json:"code,omitempty"` + RememberDevice bool `json:"rememberDevice,omitempty"` +} + +// twoStepVerificationTicketRequest implements the +// TwoStepVerificationTicketRequest API model. +type twoStepVerificationTicketRequest struct { + Username string `json:"username,omitempty"` + Ticket string `json:"ticket,omitempty"` + ActionType string `json:"actionType,omitempty"` +} diff --git a/vendor/github.com/anaminus/rbxauth/step.go b/vendor/github.com/anaminus/rbxauth/step.go new file mode 100644 index 0000000..3e7c43b --- /dev/null +++ b/vendor/github.com/anaminus/rbxauth/step.go @@ -0,0 +1,83 @@ +package rbxauth + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +// Step holds the state of a multi-step verification action. +type Step struct { + cfg Config + req twoStepVerificationVerifyRequest + + // MediaType indicates the means by which the verification code was sent. + MediaType string +} + +// Verify receives a verification code to complete authentication. If +// successful, returns HTTP cookies representing the authenticated session. +// +// The remember argument specifies whether the current device should be +// remembered for future authentication. +func (s *Step) Verify(code string, remember bool) (cookies []*http.Cookie, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("verify: %w", err) + } + }() + apiReq := s.req + apiReq.Code = code + apiReq.RememberDevice = remember + body, _ := json.Marshal(&apiReq) + + endpoint := s.cfg.VerifyEndpoint + if endpoint == "" { + endpoint = DefaultVerifyEndpoint + } + req, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := s.cfg.requestAPI(req, &errorsResponse{}) + if err != nil { + return nil, err + } + return resp.Cookies(), nil +} + +// Resend retransmits a two-step verification message. +func (s *Step) Resend() (err error) { + func() { + if err != nil { + err = fmt.Errorf("resend: %w", err) + } + }() + + body, _ := json.Marshal(&s.req.twoStepVerificationTicketRequest) + + endpoint := s.cfg.ResendEndpoint + if endpoint == "" { + endpoint = DefaultResendEndpoint + } + req, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + + var apiResp struct { + twoStepVerificationSentResponse + errorsResponse + } + if _, err = s.cfg.requestAPI(req, &apiResp); err != nil { + return err + } + s.MediaType = apiResp.MediaType + s.req.Ticket = apiResp.Ticket + return nil +} diff --git a/vendor/github.com/anaminus/rbxauth/stream.go b/vendor/github.com/anaminus/rbxauth/stream.go new file mode 100644 index 0000000..3fbbb42 --- /dev/null +++ b/vendor/github.com/anaminus/rbxauth/stream.go @@ -0,0 +1,216 @@ +package rbxauth + +import ( + "bufio" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "syscall" + + "golang.org/x/crypto/ssh/terminal" +) + +// Stream uses a io.Reader and an optional io.Writer to perform an interactive +// login. +type Stream struct { + Config + io.Reader + io.Writer +} + +// write prints to Writer if it exists. +func (s *Stream) write(a ...interface{}) (n int, err error) { + if s.Writer == nil { + return 0, nil + } + return fmt.Fprint(s.Writer, a...) +} + +// write printfs to Writer if it exists. +func (s *Stream) writef(format string, a ...interface{}) (n int, err error) { + if s.Writer == nil { + return 0, nil + } + return fmt.Fprintf(s.Writer, format, a...) +} + +// PromptCred prompts a user to login through the specified input stream. +// Handles multi-step verification, if necessary. If cred.Type and/or cred.Ident +// are empty, then they will be prompted as well. +// +// Returns the updated cred and cookies, or any error that may have occurred. +func (s *Stream) PromptCred(cred Cred) (credout Cred, cookies []*http.Cookie, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("prompt: %w", err) + } + }() + if s.Reader == nil { + return cred, nil, errors.New("stream is missing reader") + } + + switch cred.Type { + case "Username", "Email", "PhoneNumber", "": + default: + return cred, nil, fmt.Errorf("invalid credential type %q", cred.Type) + } + + scanner := bufio.NewScanner(s.Reader) + scanner.Split(bufio.ScanLines) + + // Prompt for credential type. + for cred.Type == "" { + s.write("Enter credential type ((Username), Email, PhoneNumber): ") + if scanner.Scan(); scanner.Err() != nil { + return cred, nil, scanner.Err() + } + cred.Type = strings.ToLower(scanner.Text()) + switch cred.Type { + case "username", "user", "u", "": + cred.Type = "Username" + case "email", "e": + cred.Type = "Email" + case "phonenumber", "phone number", "pn": + cred.Type = "PhoneNumber" + default: + // TODO: maybe support whatever was entered, for forward + // compatibility with the API. + s.writef("Unknown credential type %q\n", cred.Type) + cred.Type = "" + } + } + + // Prompt for identifier. + for cred.Ident == "" { + var msg string + switch cred.Type { + case "Username": + msg = "Enter username: " + case "Email": + msg = "Enter email: " + case "PhoneNumber": + msg = "Enter phone number: " + default: + msg = "Enter " + cred.Type + ": " + } + s.write(msg) + if scanner.Scan(); scanner.Err() != nil { + return cred, nil, scanner.Err() + } + cred.Ident = scanner.Text() + } + + // Prompt for password. + s.writef("Enter password for %s: ", cred.Ident) + var password []byte + if s.Reader == os.Stdin { + // Safely read from stdin. + password, err = terminal.ReadPassword(int(syscall.Stdin)) + os.Stdout.Write([]byte{'\n'}) + if err != nil { + return cred, nil, err + } + } else { + // Fallback to scan. + if scanner.Scan(); scanner.Err() != nil { + return cred, nil, scanner.Err() + } + password = scanner.Bytes() + } + + // Login. + cookies, step, err := s.Config.LoginCred(cred, password) + if err != nil { + return cred, nil, err + } + + if step != nil { + var code string + var remember bool + + // Prompt for verification code. + s.writef("Two-step verification code sent via %s\n", step.MediaType) + for { + s.write("Enter code (leave empty to resend): ") + if scanner.Scan(); scanner.Err() != nil { + return cred, nil, scanner.Err() + } + if code = scanner.Text(); code != "" { + break + } + if err := step.Resend(); err != nil { + return cred, nil, err + } + s.writef("Resent verification code via %s\n", step.MediaType) + } + + // Prompt for remember device. + loop: + for { + s.write("Remember device? ((no), yes): ") + if scanner.Scan(); scanner.Err() != nil { + return cred, nil, scanner.Err() + } + switch text := strings.ToLower(scanner.Text()); text { + case "y", "yes": + remember = true + break loop + case "n", "no", "": + break loop + } + } + + // Verify code. + if cookies, err = step.Verify(code, remember); err != nil { + return cred, nil, err + } + } + + return cred, cookies, nil +} + +// Prompt wraps PromptCred, using a username for the credentials. If the +// username is empty, it will also be prompted. +func (s *Stream) Prompt(username string) (cred Cred, cookies []*http.Cookie, err error) { + if username != "" { + cred.Type = "Username" + cred.Ident = username + } + return s.PromptCred(cred) +} + +// PromptID wraps PromptCred, deriving credentials from the given user ID. If +// the ID is less then 1, then it will also be prompted. +// +// Note that an initial request must be made in order to associate the ID with +// its corresponding credentials. +func (s *Stream) PromptID(userID int64) (cred Cred, cookies []*http.Cookie, err error) { + if userID < 1 { + if s.Reader != nil { + return cred, nil, fmt.Errorf("prompt: %w", errors.New("stream is missing reader")) + } + scanner := bufio.NewScanner(s.Reader) + scanner.Split(bufio.ScanLines) + } + + url := s.Config.UserIDEndpoint + if url == "" { + url = DefaultUserIDEndpoint + } + username, err := s.getUsername(userID) + if err != nil { + return Cred{}, nil, fmt.Errorf("prompt: %w", err) + } + return s.PromptCred(Cred{Type: "Username", Ident: username}) +} + +// StandardStream returns a Stream connected to stdin and stderr. +func StandardStream() *Stream { + return &Stream{ + Reader: os.Stdin, + Writer: os.Stderr, + } +} diff --git a/vendor/golang.org/x/crypto/AUTHORS b/vendor/golang.org/x/crypto/AUTHORS new file mode 100644 index 0000000..2b00ddb --- /dev/null +++ b/vendor/golang.org/x/crypto/AUTHORS @@ -0,0 +1,3 @@ +# This source code refers to The Go Authors for copyright purposes. +# The master list of authors is in the main Go distribution, +# visible at https://tip.golang.org/AUTHORS. diff --git a/vendor/golang.org/x/crypto/CONTRIBUTORS b/vendor/golang.org/x/crypto/CONTRIBUTORS new file mode 100644 index 0000000..1fbd3e9 --- /dev/null +++ b/vendor/golang.org/x/crypto/CONTRIBUTORS @@ -0,0 +1,3 @@ +# This source code was written by the Go contributors. +# The master list of contributors is in the main Go distribution, +# visible at https://tip.golang.org/CONTRIBUTORS. diff --git a/vendor/golang.org/x/crypto/LICENSE b/vendor/golang.org/x/crypto/LICENSE new file mode 100644 index 0000000..6a66aea --- /dev/null +++ b/vendor/golang.org/x/crypto/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/golang.org/x/crypto/PATENTS b/vendor/golang.org/x/crypto/PATENTS new file mode 100644 index 0000000..7330990 --- /dev/null +++ b/vendor/golang.org/x/crypto/PATENTS @@ -0,0 +1,22 @@ +Additional IP Rights Grant (Patents) + +"This implementation" means the copyrightable works distributed by +Google as part of the Go project. + +Google hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable (except as stated in this section) +patent license to make, have made, use, offer to sell, sell, import, +transfer and otherwise run, modify and propagate the contents of this +implementation of Go, where such license applies only to those patent +claims, both currently owned or controlled by Google and acquired in +the future, licensable by Google that are necessarily infringed by this +implementation of Go. This grant does not include claims that would be +infringed only as a consequence of further modification of this +implementation. If you or your agent or exclusive licensee institute or +order or agree to the institution of patent litigation against any +entity (including a cross-claim or counterclaim in a lawsuit) alleging +that this implementation of Go or any code incorporated within this +implementation of Go constitutes direct or contributory patent +infringement, or inducement of patent infringement, then any patent +rights granted to you under this License for this implementation of Go +shall terminate as of the date such litigation is filed. diff --git a/vendor/golang.org/x/crypto/ssh/terminal/terminal.go b/vendor/golang.org/x/crypto/ssh/terminal/terminal.go new file mode 100644 index 0000000..2ffb97b --- /dev/null +++ b/vendor/golang.org/x/crypto/ssh/terminal/terminal.go @@ -0,0 +1,987 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package terminal + +import ( + "bytes" + "io" + "runtime" + "strconv" + "sync" + "unicode/utf8" +) + +// EscapeCodes contains escape sequences that can be written to the terminal in +// order to achieve different styles of text. +type EscapeCodes struct { + // Foreground colors + Black, Red, Green, Yellow, Blue, Magenta, Cyan, White []byte + + // Reset all attributes + Reset []byte +} + +var vt100EscapeCodes = EscapeCodes{ + Black: []byte{keyEscape, '[', '3', '0', 'm'}, + Red: []byte{keyEscape, '[', '3', '1', 'm'}, + Green: []byte{keyEscape, '[', '3', '2', 'm'}, + Yellow: []byte{keyEscape, '[', '3', '3', 'm'}, + Blue: []byte{keyEscape, '[', '3', '4', 'm'}, + Magenta: []byte{keyEscape, '[', '3', '5', 'm'}, + Cyan: []byte{keyEscape, '[', '3', '6', 'm'}, + White: []byte{keyEscape, '[', '3', '7', 'm'}, + + Reset: []byte{keyEscape, '[', '0', 'm'}, +} + +// Terminal contains the state for running a VT100 terminal that is capable of +// reading lines of input. +type Terminal struct { + // AutoCompleteCallback, if non-null, is called for each keypress with + // the full input line and the current position of the cursor (in + // bytes, as an index into |line|). If it returns ok=false, the key + // press is processed normally. Otherwise it returns a replacement line + // and the new cursor position. + AutoCompleteCallback func(line string, pos int, key rune) (newLine string, newPos int, ok bool) + + // Escape contains a pointer to the escape codes for this terminal. + // It's always a valid pointer, although the escape codes themselves + // may be empty if the terminal doesn't support them. + Escape *EscapeCodes + + // lock protects the terminal and the state in this object from + // concurrent processing of a key press and a Write() call. + lock sync.Mutex + + c io.ReadWriter + prompt []rune + + // line is the current line being entered. + line []rune + // pos is the logical position of the cursor in line + pos int + // echo is true if local echo is enabled + echo bool + // pasteActive is true iff there is a bracketed paste operation in + // progress. + pasteActive bool + + // cursorX contains the current X value of the cursor where the left + // edge is 0. cursorY contains the row number where the first row of + // the current line is 0. + cursorX, cursorY int + // maxLine is the greatest value of cursorY so far. + maxLine int + + termWidth, termHeight int + + // outBuf contains the terminal data to be sent. + outBuf []byte + // remainder contains the remainder of any partial key sequences after + // a read. It aliases into inBuf. + remainder []byte + inBuf [256]byte + + // history contains previously entered commands so that they can be + // accessed with the up and down keys. + history stRingBuffer + // historyIndex stores the currently accessed history entry, where zero + // means the immediately previous entry. + historyIndex int + // When navigating up and down the history it's possible to return to + // the incomplete, initial line. That value is stored in + // historyPending. + historyPending string +} + +// NewTerminal runs a VT100 terminal on the given ReadWriter. If the ReadWriter is +// a local terminal, that terminal must first have been put into raw mode. +// prompt is a string that is written at the start of each input line (i.e. +// "> "). +func NewTerminal(c io.ReadWriter, prompt string) *Terminal { + return &Terminal{ + Escape: &vt100EscapeCodes, + c: c, + prompt: []rune(prompt), + termWidth: 80, + termHeight: 24, + echo: true, + historyIndex: -1, + } +} + +const ( + keyCtrlC = 3 + keyCtrlD = 4 + keyCtrlU = 21 + keyEnter = '\r' + keyEscape = 27 + keyBackspace = 127 + keyUnknown = 0xd800 /* UTF-16 surrogate area */ + iota + keyUp + keyDown + keyLeft + keyRight + keyAltLeft + keyAltRight + keyHome + keyEnd + keyDeleteWord + keyDeleteLine + keyClearScreen + keyPasteStart + keyPasteEnd +) + +var ( + crlf = []byte{'\r', '\n'} + pasteStart = []byte{keyEscape, '[', '2', '0', '0', '~'} + pasteEnd = []byte{keyEscape, '[', '2', '0', '1', '~'} +) + +// bytesToKey tries to parse a key sequence from b. If successful, it returns +// the key and the remainder of the input. Otherwise it returns utf8.RuneError. +func bytesToKey(b []byte, pasteActive bool) (rune, []byte) { + if len(b) == 0 { + return utf8.RuneError, nil + } + + if !pasteActive { + switch b[0] { + case 1: // ^A + return keyHome, b[1:] + case 2: // ^B + return keyLeft, b[1:] + case 5: // ^E + return keyEnd, b[1:] + case 6: // ^F + return keyRight, b[1:] + case 8: // ^H + return keyBackspace, b[1:] + case 11: // ^K + return keyDeleteLine, b[1:] + case 12: // ^L + return keyClearScreen, b[1:] + case 23: // ^W + return keyDeleteWord, b[1:] + case 14: // ^N + return keyDown, b[1:] + case 16: // ^P + return keyUp, b[1:] + } + } + + if b[0] != keyEscape { + if !utf8.FullRune(b) { + return utf8.RuneError, b + } + r, l := utf8.DecodeRune(b) + return r, b[l:] + } + + if !pasteActive && len(b) >= 3 && b[0] == keyEscape && b[1] == '[' { + switch b[2] { + case 'A': + return keyUp, b[3:] + case 'B': + return keyDown, b[3:] + case 'C': + return keyRight, b[3:] + case 'D': + return keyLeft, b[3:] + case 'H': + return keyHome, b[3:] + case 'F': + return keyEnd, b[3:] + } + } + + if !pasteActive && len(b) >= 6 && b[0] == keyEscape && b[1] == '[' && b[2] == '1' && b[3] == ';' && b[4] == '3' { + switch b[5] { + case 'C': + return keyAltRight, b[6:] + case 'D': + return keyAltLeft, b[6:] + } + } + + if !pasteActive && len(b) >= 6 && bytes.Equal(b[:6], pasteStart) { + return keyPasteStart, b[6:] + } + + if pasteActive && len(b) >= 6 && bytes.Equal(b[:6], pasteEnd) { + return keyPasteEnd, b[6:] + } + + // If we get here then we have a key that we don't recognise, or a + // partial sequence. It's not clear how one should find the end of a + // sequence without knowing them all, but it seems that [a-zA-Z~] only + // appears at the end of a sequence. + for i, c := range b[0:] { + if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '~' { + return keyUnknown, b[i+1:] + } + } + + return utf8.RuneError, b +} + +// queue appends data to the end of t.outBuf +func (t *Terminal) queue(data []rune) { + t.outBuf = append(t.outBuf, []byte(string(data))...) +} + +var eraseUnderCursor = []rune{' ', keyEscape, '[', 'D'} +var space = []rune{' '} + +func isPrintable(key rune) bool { + isInSurrogateArea := key >= 0xd800 && key <= 0xdbff + return key >= 32 && !isInSurrogateArea +} + +// moveCursorToPos appends data to t.outBuf which will move the cursor to the +// given, logical position in the text. +func (t *Terminal) moveCursorToPos(pos int) { + if !t.echo { + return + } + + x := visualLength(t.prompt) + pos + y := x / t.termWidth + x = x % t.termWidth + + up := 0 + if y < t.cursorY { + up = t.cursorY - y + } + + down := 0 + if y > t.cursorY { + down = y - t.cursorY + } + + left := 0 + if x < t.cursorX { + left = t.cursorX - x + } + + right := 0 + if x > t.cursorX { + right = x - t.cursorX + } + + t.cursorX = x + t.cursorY = y + t.move(up, down, left, right) +} + +func (t *Terminal) move(up, down, left, right int) { + m := []rune{} + + // 1 unit up can be expressed as ^[[A or ^[A + // 5 units up can be expressed as ^[[5A + + if up == 1 { + m = append(m, keyEscape, '[', 'A') + } else if up > 1 { + m = append(m, keyEscape, '[') + m = append(m, []rune(strconv.Itoa(up))...) + m = append(m, 'A') + } + + if down == 1 { + m = append(m, keyEscape, '[', 'B') + } else if down > 1 { + m = append(m, keyEscape, '[') + m = append(m, []rune(strconv.Itoa(down))...) + m = append(m, 'B') + } + + if right == 1 { + m = append(m, keyEscape, '[', 'C') + } else if right > 1 { + m = append(m, keyEscape, '[') + m = append(m, []rune(strconv.Itoa(right))...) + m = append(m, 'C') + } + + if left == 1 { + m = append(m, keyEscape, '[', 'D') + } else if left > 1 { + m = append(m, keyEscape, '[') + m = append(m, []rune(strconv.Itoa(left))...) + m = append(m, 'D') + } + + t.queue(m) +} + +func (t *Terminal) clearLineToRight() { + op := []rune{keyEscape, '[', 'K'} + t.queue(op) +} + +const maxLineLength = 4096 + +func (t *Terminal) setLine(newLine []rune, newPos int) { + if t.echo { + t.moveCursorToPos(0) + t.writeLine(newLine) + for i := len(newLine); i < len(t.line); i++ { + t.writeLine(space) + } + t.moveCursorToPos(newPos) + } + t.line = newLine + t.pos = newPos +} + +func (t *Terminal) advanceCursor(places int) { + t.cursorX += places + t.cursorY += t.cursorX / t.termWidth + if t.cursorY > t.maxLine { + t.maxLine = t.cursorY + } + t.cursorX = t.cursorX % t.termWidth + + if places > 0 && t.cursorX == 0 { + // Normally terminals will advance the current position + // when writing a character. But that doesn't happen + // for the last character in a line. However, when + // writing a character (except a new line) that causes + // a line wrap, the position will be advanced two + // places. + // + // So, if we are stopping at the end of a line, we + // need to write a newline so that our cursor can be + // advanced to the next line. + t.outBuf = append(t.outBuf, '\r', '\n') + } +} + +func (t *Terminal) eraseNPreviousChars(n int) { + if n == 0 { + return + } + + if t.pos < n { + n = t.pos + } + t.pos -= n + t.moveCursorToPos(t.pos) + + copy(t.line[t.pos:], t.line[n+t.pos:]) + t.line = t.line[:len(t.line)-n] + if t.echo { + t.writeLine(t.line[t.pos:]) + for i := 0; i < n; i++ { + t.queue(space) + } + t.advanceCursor(n) + t.moveCursorToPos(t.pos) + } +} + +// countToLeftWord returns then number of characters from the cursor to the +// start of the previous word. +func (t *Terminal) countToLeftWord() int { + if t.pos == 0 { + return 0 + } + + pos := t.pos - 1 + for pos > 0 { + if t.line[pos] != ' ' { + break + } + pos-- + } + for pos > 0 { + if t.line[pos] == ' ' { + pos++ + break + } + pos-- + } + + return t.pos - pos +} + +// countToRightWord returns then number of characters from the cursor to the +// start of the next word. +func (t *Terminal) countToRightWord() int { + pos := t.pos + for pos < len(t.line) { + if t.line[pos] == ' ' { + break + } + pos++ + } + for pos < len(t.line) { + if t.line[pos] != ' ' { + break + } + pos++ + } + return pos - t.pos +} + +// visualLength returns the number of visible glyphs in s. +func visualLength(runes []rune) int { + inEscapeSeq := false + length := 0 + + for _, r := range runes { + switch { + case inEscapeSeq: + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + inEscapeSeq = false + } + case r == '\x1b': + inEscapeSeq = true + default: + length++ + } + } + + return length +} + +// handleKey processes the given key and, optionally, returns a line of text +// that the user has entered. +func (t *Terminal) handleKey(key rune) (line string, ok bool) { + if t.pasteActive && key != keyEnter { + t.addKeyToLine(key) + return + } + + switch key { + case keyBackspace: + if t.pos == 0 { + return + } + t.eraseNPreviousChars(1) + case keyAltLeft: + // move left by a word. + t.pos -= t.countToLeftWord() + t.moveCursorToPos(t.pos) + case keyAltRight: + // move right by a word. + t.pos += t.countToRightWord() + t.moveCursorToPos(t.pos) + case keyLeft: + if t.pos == 0 { + return + } + t.pos-- + t.moveCursorToPos(t.pos) + case keyRight: + if t.pos == len(t.line) { + return + } + t.pos++ + t.moveCursorToPos(t.pos) + case keyHome: + if t.pos == 0 { + return + } + t.pos = 0 + t.moveCursorToPos(t.pos) + case keyEnd: + if t.pos == len(t.line) { + return + } + t.pos = len(t.line) + t.moveCursorToPos(t.pos) + case keyUp: + entry, ok := t.history.NthPreviousEntry(t.historyIndex + 1) + if !ok { + return "", false + } + if t.historyIndex == -1 { + t.historyPending = string(t.line) + } + t.historyIndex++ + runes := []rune(entry) + t.setLine(runes, len(runes)) + case keyDown: + switch t.historyIndex { + case -1: + return + case 0: + runes := []rune(t.historyPending) + t.setLine(runes, len(runes)) + t.historyIndex-- + default: + entry, ok := t.history.NthPreviousEntry(t.historyIndex - 1) + if ok { + t.historyIndex-- + runes := []rune(entry) + t.setLine(runes, len(runes)) + } + } + case keyEnter: + t.moveCursorToPos(len(t.line)) + t.queue([]rune("\r\n")) + line = string(t.line) + ok = true + t.line = t.line[:0] + t.pos = 0 + t.cursorX = 0 + t.cursorY = 0 + t.maxLine = 0 + case keyDeleteWord: + // Delete zero or more spaces and then one or more characters. + t.eraseNPreviousChars(t.countToLeftWord()) + case keyDeleteLine: + // Delete everything from the current cursor position to the + // end of line. + for i := t.pos; i < len(t.line); i++ { + t.queue(space) + t.advanceCursor(1) + } + t.line = t.line[:t.pos] + t.moveCursorToPos(t.pos) + case keyCtrlD: + // Erase the character under the current position. + // The EOF case when the line is empty is handled in + // readLine(). + if t.pos < len(t.line) { + t.pos++ + t.eraseNPreviousChars(1) + } + case keyCtrlU: + t.eraseNPreviousChars(t.pos) + case keyClearScreen: + // Erases the screen and moves the cursor to the home position. + t.queue([]rune("\x1b[2J\x1b[H")) + t.queue(t.prompt) + t.cursorX, t.cursorY = 0, 0 + t.advanceCursor(visualLength(t.prompt)) + t.setLine(t.line, t.pos) + default: + if t.AutoCompleteCallback != nil { + prefix := string(t.line[:t.pos]) + suffix := string(t.line[t.pos:]) + + t.lock.Unlock() + newLine, newPos, completeOk := t.AutoCompleteCallback(prefix+suffix, len(prefix), key) + t.lock.Lock() + + if completeOk { + t.setLine([]rune(newLine), utf8.RuneCount([]byte(newLine)[:newPos])) + return + } + } + if !isPrintable(key) { + return + } + if len(t.line) == maxLineLength { + return + } + t.addKeyToLine(key) + } + return +} + +// addKeyToLine inserts the given key at the current position in the current +// line. +func (t *Terminal) addKeyToLine(key rune) { + if len(t.line) == cap(t.line) { + newLine := make([]rune, len(t.line), 2*(1+len(t.line))) + copy(newLine, t.line) + t.line = newLine + } + t.line = t.line[:len(t.line)+1] + copy(t.line[t.pos+1:], t.line[t.pos:]) + t.line[t.pos] = key + if t.echo { + t.writeLine(t.line[t.pos:]) + } + t.pos++ + t.moveCursorToPos(t.pos) +} + +func (t *Terminal) writeLine(line []rune) { + for len(line) != 0 { + remainingOnLine := t.termWidth - t.cursorX + todo := len(line) + if todo > remainingOnLine { + todo = remainingOnLine + } + t.queue(line[:todo]) + t.advanceCursor(visualLength(line[:todo])) + line = line[todo:] + } +} + +// writeWithCRLF writes buf to w but replaces all occurrences of \n with \r\n. +func writeWithCRLF(w io.Writer, buf []byte) (n int, err error) { + for len(buf) > 0 { + i := bytes.IndexByte(buf, '\n') + todo := len(buf) + if i >= 0 { + todo = i + } + + var nn int + nn, err = w.Write(buf[:todo]) + n += nn + if err != nil { + return n, err + } + buf = buf[todo:] + + if i >= 0 { + if _, err = w.Write(crlf); err != nil { + return n, err + } + n++ + buf = buf[1:] + } + } + + return n, nil +} + +func (t *Terminal) Write(buf []byte) (n int, err error) { + t.lock.Lock() + defer t.lock.Unlock() + + if t.cursorX == 0 && t.cursorY == 0 { + // This is the easy case: there's nothing on the screen that we + // have to move out of the way. + return writeWithCRLF(t.c, buf) + } + + // We have a prompt and possibly user input on the screen. We + // have to clear it first. + t.move(0 /* up */, 0 /* down */, t.cursorX /* left */, 0 /* right */) + t.cursorX = 0 + t.clearLineToRight() + + for t.cursorY > 0 { + t.move(1 /* up */, 0, 0, 0) + t.cursorY-- + t.clearLineToRight() + } + + if _, err = t.c.Write(t.outBuf); err != nil { + return + } + t.outBuf = t.outBuf[:0] + + if n, err = writeWithCRLF(t.c, buf); err != nil { + return + } + + t.writeLine(t.prompt) + if t.echo { + t.writeLine(t.line) + } + + t.moveCursorToPos(t.pos) + + if _, err = t.c.Write(t.outBuf); err != nil { + return + } + t.outBuf = t.outBuf[:0] + return +} + +// ReadPassword temporarily changes the prompt and reads a password, without +// echo, from the terminal. +func (t *Terminal) ReadPassword(prompt string) (line string, err error) { + t.lock.Lock() + defer t.lock.Unlock() + + oldPrompt := t.prompt + t.prompt = []rune(prompt) + t.echo = false + + line, err = t.readLine() + + t.prompt = oldPrompt + t.echo = true + + return +} + +// ReadLine returns a line of input from the terminal. +func (t *Terminal) ReadLine() (line string, err error) { + t.lock.Lock() + defer t.lock.Unlock() + + return t.readLine() +} + +func (t *Terminal) readLine() (line string, err error) { + // t.lock must be held at this point + + if t.cursorX == 0 && t.cursorY == 0 { + t.writeLine(t.prompt) + t.c.Write(t.outBuf) + t.outBuf = t.outBuf[:0] + } + + lineIsPasted := t.pasteActive + + for { + rest := t.remainder + lineOk := false + for !lineOk { + var key rune + key, rest = bytesToKey(rest, t.pasteActive) + if key == utf8.RuneError { + break + } + if !t.pasteActive { + if key == keyCtrlD { + if len(t.line) == 0 { + return "", io.EOF + } + } + if key == keyCtrlC { + return "", io.EOF + } + if key == keyPasteStart { + t.pasteActive = true + if len(t.line) == 0 { + lineIsPasted = true + } + continue + } + } else if key == keyPasteEnd { + t.pasteActive = false + continue + } + if !t.pasteActive { + lineIsPasted = false + } + line, lineOk = t.handleKey(key) + } + if len(rest) > 0 { + n := copy(t.inBuf[:], rest) + t.remainder = t.inBuf[:n] + } else { + t.remainder = nil + } + t.c.Write(t.outBuf) + t.outBuf = t.outBuf[:0] + if lineOk { + if t.echo { + t.historyIndex = -1 + t.history.Add(line) + } + if lineIsPasted { + err = ErrPasteIndicator + } + return + } + + // t.remainder is a slice at the beginning of t.inBuf + // containing a partial key sequence + readBuf := t.inBuf[len(t.remainder):] + var n int + + t.lock.Unlock() + n, err = t.c.Read(readBuf) + t.lock.Lock() + + if err != nil { + return + } + + t.remainder = t.inBuf[:n+len(t.remainder)] + } +} + +// SetPrompt sets the prompt to be used when reading subsequent lines. +func (t *Terminal) SetPrompt(prompt string) { + t.lock.Lock() + defer t.lock.Unlock() + + t.prompt = []rune(prompt) +} + +func (t *Terminal) clearAndRepaintLinePlusNPrevious(numPrevLines int) { + // Move cursor to column zero at the start of the line. + t.move(t.cursorY, 0, t.cursorX, 0) + t.cursorX, t.cursorY = 0, 0 + t.clearLineToRight() + for t.cursorY < numPrevLines { + // Move down a line + t.move(0, 1, 0, 0) + t.cursorY++ + t.clearLineToRight() + } + // Move back to beginning. + t.move(t.cursorY, 0, 0, 0) + t.cursorX, t.cursorY = 0, 0 + + t.queue(t.prompt) + t.advanceCursor(visualLength(t.prompt)) + t.writeLine(t.line) + t.moveCursorToPos(t.pos) +} + +func (t *Terminal) SetSize(width, height int) error { + t.lock.Lock() + defer t.lock.Unlock() + + if width == 0 { + width = 1 + } + + oldWidth := t.termWidth + t.termWidth, t.termHeight = width, height + + switch { + case width == oldWidth: + // If the width didn't change then nothing else needs to be + // done. + return nil + case len(t.line) == 0 && t.cursorX == 0 && t.cursorY == 0: + // If there is nothing on current line and no prompt printed, + // just do nothing + return nil + case width < oldWidth: + // Some terminals (e.g. xterm) will truncate lines that were + // too long when shinking. Others, (e.g. gnome-terminal) will + // attempt to wrap them. For the former, repainting t.maxLine + // works great, but that behaviour goes badly wrong in the case + // of the latter because they have doubled every full line. + + // We assume that we are working on a terminal that wraps lines + // and adjust the cursor position based on every previous line + // wrapping and turning into two. This causes the prompt on + // xterms to move upwards, which isn't great, but it avoids a + // huge mess with gnome-terminal. + if t.cursorX >= t.termWidth { + t.cursorX = t.termWidth - 1 + } + t.cursorY *= 2 + t.clearAndRepaintLinePlusNPrevious(t.maxLine * 2) + case width > oldWidth: + // If the terminal expands then our position calculations will + // be wrong in the future because we think the cursor is + // |t.pos| chars into the string, but there will be a gap at + // the end of any wrapped line. + // + // But the position will actually be correct until we move, so + // we can move back to the beginning and repaint everything. + t.clearAndRepaintLinePlusNPrevious(t.maxLine) + } + + _, err := t.c.Write(t.outBuf) + t.outBuf = t.outBuf[:0] + return err +} + +type pasteIndicatorError struct{} + +func (pasteIndicatorError) Error() string { + return "terminal: ErrPasteIndicator not correctly handled" +} + +// ErrPasteIndicator may be returned from ReadLine as the error, in addition +// to valid line data. It indicates that bracketed paste mode is enabled and +// that the returned line consists only of pasted data. Programs may wish to +// interpret pasted data more literally than typed data. +var ErrPasteIndicator = pasteIndicatorError{} + +// SetBracketedPasteMode requests that the terminal bracket paste operations +// with markers. Not all terminals support this but, if it is supported, then +// enabling this mode will stop any autocomplete callback from running due to +// pastes. Additionally, any lines that are completely pasted will be returned +// from ReadLine with the error set to ErrPasteIndicator. +func (t *Terminal) SetBracketedPasteMode(on bool) { + if on { + io.WriteString(t.c, "\x1b[?2004h") + } else { + io.WriteString(t.c, "\x1b[?2004l") + } +} + +// stRingBuffer is a ring buffer of strings. +type stRingBuffer struct { + // entries contains max elements. + entries []string + max int + // head contains the index of the element most recently added to the ring. + head int + // size contains the number of elements in the ring. + size int +} + +func (s *stRingBuffer) Add(a string) { + if s.entries == nil { + const defaultNumEntries = 100 + s.entries = make([]string, defaultNumEntries) + s.max = defaultNumEntries + } + + s.head = (s.head + 1) % s.max + s.entries[s.head] = a + if s.size < s.max { + s.size++ + } +} + +// NthPreviousEntry returns the value passed to the nth previous call to Add. +// If n is zero then the immediately prior value is returned, if one, then the +// next most recent, and so on. If such an element doesn't exist then ok is +// false. +func (s *stRingBuffer) NthPreviousEntry(n int) (value string, ok bool) { + if n >= s.size { + return "", false + } + index := s.head - n + if index < 0 { + index += s.max + } + return s.entries[index], true +} + +// readPasswordLine reads from reader until it finds \n or io.EOF. +// The slice returned does not include the \n. +// readPasswordLine also ignores any \r it finds. +// Windows uses \r as end of line. So, on Windows, readPasswordLine +// reads until it finds \r and ignores any \n it finds during processing. +func readPasswordLine(reader io.Reader) ([]byte, error) { + var buf [1]byte + var ret []byte + + for { + n, err := reader.Read(buf[:]) + if n > 0 { + switch buf[0] { + case '\b': + if len(ret) > 0 { + ret = ret[:len(ret)-1] + } + case '\n': + if runtime.GOOS != "windows" { + return ret, nil + } + // otherwise ignore \n + case '\r': + if runtime.GOOS == "windows" { + return ret, nil + } + // otherwise ignore \r + default: + ret = append(ret, buf[0]) + } + continue + } + if err != nil { + if err == io.EOF && len(ret) > 0 { + return ret, nil + } + return ret, err + } + } +} diff --git a/vendor/golang.org/x/crypto/ssh/terminal/util.go b/vendor/golang.org/x/crypto/ssh/terminal/util.go new file mode 100644 index 0000000..3911040 --- /dev/null +++ b/vendor/golang.org/x/crypto/ssh/terminal/util.go @@ -0,0 +1,114 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build aix darwin dragonfly freebsd linux,!appengine netbsd openbsd + +// Package terminal provides support functions for dealing with terminals, as +// commonly found on UNIX systems. +// +// Putting a terminal into raw mode is the most common requirement: +// +// oldState, err := terminal.MakeRaw(0) +// if err != nil { +// panic(err) +// } +// defer terminal.Restore(0, oldState) +package terminal // import "golang.org/x/crypto/ssh/terminal" + +import ( + "golang.org/x/sys/unix" +) + +// State contains the state of a terminal. +type State struct { + termios unix.Termios +} + +// IsTerminal returns whether the given file descriptor is a terminal. +func IsTerminal(fd int) bool { + _, err := unix.IoctlGetTermios(fd, ioctlReadTermios) + return err == nil +} + +// MakeRaw put the terminal connected to the given file descriptor into raw +// mode and returns the previous state of the terminal so that it can be +// restored. +func MakeRaw(fd int) (*State, error) { + termios, err := unix.IoctlGetTermios(fd, ioctlReadTermios) + if err != nil { + return nil, err + } + + oldState := State{termios: *termios} + + // This attempts to replicate the behaviour documented for cfmakeraw in + // the termios(3) manpage. + termios.Iflag &^= unix.IGNBRK | unix.BRKINT | unix.PARMRK | unix.ISTRIP | unix.INLCR | unix.IGNCR | unix.ICRNL | unix.IXON + termios.Oflag &^= unix.OPOST + termios.Lflag &^= unix.ECHO | unix.ECHONL | unix.ICANON | unix.ISIG | unix.IEXTEN + termios.Cflag &^= unix.CSIZE | unix.PARENB + termios.Cflag |= unix.CS8 + termios.Cc[unix.VMIN] = 1 + termios.Cc[unix.VTIME] = 0 + if err := unix.IoctlSetTermios(fd, ioctlWriteTermios, termios); err != nil { + return nil, err + } + + return &oldState, nil +} + +// GetState returns the current state of a terminal which may be useful to +// restore the terminal after a signal. +func GetState(fd int) (*State, error) { + termios, err := unix.IoctlGetTermios(fd, ioctlReadTermios) + if err != nil { + return nil, err + } + + return &State{termios: *termios}, nil +} + +// Restore restores the terminal connected to the given file descriptor to a +// previous state. +func Restore(fd int, state *State) error { + return unix.IoctlSetTermios(fd, ioctlWriteTermios, &state.termios) +} + +// GetSize returns the dimensions of the given terminal. +func GetSize(fd int) (width, height int, err error) { + ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ) + if err != nil { + return -1, -1, err + } + return int(ws.Col), int(ws.Row), nil +} + +// passwordReader is an io.Reader that reads from a specific file descriptor. +type passwordReader int + +func (r passwordReader) Read(buf []byte) (int, error) { + return unix.Read(int(r), buf) +} + +// ReadPassword reads a line of input from a terminal without local echo. This +// is commonly used for inputting passwords and other sensitive data. The slice +// returned does not include the \n. +func ReadPassword(fd int) ([]byte, error) { + termios, err := unix.IoctlGetTermios(fd, ioctlReadTermios) + if err != nil { + return nil, err + } + + newState := *termios + newState.Lflag &^= unix.ECHO + newState.Lflag |= unix.ICANON | unix.ISIG + newState.Iflag |= unix.ICRNL + if err := unix.IoctlSetTermios(fd, ioctlWriteTermios, &newState); err != nil { + return nil, err + } + + defer unix.IoctlSetTermios(fd, ioctlWriteTermios, termios) + + return readPasswordLine(passwordReader(fd)) +} diff --git a/vendor/golang.org/x/crypto/ssh/terminal/util_aix.go b/vendor/golang.org/x/crypto/ssh/terminal/util_aix.go new file mode 100644 index 0000000..dfcd627 --- /dev/null +++ b/vendor/golang.org/x/crypto/ssh/terminal/util_aix.go @@ -0,0 +1,12 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build aix + +package terminal + +import "golang.org/x/sys/unix" + +const ioctlReadTermios = unix.TCGETS +const ioctlWriteTermios = unix.TCSETS diff --git a/vendor/golang.org/x/crypto/ssh/terminal/util_bsd.go b/vendor/golang.org/x/crypto/ssh/terminal/util_bsd.go new file mode 100644 index 0000000..cb23a59 --- /dev/null +++ b/vendor/golang.org/x/crypto/ssh/terminal/util_bsd.go @@ -0,0 +1,12 @@ +// Copyright 2013 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build darwin dragonfly freebsd netbsd openbsd + +package terminal + +import "golang.org/x/sys/unix" + +const ioctlReadTermios = unix.TIOCGETA +const ioctlWriteTermios = unix.TIOCSETA diff --git a/vendor/golang.org/x/crypto/ssh/terminal/util_linux.go b/vendor/golang.org/x/crypto/ssh/terminal/util_linux.go new file mode 100644 index 0000000..5fadfe8 --- /dev/null +++ b/vendor/golang.org/x/crypto/ssh/terminal/util_linux.go @@ -0,0 +1,10 @@ +// Copyright 2013 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package terminal + +import "golang.org/x/sys/unix" + +const ioctlReadTermios = unix.TCGETS +const ioctlWriteTermios = unix.TCSETS diff --git a/vendor/golang.org/x/crypto/ssh/terminal/util_plan9.go b/vendor/golang.org/x/crypto/ssh/terminal/util_plan9.go new file mode 100644 index 0000000..9317ac7 --- /dev/null +++ b/vendor/golang.org/x/crypto/ssh/terminal/util_plan9.go @@ -0,0 +1,58 @@ +// Copyright 2016 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package terminal provides support functions for dealing with terminals, as +// commonly found on UNIX systems. +// +// Putting a terminal into raw mode is the most common requirement: +// +// oldState, err := terminal.MakeRaw(0) +// if err != nil { +// panic(err) +// } +// defer terminal.Restore(0, oldState) +package terminal + +import ( + "fmt" + "runtime" +) + +type State struct{} + +// IsTerminal returns whether the given file descriptor is a terminal. +func IsTerminal(fd int) bool { + return false +} + +// MakeRaw put the terminal connected to the given file descriptor into raw +// mode and returns the previous state of the terminal so that it can be +// restored. +func MakeRaw(fd int) (*State, error) { + return nil, fmt.Errorf("terminal: MakeRaw not implemented on %s/%s", runtime.GOOS, runtime.GOARCH) +} + +// GetState returns the current state of a terminal which may be useful to +// restore the terminal after a signal. +func GetState(fd int) (*State, error) { + return nil, fmt.Errorf("terminal: GetState not implemented on %s/%s", runtime.GOOS, runtime.GOARCH) +} + +// Restore restores the terminal connected to the given file descriptor to a +// previous state. +func Restore(fd int, state *State) error { + return fmt.Errorf("terminal: Restore not implemented on %s/%s", runtime.GOOS, runtime.GOARCH) +} + +// GetSize returns the dimensions of the given terminal. +func GetSize(fd int) (width, height int, err error) { + return 0, 0, fmt.Errorf("terminal: GetSize not implemented on %s/%s", runtime.GOOS, runtime.GOARCH) +} + +// ReadPassword reads a line of input from a terminal without local echo. This +// is commonly used for inputting passwords and other sensitive data. The slice +// returned does not include the \n. +func ReadPassword(fd int) ([]byte, error) { + return nil, fmt.Errorf("terminal: ReadPassword not implemented on %s/%s", runtime.GOOS, runtime.GOARCH) +} diff --git a/vendor/golang.org/x/crypto/ssh/terminal/util_solaris.go b/vendor/golang.org/x/crypto/ssh/terminal/util_solaris.go new file mode 100644 index 0000000..3d5f06a --- /dev/null +++ b/vendor/golang.org/x/crypto/ssh/terminal/util_solaris.go @@ -0,0 +1,124 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build solaris + +package terminal // import "golang.org/x/crypto/ssh/terminal" + +import ( + "golang.org/x/sys/unix" + "io" + "syscall" +) + +// State contains the state of a terminal. +type State struct { + termios unix.Termios +} + +// IsTerminal returns whether the given file descriptor is a terminal. +func IsTerminal(fd int) bool { + _, err := unix.IoctlGetTermio(fd, unix.TCGETA) + return err == nil +} + +// ReadPassword reads a line of input from a terminal without local echo. This +// is commonly used for inputting passwords and other sensitive data. The slice +// returned does not include the \n. +func ReadPassword(fd int) ([]byte, error) { + // see also: http://src.illumos.org/source/xref/illumos-gate/usr/src/lib/libast/common/uwin/getpass.c + val, err := unix.IoctlGetTermios(fd, unix.TCGETS) + if err != nil { + return nil, err + } + oldState := *val + + newState := oldState + newState.Lflag &^= syscall.ECHO + newState.Lflag |= syscall.ICANON | syscall.ISIG + newState.Iflag |= syscall.ICRNL + err = unix.IoctlSetTermios(fd, unix.TCSETS, &newState) + if err != nil { + return nil, err + } + + defer unix.IoctlSetTermios(fd, unix.TCSETS, &oldState) + + var buf [16]byte + var ret []byte + for { + n, err := syscall.Read(fd, buf[:]) + if err != nil { + return nil, err + } + if n == 0 { + if len(ret) == 0 { + return nil, io.EOF + } + break + } + if buf[n-1] == '\n' { + n-- + } + ret = append(ret, buf[:n]...) + if n < len(buf) { + break + } + } + + return ret, nil +} + +// MakeRaw puts the terminal connected to the given file descriptor into raw +// mode and returns the previous state of the terminal so that it can be +// restored. +// see http://cr.illumos.org/~webrev/andy_js/1060/ +func MakeRaw(fd int) (*State, error) { + termios, err := unix.IoctlGetTermios(fd, unix.TCGETS) + if err != nil { + return nil, err + } + + oldState := State{termios: *termios} + + termios.Iflag &^= unix.IGNBRK | unix.BRKINT | unix.PARMRK | unix.ISTRIP | unix.INLCR | unix.IGNCR | unix.ICRNL | unix.IXON + termios.Oflag &^= unix.OPOST + termios.Lflag &^= unix.ECHO | unix.ECHONL | unix.ICANON | unix.ISIG | unix.IEXTEN + termios.Cflag &^= unix.CSIZE | unix.PARENB + termios.Cflag |= unix.CS8 + termios.Cc[unix.VMIN] = 1 + termios.Cc[unix.VTIME] = 0 + + if err := unix.IoctlSetTermios(fd, unix.TCSETS, termios); err != nil { + return nil, err + } + + return &oldState, nil +} + +// Restore restores the terminal connected to the given file descriptor to a +// previous state. +func Restore(fd int, oldState *State) error { + return unix.IoctlSetTermios(fd, unix.TCSETS, &oldState.termios) +} + +// GetState returns the current state of a terminal which may be useful to +// restore the terminal after a signal. +func GetState(fd int) (*State, error) { + termios, err := unix.IoctlGetTermios(fd, unix.TCGETS) + if err != nil { + return nil, err + } + + return &State{termios: *termios}, nil +} + +// GetSize returns the dimensions of the given terminal. +func GetSize(fd int) (width, height int, err error) { + ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ) + if err != nil { + return 0, 0, err + } + return int(ws.Col), int(ws.Row), nil +} diff --git a/vendor/golang.org/x/crypto/ssh/terminal/util_windows.go b/vendor/golang.org/x/crypto/ssh/terminal/util_windows.go new file mode 100644 index 0000000..f614e9c --- /dev/null +++ b/vendor/golang.org/x/crypto/ssh/terminal/util_windows.go @@ -0,0 +1,105 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build windows + +// Package terminal provides support functions for dealing with terminals, as +// commonly found on UNIX systems. +// +// Putting a terminal into raw mode is the most common requirement: +// +// oldState, err := terminal.MakeRaw(0) +// if err != nil { +// panic(err) +// } +// defer terminal.Restore(0, oldState) +package terminal + +import ( + "os" + + "golang.org/x/sys/windows" +) + +type State struct { + mode uint32 +} + +// IsTerminal returns whether the given file descriptor is a terminal. +func IsTerminal(fd int) bool { + var st uint32 + err := windows.GetConsoleMode(windows.Handle(fd), &st) + return err == nil +} + +// MakeRaw put the terminal connected to the given file descriptor into raw +// mode and returns the previous state of the terminal so that it can be +// restored. +func MakeRaw(fd int) (*State, error) { + var st uint32 + if err := windows.GetConsoleMode(windows.Handle(fd), &st); err != nil { + return nil, err + } + raw := st &^ (windows.ENABLE_ECHO_INPUT | windows.ENABLE_PROCESSED_INPUT | windows.ENABLE_LINE_INPUT | windows.ENABLE_PROCESSED_OUTPUT) + if err := windows.SetConsoleMode(windows.Handle(fd), raw); err != nil { + return nil, err + } + return &State{st}, nil +} + +// GetState returns the current state of a terminal which may be useful to +// restore the terminal after a signal. +func GetState(fd int) (*State, error) { + var st uint32 + if err := windows.GetConsoleMode(windows.Handle(fd), &st); err != nil { + return nil, err + } + return &State{st}, nil +} + +// Restore restores the terminal connected to the given file descriptor to a +// previous state. +func Restore(fd int, state *State) error { + return windows.SetConsoleMode(windows.Handle(fd), state.mode) +} + +// GetSize returns the visible dimensions of the given terminal. +// +// These dimensions don't include any scrollback buffer height. +func GetSize(fd int) (width, height int, err error) { + var info windows.ConsoleScreenBufferInfo + if err := windows.GetConsoleScreenBufferInfo(windows.Handle(fd), &info); err != nil { + return 0, 0, err + } + return int(info.Window.Right - info.Window.Left + 1), int(info.Window.Bottom - info.Window.Top + 1), nil +} + +// ReadPassword reads a line of input from a terminal without local echo. This +// is commonly used for inputting passwords and other sensitive data. The slice +// returned does not include the \n. +func ReadPassword(fd int) ([]byte, error) { + var st uint32 + if err := windows.GetConsoleMode(windows.Handle(fd), &st); err != nil { + return nil, err + } + old := st + + st &^= (windows.ENABLE_ECHO_INPUT | windows.ENABLE_LINE_INPUT) + st |= (windows.ENABLE_PROCESSED_OUTPUT | windows.ENABLE_PROCESSED_INPUT) + if err := windows.SetConsoleMode(windows.Handle(fd), st); err != nil { + return nil, err + } + + defer windows.SetConsoleMode(windows.Handle(fd), old) + + var h windows.Handle + p, _ := windows.GetCurrentProcess() + if err := windows.DuplicateHandle(p, windows.Handle(fd), p, &h, 0, false, windows.DUPLICATE_SAME_ACCESS); err != nil { + return nil, err + } + + f := os.NewFile(uintptr(h), "stdin") + defer f.Close() + return readPasswordLine(f) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index d8c4428..d205c08 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,3 +1,6 @@ +# github.com/anaminus/rbxauth v0.4.0 +## explicit; go 1.13 +github.com/anaminus/rbxauth # github.com/beorn7/perks v1.0.1 ## explicit; go 1.11 github.com/beorn7/perks/quantile @@ -32,6 +35,9 @@ github.com/prometheus/common/model github.com/prometheus/procfs github.com/prometheus/procfs/internal/fs github.com/prometheus/procfs/internal/util +# golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 +## explicit; go 1.11 +golang.org/x/crypto/ssh/terminal # golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a ## explicit; go 1.17 golang.org/x/sys/internal/unsafeheader From fd975ac251792cd566fff8a72cc7b05ca8d5aaf8 Mon Sep 17 00:00:00 2001 From: Joe Crypto Date: Mon, 1 Dec 2025 17:45:13 +0000 Subject: [PATCH 2/3] Fix LastOnline time parsing by adding JSON tags to UserPresence struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LastOnline field was not being parsed from the Roblox API response because the struct fields lacked JSON tags. This caused the field to default to the zero time value (0001-01-01), resulting in incorrect "last online" calculations showing very large numbers (e.g., 153722867 minutes). Added proper JSON tags to UserPresence and UserPresenceResponse structs to match the camelCase field names returned by the Roblox presence API. Fixes: "last online X minutes ago" now shows accurate values 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- roblox.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/roblox.go b/roblox.go index d85ffbc..07ec1d9 100644 --- a/roblox.go +++ b/roblox.go @@ -12,17 +12,17 @@ import ( ) type UserPresence struct { - UserPresenceType int - LastOnline time.Time - PlaceID int64 - RootPlaceID int64 - GameID string - UniverseID int64 - UserID int64 + UserPresenceType int `json:"userPresenceType"` + LastOnline time.Time `json:"lastOnline"` + PlaceID int64 `json:"placeId"` + RootPlaceID int64 `json:"rootPlaceId"` + GameID string `json:"gameId"` + UniverseID int64 `json:"universeId"` + UserID int64 `json:"userId"` } type UserPresenceResponse struct { - UserPresences []UserPresence + UserPresences []UserPresence `json:"userPresences"` } type User struct { From 7f869335b1e896d8a8f8b8b912866e6de62dd8dc Mon Sep 17 00:00:00 2001 From: Joe Crypto Date: Mon, 1 Dec 2025 18:39:50 +0000 Subject: [PATCH 3/3] Add warp-socks SOCKS5 proxy support and fix last online display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Integrated SOCKS5 proxy support using golang.org/x/net/proxy - Route all Roblox API requests through warp-socks (Cloudflare WARP) - Added retry logic (3 attempts) to handle transient proxy errors - Fixed "last online" display to show "currently active" for active users instead of incorrect large minute values - Disabled HTTP keep-alives to prevent EOF errors with proxy - Added service dependency to ensure warp-socks is healthy before starting - Updated both getUsernameFromID() and checkPresence() with retry logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docker-compose.yml | 29 +- go.mod | 3 +- go.sum | 1 + main.go | 3 +- pushover.go | 6 +- roblox.go | 91 ++++- vendor/golang.org/x/net/AUTHORS | 3 + vendor/golang.org/x/net/CONTRIBUTORS | 3 + vendor/golang.org/x/net/LICENSE | 27 ++ vendor/golang.org/x/net/PATENTS | 22 ++ .../golang.org/x/net/internal/socks/client.go | 168 ++++++++++ .../golang.org/x/net/internal/socks/socks.go | 317 ++++++++++++++++++ vendor/golang.org/x/net/proxy/dial.go | 54 +++ vendor/golang.org/x/net/proxy/direct.go | 31 ++ vendor/golang.org/x/net/proxy/per_host.go | 155 +++++++++ vendor/golang.org/x/net/proxy/proxy.go | 149 ++++++++ vendor/golang.org/x/net/proxy/socks5.go | 42 +++ vendor/modules.txt | 4 + 18 files changed, 1093 insertions(+), 15 deletions(-) create mode 100644 vendor/golang.org/x/net/AUTHORS create mode 100644 vendor/golang.org/x/net/CONTRIBUTORS create mode 100644 vendor/golang.org/x/net/LICENSE create mode 100644 vendor/golang.org/x/net/PATENTS create mode 100644 vendor/golang.org/x/net/internal/socks/client.go create mode 100644 vendor/golang.org/x/net/internal/socks/socks.go create mode 100644 vendor/golang.org/x/net/proxy/dial.go create mode 100644 vendor/golang.org/x/net/proxy/direct.go create mode 100644 vendor/golang.org/x/net/proxy/per_host.go create mode 100644 vendor/golang.org/x/net/proxy/proxy.go create mode 100644 vendor/golang.org/x/net/proxy/socks5.go diff --git a/docker-compose.yml b/docker-compose.yml index 4d26553..fd3205a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: "3.7" +--- services: robloxtracker: image: robloxtracker:latest @@ -11,6 +11,10 @@ services: - roblox volumes: - robloxtracker-data:/data + depends_on: + warp-socks: + condition: service_healthy + prometheus: image: prom/prometheus:v2.42.0 restart: unless-stopped @@ -28,6 +32,29 @@ services: networks: - roblox + warp-socks: + image: monius/docker-warp-socks:latest + privileged: true + restart: unless-stopped + expose: + - 9091/tcp + - 9091/udp + networks: + - roblox + cap_add: + - NET_ADMIN + - SYS_ADMIN + sysctls: + net.ipv6.conf.all.disable_ipv6: 0 + net.ipv4.conf.all.src_valid_mark: 1 + healthcheck: + test: ["CMD", "curl", "-f", "https://www.cloudflare.com/cdn-cgi/trace"] + interval: 30s + timeout: 10s + retries: 5 + deploy: + replicas: 5 + networks: roblox: diff --git a/go.mod b/go.mod index 00112a4..6fd409c 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,12 @@ module github.com/joecryptotoo/robloxtracker -go 1.19 +go 1.18 require ( github.com/anaminus/rbxauth v0.4.0 github.com/gregdel/pushover v1.1.0 github.com/prometheus/client_golang v1.14.0 + golang.org/x/net v0.0.0-20220225172249-27dd8689420f ) require ( diff --git a/go.sum b/go.sum index b808bdb..98d641a 100644 --- a/go.sum +++ b/go.sum @@ -273,6 +273,7 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= diff --git a/main.go b/main.go index c7a1476..39f496d 100644 --- a/main.go +++ b/main.go @@ -84,10 +84,9 @@ func monitorUser(reg *prometheus.Registry, userID int64, metrics *Metrics, notif if presenceState != user.Presence.UserPresenceType { // Update last online time user.LastPresenceType = presenceState - minutesSinceLastOnline := int(time.Now().UTC().Sub(user.Presence.LastOnline).Minutes()) // Log presence change - log.Printf("User %s is %s, last online %d minutes ago\n", user.Name, presenceTypeToString(user.Presence.UserPresenceType), minutesSinceLastOnline) + log.Printf("User %s is %s, last online: %s\n", user.Name, presenceTypeToString(user.Presence.UserPresenceType), formatLastOnline(user.Presence)) log.Printf("Presence: %#v\n", user.Presence) diff --git a/pushover.go b/pushover.go index 21e3922..0a18d88 100644 --- a/pushover.go +++ b/pushover.go @@ -14,12 +14,12 @@ func notifyPresenceChange(user User) { recipient := pushover.NewRecipient(os.Getenv("PUSHOVER_USER_KEY")) minutesSinceLastState := int(time.Now().UTC().Sub(user.LastPresenceChange).Minutes()) - minutesSinceLastOnline := int(time.Now().UTC().Sub(user.Presence.LastOnline).Minutes()) + lastOnlineStr := formatLastOnline(user.Presence) lastPresenceType := presenceTypeToString(user.LastPresenceType) presenceType := presenceTypeToString(user.Presence.UserPresenceType) - message := pushover.NewMessage(fmt.Sprintf("User %s is now %s, was %s for %d minutes, last online %d minutes ago.", - user.Name, presenceType, lastPresenceType, minutesSinceLastState, minutesSinceLastOnline)) + message := pushover.NewMessage(fmt.Sprintf("User %s is now %s, was %s for %d minutes, last online: %s", + user.Name, presenceType, lastPresenceType, minutesSinceLastState, lastOnlineStr)) message.Title = "Roblox Presence Change" message.URL = fmt.Sprintf("https://www.roblox.com/users/%d/profile", user.ID) message.URLTitle = "View Profile" diff --git a/roblox.go b/roblox.go index 07ec1d9..6bd1b06 100644 --- a/roblox.go +++ b/roblox.go @@ -2,13 +2,16 @@ package main import ( "bytes" + "context" "encoding/json" "fmt" "io" + "net" "net/http" - "net/url" "os" "time" + + "golang.org/x/net/proxy" ) type UserPresence struct { @@ -41,15 +44,47 @@ type User struct { } func getHTTPClient() (*http.Client, error) { - proxyURL, err := url.Parse(fmt.Sprintf("http://%s:%s@%s:%s", os.Getenv("PROXY_USER"), os.Getenv("PROXY_PASSWORD"), os.Getenv("PROXY_HOST"), os.Getenv("PROXY_PORT"))) + proxyHost := os.Getenv("PROXY_HOST") + proxyPort := os.Getenv("PROXY_PORT") + proxyUser := os.Getenv("PROXY_USER") + proxyPassword := os.Getenv("PROXY_PASSWORD") + + // If no proxy is configured, return a default client + if proxyHost == "" || proxyPort == "" { + return &http.Client{}, nil + } + + proxyAddr := fmt.Sprintf("%s:%s", proxyHost, proxyPort) + + // Create SOCKS5 dialer with optional authentication + var auth *proxy.Auth + if proxyUser != "" && proxyPassword != "" { + auth = &proxy.Auth{ + User: proxyUser, + Password: proxyPassword, + } + } + + dialer, err := proxy.SOCKS5("tcp", proxyAddr, auth, proxy.Direct) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create SOCKS5 dialer: %w", err) } - return &http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyURL(proxyURL), + // Create HTTP transport with SOCKS5 dialer + // Disable keep-alives to avoid EOF errors when proxy closes connections + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.Dial(network, addr) }, + DisableKeepAlives: true, + MaxIdleConns: 0, + IdleConnTimeout: 0, + TLSHandshakeTimeout: 10 * time.Second, + } + + return &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, }, nil } @@ -59,7 +94,18 @@ func getUsernameFromID(id int64) (User, error) { return User{}, err } - resp, err := client.Get(fmt.Sprintf("https://users.roblox.com/v1/users/%d", id)) + // Retry logic for transient proxy errors + var resp *http.Response + maxRetries := 3 + for attempt := 0; attempt < maxRetries; attempt++ { + resp, err = client.Get(fmt.Sprintf("https://users.roblox.com/v1/users/%d", id)) + if err == nil { + break + } + if attempt < maxRetries-1 { + time.Sleep(time.Second * time.Duration(attempt+1)) + } + } if err != nil { return User{}, err } @@ -98,7 +144,18 @@ func checkPresence(userID int64) (UserPresence, error) { return UserPresence{}, err } - resp, err := client.Post("https://presence.roblox.com/v1/presence/users", "application/json", bytes.NewBuffer(reqBytes)) + // Retry logic for transient proxy errors + var resp *http.Response + maxRetries := 3 + for attempt := 0; attempt < maxRetries; attempt++ { + resp, err = client.Post("https://presence.roblox.com/v1/presence/users", "application/json", bytes.NewBuffer(reqBytes)) + if err == nil { + break + } + if attempt < maxRetries-1 { + time.Sleep(time.Second * time.Duration(attempt+1)) + } + } if err != nil { fmt.Println("Error making request:", err) return UserPresence{}, err @@ -135,3 +192,21 @@ func presenceTypeToString(presenceType int) string { return "Unknown" } } + +func formatLastOnline(presence UserPresence) string { + // If LastOnline is zero/null and user is active, they're currently active + if presence.LastOnline.IsZero() || presence.LastOnline.Year() == 1 { + if presence.UserPresenceType > 0 { + return "currently active" + } + return "unknown" + } + + minutesSinceLastOnline := int(time.Now().UTC().Sub(presence.LastOnline).Minutes()) + if minutesSinceLastOnline < 1 { + return "just now" + } else if minutesSinceLastOnline == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", minutesSinceLastOnline) +} diff --git a/vendor/golang.org/x/net/AUTHORS b/vendor/golang.org/x/net/AUTHORS new file mode 100644 index 0000000..15167cd --- /dev/null +++ b/vendor/golang.org/x/net/AUTHORS @@ -0,0 +1,3 @@ +# This source code refers to The Go Authors for copyright purposes. +# The master list of authors is in the main Go distribution, +# visible at http://tip.golang.org/AUTHORS. diff --git a/vendor/golang.org/x/net/CONTRIBUTORS b/vendor/golang.org/x/net/CONTRIBUTORS new file mode 100644 index 0000000..1c4577e --- /dev/null +++ b/vendor/golang.org/x/net/CONTRIBUTORS @@ -0,0 +1,3 @@ +# This source code was written by the Go contributors. +# The master list of contributors is in the main Go distribution, +# visible at http://tip.golang.org/CONTRIBUTORS. diff --git a/vendor/golang.org/x/net/LICENSE b/vendor/golang.org/x/net/LICENSE new file mode 100644 index 0000000..6a66aea --- /dev/null +++ b/vendor/golang.org/x/net/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/golang.org/x/net/PATENTS b/vendor/golang.org/x/net/PATENTS new file mode 100644 index 0000000..7330990 --- /dev/null +++ b/vendor/golang.org/x/net/PATENTS @@ -0,0 +1,22 @@ +Additional IP Rights Grant (Patents) + +"This implementation" means the copyrightable works distributed by +Google as part of the Go project. + +Google hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable (except as stated in this section) +patent license to make, have made, use, offer to sell, sell, import, +transfer and otherwise run, modify and propagate the contents of this +implementation of Go, where such license applies only to those patent +claims, both currently owned or controlled by Google and acquired in +the future, licensable by Google that are necessarily infringed by this +implementation of Go. This grant does not include claims that would be +infringed only as a consequence of further modification of this +implementation. If you or your agent or exclusive licensee institute or +order or agree to the institution of patent litigation against any +entity (including a cross-claim or counterclaim in a lawsuit) alleging +that this implementation of Go or any code incorporated within this +implementation of Go constitutes direct or contributory patent +infringement, or inducement of patent infringement, then any patent +rights granted to you under this License for this implementation of Go +shall terminate as of the date such litigation is filed. diff --git a/vendor/golang.org/x/net/internal/socks/client.go b/vendor/golang.org/x/net/internal/socks/client.go new file mode 100644 index 0000000..3d6f516 --- /dev/null +++ b/vendor/golang.org/x/net/internal/socks/client.go @@ -0,0 +1,168 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package socks + +import ( + "context" + "errors" + "io" + "net" + "strconv" + "time" +) + +var ( + noDeadline = time.Time{} + aLongTimeAgo = time.Unix(1, 0) +) + +func (d *Dialer) connect(ctx context.Context, c net.Conn, address string) (_ net.Addr, ctxErr error) { + host, port, err := splitHostPort(address) + if err != nil { + return nil, err + } + if deadline, ok := ctx.Deadline(); ok && !deadline.IsZero() { + c.SetDeadline(deadline) + defer c.SetDeadline(noDeadline) + } + if ctx != context.Background() { + errCh := make(chan error, 1) + done := make(chan struct{}) + defer func() { + close(done) + if ctxErr == nil { + ctxErr = <-errCh + } + }() + go func() { + select { + case <-ctx.Done(): + c.SetDeadline(aLongTimeAgo) + errCh <- ctx.Err() + case <-done: + errCh <- nil + } + }() + } + + b := make([]byte, 0, 6+len(host)) // the size here is just an estimate + b = append(b, Version5) + if len(d.AuthMethods) == 0 || d.Authenticate == nil { + b = append(b, 1, byte(AuthMethodNotRequired)) + } else { + ams := d.AuthMethods + if len(ams) > 255 { + return nil, errors.New("too many authentication methods") + } + b = append(b, byte(len(ams))) + for _, am := range ams { + b = append(b, byte(am)) + } + } + if _, ctxErr = c.Write(b); ctxErr != nil { + return + } + + if _, ctxErr = io.ReadFull(c, b[:2]); ctxErr != nil { + return + } + if b[0] != Version5 { + return nil, errors.New("unexpected protocol version " + strconv.Itoa(int(b[0]))) + } + am := AuthMethod(b[1]) + if am == AuthMethodNoAcceptableMethods { + return nil, errors.New("no acceptable authentication methods") + } + if d.Authenticate != nil { + if ctxErr = d.Authenticate(ctx, c, am); ctxErr != nil { + return + } + } + + b = b[:0] + b = append(b, Version5, byte(d.cmd), 0) + if ip := net.ParseIP(host); ip != nil { + if ip4 := ip.To4(); ip4 != nil { + b = append(b, AddrTypeIPv4) + b = append(b, ip4...) + } else if ip6 := ip.To16(); ip6 != nil { + b = append(b, AddrTypeIPv6) + b = append(b, ip6...) + } else { + return nil, errors.New("unknown address type") + } + } else { + if len(host) > 255 { + return nil, errors.New("FQDN too long") + } + b = append(b, AddrTypeFQDN) + b = append(b, byte(len(host))) + b = append(b, host...) + } + b = append(b, byte(port>>8), byte(port)) + if _, ctxErr = c.Write(b); ctxErr != nil { + return + } + + if _, ctxErr = io.ReadFull(c, b[:4]); ctxErr != nil { + return + } + if b[0] != Version5 { + return nil, errors.New("unexpected protocol version " + strconv.Itoa(int(b[0]))) + } + if cmdErr := Reply(b[1]); cmdErr != StatusSucceeded { + return nil, errors.New("unknown error " + cmdErr.String()) + } + if b[2] != 0 { + return nil, errors.New("non-zero reserved field") + } + l := 2 + var a Addr + switch b[3] { + case AddrTypeIPv4: + l += net.IPv4len + a.IP = make(net.IP, net.IPv4len) + case AddrTypeIPv6: + l += net.IPv6len + a.IP = make(net.IP, net.IPv6len) + case AddrTypeFQDN: + if _, err := io.ReadFull(c, b[:1]); err != nil { + return nil, err + } + l += int(b[0]) + default: + return nil, errors.New("unknown address type " + strconv.Itoa(int(b[3]))) + } + if cap(b) < l { + b = make([]byte, l) + } else { + b = b[:l] + } + if _, ctxErr = io.ReadFull(c, b); ctxErr != nil { + return + } + if a.IP != nil { + copy(a.IP, b) + } else { + a.Name = string(b[:len(b)-2]) + } + a.Port = int(b[len(b)-2])<<8 | int(b[len(b)-1]) + return &a, nil +} + +func splitHostPort(address string) (string, int, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return "", 0, err + } + portnum, err := strconv.Atoi(port) + if err != nil { + return "", 0, err + } + if 1 > portnum || portnum > 0xffff { + return "", 0, errors.New("port number out of range " + port) + } + return host, portnum, nil +} diff --git a/vendor/golang.org/x/net/internal/socks/socks.go b/vendor/golang.org/x/net/internal/socks/socks.go new file mode 100644 index 0000000..97db234 --- /dev/null +++ b/vendor/golang.org/x/net/internal/socks/socks.go @@ -0,0 +1,317 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package socks provides a SOCKS version 5 client implementation. +// +// SOCKS protocol version 5 is defined in RFC 1928. +// Username/Password authentication for SOCKS version 5 is defined in +// RFC 1929. +package socks + +import ( + "context" + "errors" + "io" + "net" + "strconv" +) + +// A Command represents a SOCKS command. +type Command int + +func (cmd Command) String() string { + switch cmd { + case CmdConnect: + return "socks connect" + case cmdBind: + return "socks bind" + default: + return "socks " + strconv.Itoa(int(cmd)) + } +} + +// An AuthMethod represents a SOCKS authentication method. +type AuthMethod int + +// A Reply represents a SOCKS command reply code. +type Reply int + +func (code Reply) String() string { + switch code { + case StatusSucceeded: + return "succeeded" + case 0x01: + return "general SOCKS server failure" + case 0x02: + return "connection not allowed by ruleset" + case 0x03: + return "network unreachable" + case 0x04: + return "host unreachable" + case 0x05: + return "connection refused" + case 0x06: + return "TTL expired" + case 0x07: + return "command not supported" + case 0x08: + return "address type not supported" + default: + return "unknown code: " + strconv.Itoa(int(code)) + } +} + +// Wire protocol constants. +const ( + Version5 = 0x05 + + AddrTypeIPv4 = 0x01 + AddrTypeFQDN = 0x03 + AddrTypeIPv6 = 0x04 + + CmdConnect Command = 0x01 // establishes an active-open forward proxy connection + cmdBind Command = 0x02 // establishes a passive-open forward proxy connection + + AuthMethodNotRequired AuthMethod = 0x00 // no authentication required + AuthMethodUsernamePassword AuthMethod = 0x02 // use username/password + AuthMethodNoAcceptableMethods AuthMethod = 0xff // no acceptable authentication methods + + StatusSucceeded Reply = 0x00 +) + +// An Addr represents a SOCKS-specific address. +// Either Name or IP is used exclusively. +type Addr struct { + Name string // fully-qualified domain name + IP net.IP + Port int +} + +func (a *Addr) Network() string { return "socks" } + +func (a *Addr) String() string { + if a == nil { + return "" + } + port := strconv.Itoa(a.Port) + if a.IP == nil { + return net.JoinHostPort(a.Name, port) + } + return net.JoinHostPort(a.IP.String(), port) +} + +// A Conn represents a forward proxy connection. +type Conn struct { + net.Conn + + boundAddr net.Addr +} + +// BoundAddr returns the address assigned by the proxy server for +// connecting to the command target address from the proxy server. +func (c *Conn) BoundAddr() net.Addr { + if c == nil { + return nil + } + return c.boundAddr +} + +// A Dialer holds SOCKS-specific options. +type Dialer struct { + cmd Command // either CmdConnect or cmdBind + proxyNetwork string // network between a proxy server and a client + proxyAddress string // proxy server address + + // ProxyDial specifies the optional dial function for + // establishing the transport connection. + ProxyDial func(context.Context, string, string) (net.Conn, error) + + // AuthMethods specifies the list of request authentication + // methods. + // If empty, SOCKS client requests only AuthMethodNotRequired. + AuthMethods []AuthMethod + + // Authenticate specifies the optional authentication + // function. It must be non-nil when AuthMethods is not empty. + // It must return an error when the authentication is failed. + Authenticate func(context.Context, io.ReadWriter, AuthMethod) error +} + +// DialContext connects to the provided address on the provided +// network. +// +// The returned error value may be a net.OpError. When the Op field of +// net.OpError contains "socks", the Source field contains a proxy +// server address and the Addr field contains a command target +// address. +// +// See func Dial of the net package of standard library for a +// description of the network and address parameters. +func (d *Dialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + if err := d.validateTarget(network, address); err != nil { + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: err} + } + if ctx == nil { + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: errors.New("nil context")} + } + var err error + var c net.Conn + if d.ProxyDial != nil { + c, err = d.ProxyDial(ctx, d.proxyNetwork, d.proxyAddress) + } else { + var dd net.Dialer + c, err = dd.DialContext(ctx, d.proxyNetwork, d.proxyAddress) + } + if err != nil { + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: err} + } + a, err := d.connect(ctx, c, address) + if err != nil { + c.Close() + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: err} + } + return &Conn{Conn: c, boundAddr: a}, nil +} + +// DialWithConn initiates a connection from SOCKS server to the target +// network and address using the connection c that is already +// connected to the SOCKS server. +// +// It returns the connection's local address assigned by the SOCKS +// server. +func (d *Dialer) DialWithConn(ctx context.Context, c net.Conn, network, address string) (net.Addr, error) { + if err := d.validateTarget(network, address); err != nil { + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: err} + } + if ctx == nil { + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: errors.New("nil context")} + } + a, err := d.connect(ctx, c, address) + if err != nil { + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: err} + } + return a, nil +} + +// Dial connects to the provided address on the provided network. +// +// Unlike DialContext, it returns a raw transport connection instead +// of a forward proxy connection. +// +// Deprecated: Use DialContext or DialWithConn instead. +func (d *Dialer) Dial(network, address string) (net.Conn, error) { + if err := d.validateTarget(network, address); err != nil { + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: err} + } + var err error + var c net.Conn + if d.ProxyDial != nil { + c, err = d.ProxyDial(context.Background(), d.proxyNetwork, d.proxyAddress) + } else { + c, err = net.Dial(d.proxyNetwork, d.proxyAddress) + } + if err != nil { + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: err} + } + if _, err := d.DialWithConn(context.Background(), c, network, address); err != nil { + c.Close() + return nil, err + } + return c, nil +} + +func (d *Dialer) validateTarget(network, address string) error { + switch network { + case "tcp", "tcp6", "tcp4": + default: + return errors.New("network not implemented") + } + switch d.cmd { + case CmdConnect, cmdBind: + default: + return errors.New("command not implemented") + } + return nil +} + +func (d *Dialer) pathAddrs(address string) (proxy, dst net.Addr, err error) { + for i, s := range []string{d.proxyAddress, address} { + host, port, err := splitHostPort(s) + if err != nil { + return nil, nil, err + } + a := &Addr{Port: port} + a.IP = net.ParseIP(host) + if a.IP == nil { + a.Name = host + } + if i == 0 { + proxy = a + } else { + dst = a + } + } + return +} + +// NewDialer returns a new Dialer that dials through the provided +// proxy server's network and address. +func NewDialer(network, address string) *Dialer { + return &Dialer{proxyNetwork: network, proxyAddress: address, cmd: CmdConnect} +} + +const ( + authUsernamePasswordVersion = 0x01 + authStatusSucceeded = 0x00 +) + +// UsernamePassword are the credentials for the username/password +// authentication method. +type UsernamePassword struct { + Username string + Password string +} + +// Authenticate authenticates a pair of username and password with the +// proxy server. +func (up *UsernamePassword) Authenticate(ctx context.Context, rw io.ReadWriter, auth AuthMethod) error { + switch auth { + case AuthMethodNotRequired: + return nil + case AuthMethodUsernamePassword: + if len(up.Username) == 0 || len(up.Username) > 255 || len(up.Password) == 0 || len(up.Password) > 255 { + return errors.New("invalid username/password") + } + b := []byte{authUsernamePasswordVersion} + b = append(b, byte(len(up.Username))) + b = append(b, up.Username...) + b = append(b, byte(len(up.Password))) + b = append(b, up.Password...) + // TODO(mikio): handle IO deadlines and cancelation if + // necessary + if _, err := rw.Write(b); err != nil { + return err + } + if _, err := io.ReadFull(rw, b[:2]); err != nil { + return err + } + if b[0] != authUsernamePasswordVersion { + return errors.New("invalid username/password version") + } + if b[1] != authStatusSucceeded { + return errors.New("username/password authentication failed") + } + return nil + } + return errors.New("unsupported authentication method " + strconv.Itoa(int(auth))) +} diff --git a/vendor/golang.org/x/net/proxy/dial.go b/vendor/golang.org/x/net/proxy/dial.go new file mode 100644 index 0000000..811c2e4 --- /dev/null +++ b/vendor/golang.org/x/net/proxy/dial.go @@ -0,0 +1,54 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package proxy + +import ( + "context" + "net" +) + +// A ContextDialer dials using a context. +type ContextDialer interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) +} + +// Dial works like DialContext on net.Dialer but using a dialer returned by FromEnvironment. +// +// The passed ctx is only used for returning the Conn, not the lifetime of the Conn. +// +// Custom dialers (registered via RegisterDialerType) that do not implement ContextDialer +// can leak a goroutine for as long as it takes the underlying Dialer implementation to timeout. +// +// A Conn returned from a successful Dial after the context has been cancelled will be immediately closed. +func Dial(ctx context.Context, network, address string) (net.Conn, error) { + d := FromEnvironment() + if xd, ok := d.(ContextDialer); ok { + return xd.DialContext(ctx, network, address) + } + return dialContext(ctx, d, network, address) +} + +// WARNING: this can leak a goroutine for as long as the underlying Dialer implementation takes to timeout +// A Conn returned from a successful Dial after the context has been cancelled will be immediately closed. +func dialContext(ctx context.Context, d Dialer, network, address string) (net.Conn, error) { + var ( + conn net.Conn + done = make(chan struct{}, 1) + err error + ) + go func() { + conn, err = d.Dial(network, address) + close(done) + if conn != nil && ctx.Err() != nil { + conn.Close() + } + }() + select { + case <-ctx.Done(): + err = ctx.Err() + case <-done: + } + return conn, err +} diff --git a/vendor/golang.org/x/net/proxy/direct.go b/vendor/golang.org/x/net/proxy/direct.go new file mode 100644 index 0000000..3d66bde --- /dev/null +++ b/vendor/golang.org/x/net/proxy/direct.go @@ -0,0 +1,31 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package proxy + +import ( + "context" + "net" +) + +type direct struct{} + +// Direct implements Dialer by making network connections directly using net.Dial or net.DialContext. +var Direct = direct{} + +var ( + _ Dialer = Direct + _ ContextDialer = Direct +) + +// Dial directly invokes net.Dial with the supplied parameters. +func (direct) Dial(network, addr string) (net.Conn, error) { + return net.Dial(network, addr) +} + +// DialContext instantiates a net.Dialer and invokes its DialContext receiver with the supplied parameters. +func (direct) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, addr) +} diff --git a/vendor/golang.org/x/net/proxy/per_host.go b/vendor/golang.org/x/net/proxy/per_host.go new file mode 100644 index 0000000..573fe79 --- /dev/null +++ b/vendor/golang.org/x/net/proxy/per_host.go @@ -0,0 +1,155 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package proxy + +import ( + "context" + "net" + "strings" +) + +// A PerHost directs connections to a default Dialer unless the host name +// requested matches one of a number of exceptions. +type PerHost struct { + def, bypass Dialer + + bypassNetworks []*net.IPNet + bypassIPs []net.IP + bypassZones []string + bypassHosts []string +} + +// NewPerHost returns a PerHost Dialer that directs connections to either +// defaultDialer or bypass, depending on whether the connection matches one of +// the configured rules. +func NewPerHost(defaultDialer, bypass Dialer) *PerHost { + return &PerHost{ + def: defaultDialer, + bypass: bypass, + } +} + +// Dial connects to the address addr on the given network through either +// defaultDialer or bypass. +func (p *PerHost) Dial(network, addr string) (c net.Conn, err error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + + return p.dialerForRequest(host).Dial(network, addr) +} + +// DialContext connects to the address addr on the given network through either +// defaultDialer or bypass. +func (p *PerHost) DialContext(ctx context.Context, network, addr string) (c net.Conn, err error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + d := p.dialerForRequest(host) + if x, ok := d.(ContextDialer); ok { + return x.DialContext(ctx, network, addr) + } + return dialContext(ctx, d, network, addr) +} + +func (p *PerHost) dialerForRequest(host string) Dialer { + if ip := net.ParseIP(host); ip != nil { + for _, net := range p.bypassNetworks { + if net.Contains(ip) { + return p.bypass + } + } + for _, bypassIP := range p.bypassIPs { + if bypassIP.Equal(ip) { + return p.bypass + } + } + return p.def + } + + for _, zone := range p.bypassZones { + if strings.HasSuffix(host, zone) { + return p.bypass + } + if host == zone[1:] { + // For a zone ".example.com", we match "example.com" + // too. + return p.bypass + } + } + for _, bypassHost := range p.bypassHosts { + if bypassHost == host { + return p.bypass + } + } + return p.def +} + +// AddFromString parses a string that contains comma-separated values +// specifying hosts that should use the bypass proxy. Each value is either an +// IP address, a CIDR range, a zone (*.example.com) or a host name +// (localhost). A best effort is made to parse the string and errors are +// ignored. +func (p *PerHost) AddFromString(s string) { + hosts := strings.Split(s, ",") + for _, host := range hosts { + host = strings.TrimSpace(host) + if len(host) == 0 { + continue + } + if strings.Contains(host, "/") { + // We assume that it's a CIDR address like 127.0.0.0/8 + if _, net, err := net.ParseCIDR(host); err == nil { + p.AddNetwork(net) + } + continue + } + if ip := net.ParseIP(host); ip != nil { + p.AddIP(ip) + continue + } + if strings.HasPrefix(host, "*.") { + p.AddZone(host[1:]) + continue + } + p.AddHost(host) + } +} + +// AddIP specifies an IP address that will use the bypass proxy. Note that +// this will only take effect if a literal IP address is dialed. A connection +// to a named host will never match an IP. +func (p *PerHost) AddIP(ip net.IP) { + p.bypassIPs = append(p.bypassIPs, ip) +} + +// AddNetwork specifies an IP range that will use the bypass proxy. Note that +// this will only take effect if a literal IP address is dialed. A connection +// to a named host will never match. +func (p *PerHost) AddNetwork(net *net.IPNet) { + p.bypassNetworks = append(p.bypassNetworks, net) +} + +// AddZone specifies a DNS suffix that will use the bypass proxy. A zone of +// "example.com" matches "example.com" and all of its subdomains. +func (p *PerHost) AddZone(zone string) { + if strings.HasSuffix(zone, ".") { + zone = zone[:len(zone)-1] + } + if !strings.HasPrefix(zone, ".") { + zone = "." + zone + } + p.bypassZones = append(p.bypassZones, zone) +} + +// AddHost specifies a host name that will use the bypass proxy. +func (p *PerHost) AddHost(host string) { + if strings.HasSuffix(host, ".") { + host = host[:len(host)-1] + } + p.bypassHosts = append(p.bypassHosts, host) +} diff --git a/vendor/golang.org/x/net/proxy/proxy.go b/vendor/golang.org/x/net/proxy/proxy.go new file mode 100644 index 0000000..9ff4b9a --- /dev/null +++ b/vendor/golang.org/x/net/proxy/proxy.go @@ -0,0 +1,149 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package proxy provides support for a variety of protocols to proxy network +// data. +package proxy // import "golang.org/x/net/proxy" + +import ( + "errors" + "net" + "net/url" + "os" + "sync" +) + +// A Dialer is a means to establish a connection. +// Custom dialers should also implement ContextDialer. +type Dialer interface { + // Dial connects to the given address via the proxy. + Dial(network, addr string) (c net.Conn, err error) +} + +// Auth contains authentication parameters that specific Dialers may require. +type Auth struct { + User, Password string +} + +// FromEnvironment returns the dialer specified by the proxy-related +// variables in the environment and makes underlying connections +// directly. +func FromEnvironment() Dialer { + return FromEnvironmentUsing(Direct) +} + +// FromEnvironmentUsing returns the dialer specify by the proxy-related +// variables in the environment and makes underlying connections +// using the provided forwarding Dialer (for instance, a *net.Dialer +// with desired configuration). +func FromEnvironmentUsing(forward Dialer) Dialer { + allProxy := allProxyEnv.Get() + if len(allProxy) == 0 { + return forward + } + + proxyURL, err := url.Parse(allProxy) + if err != nil { + return forward + } + proxy, err := FromURL(proxyURL, forward) + if err != nil { + return forward + } + + noProxy := noProxyEnv.Get() + if len(noProxy) == 0 { + return proxy + } + + perHost := NewPerHost(proxy, forward) + perHost.AddFromString(noProxy) + return perHost +} + +// proxySchemes is a map from URL schemes to a function that creates a Dialer +// from a URL with such a scheme. +var proxySchemes map[string]func(*url.URL, Dialer) (Dialer, error) + +// RegisterDialerType takes a URL scheme and a function to generate Dialers from +// a URL with that scheme and a forwarding Dialer. Registered schemes are used +// by FromURL. +func RegisterDialerType(scheme string, f func(*url.URL, Dialer) (Dialer, error)) { + if proxySchemes == nil { + proxySchemes = make(map[string]func(*url.URL, Dialer) (Dialer, error)) + } + proxySchemes[scheme] = f +} + +// FromURL returns a Dialer given a URL specification and an underlying +// Dialer for it to make network requests. +func FromURL(u *url.URL, forward Dialer) (Dialer, error) { + var auth *Auth + if u.User != nil { + auth = new(Auth) + auth.User = u.User.Username() + if p, ok := u.User.Password(); ok { + auth.Password = p + } + } + + switch u.Scheme { + case "socks5", "socks5h": + addr := u.Hostname() + port := u.Port() + if port == "" { + port = "1080" + } + return SOCKS5("tcp", net.JoinHostPort(addr, port), auth, forward) + } + + // If the scheme doesn't match any of the built-in schemes, see if it + // was registered by another package. + if proxySchemes != nil { + if f, ok := proxySchemes[u.Scheme]; ok { + return f(u, forward) + } + } + + return nil, errors.New("proxy: unknown scheme: " + u.Scheme) +} + +var ( + allProxyEnv = &envOnce{ + names: []string{"ALL_PROXY", "all_proxy"}, + } + noProxyEnv = &envOnce{ + names: []string{"NO_PROXY", "no_proxy"}, + } +) + +// envOnce looks up an environment variable (optionally by multiple +// names) once. It mitigates expensive lookups on some platforms +// (e.g. Windows). +// (Borrowed from net/http/transport.go) +type envOnce struct { + names []string + once sync.Once + val string +} + +func (e *envOnce) Get() string { + e.once.Do(e.init) + return e.val +} + +func (e *envOnce) init() { + for _, n := range e.names { + e.val = os.Getenv(n) + if e.val != "" { + return + } + } +} + +// reset is used by tests +func (e *envOnce) reset() { + e.once = sync.Once{} + e.val = "" +} diff --git a/vendor/golang.org/x/net/proxy/socks5.go b/vendor/golang.org/x/net/proxy/socks5.go new file mode 100644 index 0000000..c91651f --- /dev/null +++ b/vendor/golang.org/x/net/proxy/socks5.go @@ -0,0 +1,42 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package proxy + +import ( + "context" + "net" + + "golang.org/x/net/internal/socks" +) + +// SOCKS5 returns a Dialer that makes SOCKSv5 connections to the given +// address with an optional username and password. +// See RFC 1928 and RFC 1929. +func SOCKS5(network, address string, auth *Auth, forward Dialer) (Dialer, error) { + d := socks.NewDialer(network, address) + if forward != nil { + if f, ok := forward.(ContextDialer); ok { + d.ProxyDial = func(ctx context.Context, network string, address string) (net.Conn, error) { + return f.DialContext(ctx, network, address) + } + } else { + d.ProxyDial = func(ctx context.Context, network string, address string) (net.Conn, error) { + return dialContext(ctx, forward, network, address) + } + } + } + if auth != nil { + up := socks.UsernamePassword{ + Username: auth.User, + Password: auth.Password, + } + d.AuthMethods = []socks.AuthMethod{ + socks.AuthMethodNotRequired, + socks.AuthMethodUsernamePassword, + } + d.Authenticate = up.Authenticate + } + return d, nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index d205c08..6b0c8cd 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -38,6 +38,10 @@ github.com/prometheus/procfs/internal/util # golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 ## explicit; go 1.11 golang.org/x/crypto/ssh/terminal +# golang.org/x/net v0.0.0-20220225172249-27dd8689420f +## explicit; go 1.17 +golang.org/x/net/internal/socks +golang.org/x/net/proxy # golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a ## explicit; go 1.17 golang.org/x/sys/internal/unsafeheader