feat: initial goget release — modern IPv6-first download utility

This commit is contained in:
2026-06-26 21:21:58 +02:00
commit 53db81e1f7
147 changed files with 45931 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
//go:build linux || freebsd
// +build linux freebsd
package auth
import (
"encoding/base64"
"net/http"
"net/url"
"strings"
)
// BasicAuth adds Basic Authentication header to the request
func BasicAuth(req *http.Request, username, password string) {
if username == "" {
return
}
auth := username + ":" + password
encoded := base64.StdEncoding.EncodeToString([]byte(auth))
req.Header.Set("Authorization", "Basic "+encoded)
}
// ParseURLAuth extracts username/password from URL (https://user:pass@host)
func ParseURLAuth(u *url.URL) (username, password string, hasAuth bool) {
if u == nil || u.User == nil {
return "", "", false
}
username = u.User.Username()
if username == "" {
return "", "", false
}
password, hasAuth = u.User.Password()
return username, password, true
}
// StripAuthFromURL removes credentials from URL for safe logging
func StripAuthFromURL(u *url.URL) *url.URL {
if u == nil {
return nil
}
// Clone URL to avoid modifying original
clean := *u
clean.User = nil
return &clean
}
// MaskPassword returns a masked version of the password for logging
func MaskPassword(password string) string {
if password == "" {
return ""
}
if len(password) <= 4 {
return "****"
}
return password[:2] + strings.Repeat("*", len(password)-2)
}
// GetAuthForURL gets credentials for a given URL (priority: CLI > URL > config)
func GetAuthForURL(cliUsername, cliPassword, configUsername, configPassword string, u *url.URL) (username, password string) {
// 1. CLI flags have highest priority
if cliUsername != "" {
return cliUsername, cliPassword
}
// 2. URL-embedded credentials
if urlUser, urlPass, ok := ParseURLAuth(u); ok {
return urlUser, urlPass
}
// 3. Config file credentials
if configUsername != "" {
return configUsername, configPassword
}
return "", ""
}
+206
View File
@@ -0,0 +1,206 @@
// Package auth handles HTTP authentication schemes (Basic, Digest, OAuth 2.0).
//go:build linux || freebsd
// +build linux freebsd
package auth
import (
"crypto/md5"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strings"
)
// DigestAuthHandler handles HTTP Digest Authentication
type DigestAuthHandler struct {
username string
password string
}
// NewDigestAuthHandler creates new handler
func NewDigestAuthHandler(username, password string) *DigestAuthHandler {
return &DigestAuthHandler{username, password}
}
// HandleResponse processes a 401 response and returns a modified request
func (h *DigestAuthHandler) HandleResponse(req *http.Request, resp *http.Response) (*http.Request, error) {
// Parse WWW-Authenticate header
authHeader := resp.Header.Get("WWW-Authenticate")
if !strings.HasPrefix(authHeader, "Digest ") {
return nil, fmt.Errorf("not a digest auth challenge")
}
// Parse digest parameters
params := parseDigestParams(authHeader[7:])
// Validate required parameters
requiredParams := []string{"realm", "nonce"}
for _, param := range requiredParams {
if _, exists := params[param]; !exists {
return nil, fmt.Errorf("missing required digest parameter: %s", param)
}
}
// Calculate response hash according to RFC 2617 / RFC 7616
qop := params.get("qop")
nonceCount := params.get("nc")
cnonce := params.get("cnonce")
opaque := params.get("opaque")
// HA1 = MD5(username:realm:password) or MD5(username:realm:password):nonce:cnonce
ha1 := h.calculateHA1(params["realm"], params["nonce"], cnonce, qop)
// HA2 = MD5(method:uri) or MD5(method:uri:response-body) for auth-int.
// RFC 7616 §3.4 (and RFC 2617 §3.2.2.3) require uri to be the
// request-uri — the path *and* the query string, exactly as it
// appears in the request line. Using req.URL.Path strips the
// query, which causes digest auth to fail for any URL that carries
// a token or other parameter in the query string.
ha2 := h.calculateHA2(req.Method, req.URL.RequestURI(), qop)
// Response calculation based on qop
var response string
if qop == "auth" || qop == "auth-int" {
response = md5Sum(ha1 + ":" + params["nonce"] + ":" + nonceCount + ":" + cnonce + ":" + qop + ":" + ha2)
} else {
// No qop (old RFC 2617 style)
response = md5Sum(ha1 + ":" + params["nonce"] + ":" + ha2)
}
// Build Authorization header — see note above about request-uri
// vs path.
auth := h.buildAuthHeader(params, req.URL.RequestURI(), response, nonceCount, cnonce, qop, opaque)
// Clone request and add header
newReq := req.Clone(req.Context())
newReq.Header.Set("Authorization", auth)
return newReq, nil
}
func (h *DigestAuthHandler) calculateHA1(realm, nonce, cnonce, qop string) string {
a1 := h.username + ":" + realm + ":" + h.password
ha1 := md5Sum(a1)
// If qop is specified, we need to include nonce and cnonce
if qop == "auth" || qop == "auth-int" {
ha1 = md5Sum(ha1 + ":" + nonce + ":" + cnonce)
}
return ha1
}
func (h *DigestAuthHandler) calculateHA2(method, uri, qop string) string {
a2 := method + ":" + uri
// For auth-int, we would need to include body hash, but that's complex
// For now, we just support auth and no-qop modes
return md5Sum(a2)
}
func (h *DigestAuthHandler) buildAuthHeader(params map[string]string, uri, response, nc, cnonce, qop, opaque string) string {
parts := []string{
fmt.Sprintf(`username="%s"`, h.username),
fmt.Sprintf(`realm="%s"`, params["realm"]),
fmt.Sprintf(`nonce="%s"`, params["nonce"]),
fmt.Sprintf(`uri="%s"`, uri),
fmt.Sprintf(`response="%s"`, response),
}
if qop != "" {
parts = append(parts,
fmt.Sprintf(`qop=%s`, qop),
fmt.Sprintf(`nc=%s`, nc),
fmt.Sprintf(`cnonce="%s"`, cnonce),
)
}
if opaque != "" {
parts = append(parts, fmt.Sprintf(`opaque="%s"`, opaque))
}
return "Digest " + strings.Join(parts, ", ")
}
// GenerateCnonce generates a cryptographically random client nonce
func GenerateCnonce() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// Fallback should never happen on a functioning system
// but we still produce a value rather than crash
for i := range b {
b[i] = byte(i)
}
}
return hex.EncodeToString(b)
}
// parseDigestParams parses the WWW-Authenticate header parameters
func parseDigestParams(header string) digestParams {
params := make(digestParams)
// Handle quoted and unquoted values
currentKey := ""
currentVal := ""
inQuotes := false
hasSeenEquals := false
for i := 0; i < len(header); i++ {
char := header[i]
switch {
case char == '=' && !inQuotes:
currentKey = strings.TrimSpace(currentKey)
currentVal = ""
hasSeenEquals = true
case char == '"' && (i == 0 || header[i-1] != '\\'):
inQuotes = !inQuotes
case char == ',' && !inQuotes:
if currentKey != "" {
params[currentKey] = strings.Trim(strings.TrimSpace(currentVal), `"`)
}
currentKey = ""
currentVal = ""
hasSeenEquals = false
case (char == ' ' || char == '\t') && !inQuotes && ((!hasSeenEquals && currentKey == "") || (hasSeenEquals && currentVal == "")):
// Skip leading whitespace before a key or after '='
default:
if !hasSeenEquals {
currentKey += string(char)
} else {
currentVal += string(char)
}
}
}
// Don't forget the last parameter
if currentKey != "" {
params[currentKey] = strings.Trim(strings.TrimSpace(currentVal), `"`)
}
return params
}
// digestParams is a map of digest authentication parameters
type digestParams map[string]string
func (p digestParams) get(key string) string {
if val, ok := p[key]; ok {
return val
}
return ""
}
func md5Sum(s string) string {
h := md5.Sum([]byte(s))
return hex.EncodeToString(h[:])
}
// SHA256 sum for future use (RFC 7616 supports SHA-256)
func sha256Sum(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}
+103
View File
@@ -0,0 +1,103 @@
//go:build linux || freebsd
// +build linux freebsd
package auth
import (
"bufio"
"os"
"path/filepath"
"strings"
)
// NetrcEntry holds credentials for a single machine entry in .netrc.
type NetrcEntry struct {
Machine string
Login string
Password string
}
// LoadNetrc reads ~/.netrc and returns entries keyed by machine name.
func LoadNetrc() (map[string]*NetrcEntry, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, nil
}
path := filepath.Join(home, ".netrc")
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer file.Close()
entries := make(map[string]*NetrcEntry)
var current *NetrcEntry
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip comments and empty lines
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
// "default" is also valid but we treat it as a machine
if len(fields) >= 2 && (fields[0] == "machine" || fields[0] == "default") {
if current != nil && current.Machine != "" {
entries[current.Machine] = current
}
name := fields[1]
if fields[0] == "default" {
name = "default"
}
current = &NetrcEntry{Machine: name}
// Rest of line may have login/password
parseNetrcLine(fields[2:], current)
} else if current != nil {
parseNetrcLine(fields, current)
}
}
if current != nil && current.Machine != "" {
entries[current.Machine] = current
}
return entries, scanner.Err()
}
func parseNetrcLine(fields []string, entry *NetrcEntry) {
for i := 0; i < len(fields); i++ {
switch fields[i] {
case "login":
if i+1 < len(fields) {
entry.Login = fields[i+1]
i++
}
case "password":
if i+1 < len(fields) {
entry.Password = fields[i+1]
i++
}
}
}
}
// LookupNetrc returns credentials from .netrc for a given hostname.
func LookupNetrc(host string) (login, password string, found bool) {
entries, err := LoadNetrc()
if err != nil || entries == nil {
return "", "", false
}
// Exact match first
if e, ok := entries[host]; ok {
return e.Login, e.Password, true
}
// Default entry
if e, ok := entries["default"]; ok {
return e.Login, e.Password, true
}
return "", "", false
}
+293
View File
@@ -0,0 +1,293 @@
//go:build linux || freebsd
// +build linux freebsd
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// OAuthConfig configuration for OAuth 2.0
type OAuthConfig struct {
ClientID string `json:"client_id,omitempty" toml:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty" toml:"client_secret,omitempty"`
TokenURL string `json:"token_url,omitempty" toml:"token_url,omitempty"`
AuthURL string `json:"auth_url,omitempty" toml:"auth_url,omitempty"`
RedirectURI string `json:"redirect_uri,omitempty" toml:"redirect_uri,omitempty"`
Scopes []string `json:"scopes,omitempty" toml:"scopes,omitempty"`
AccessToken string `json:"access_token,omitempty" toml:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty" toml:"refresh_token,omitempty"`
TokenType string `json:"token_type,omitempty" toml:"token_type,omitempty"`
Expiry time.Time `json:"expiry,omitempty" toml:"expiry,omitempty"`
GrantType string `json:"grant_type,omitempty" toml:"grant_type,omitempty"` // authorization_code, client_credentials, password, refresh_token
}
// TokenResponse response from a token endpoint
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
Scope string `json:"scope,omitempty"`
Error string `json:"error,omitempty"`
ErrorDesc string `json:"error_description,omitempty"`
}
// OAuthClient client for OAuth 2.0 authentication
type OAuthClient struct {
config *OAuthConfig
client *http.Client
}
// NewOAuthClient creates a new OAuth client
func NewOAuthClient(cfg *OAuthConfig) *OAuthClient {
if cfg.TokenType == "" {
cfg.TokenType = "Bearer"
}
return &OAuthClient{
config: cfg,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// IsTokenValid checks whether the token is valid
func (c *OAuthClient) IsTokenValid() bool {
if c.config.AccessToken == "" {
return false
}
if c.config.Expiry.IsZero() {
return true // No expiry, assume valid
}
// Token is valid if it expires in more than 30 seconds
return time.Now().Add(30 * time.Second).Before(c.config.Expiry)
}
// RequestToken gets new access token
func (c *OAuthClient) RequestToken(ctx context.Context) (*TokenResponse, error) {
if c.config.TokenURL != "" {
u, err := url.Parse(c.config.TokenURL)
if err != nil {
return nil, fmt.Errorf("invalid oauth token URL: %w", err)
}
if u.Scheme != "https" && u.Hostname() != "localhost" && u.Hostname() != "127.0.0.1" && u.Hostname() != "::1" {
return nil, fmt.Errorf("oauth token URL must use HTTPS for security")
}
}
formData := url.Values{}
grantType := c.config.GrantType
if grantType == "" {
grantType = "client_credentials"
}
formData.Set("grant_type", grantType)
switch grantType {
case "client_credentials":
formData.Set("client_id", c.config.ClientID)
formData.Set("client_secret", c.config.ClientSecret)
if len(c.config.Scopes) > 0 {
formData.Set("scope", strings.Join(c.config.Scopes, " "))
}
case "password":
formData.Set("client_id", c.config.ClientID)
formData.Set("client_secret", c.config.ClientSecret)
// Username and password would need to be passed separately
// This is handled by caller
case "authorization_code":
formData.Set("client_id", c.config.ClientID)
formData.Set("client_secret", c.config.ClientSecret)
// Code and redirect_uri would need to be passed separately
case "refresh_token":
formData.Set("client_id", c.config.ClientID)
formData.Set("client_secret", c.config.ClientSecret)
formData.Set("refresh_token", c.config.RefreshToken)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.config.TokenURL, strings.NewReader(formData.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("token request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read token response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("token endpoint returned %s", formatOAuthError(resp.StatusCode, body))
}
var tokenResp TokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
if tokenResp.Error != "" {
return nil, fmt.Errorf("oauth error: %s - %s", tokenResp.Error, tokenResp.ErrorDesc)
}
return &tokenResp, nil
}
// ApplyToken applies the token to the HTTP request
func (c *OAuthClient) ApplyToken(req *http.Request) {
if c.config.AccessToken == "" {
return
}
tokenType := c.config.TokenType
if tokenType == "" {
tokenType = "Bearer"
}
req.Header.Set("Authorization", fmt.Sprintf("%s %s", tokenType, c.config.AccessToken))
}
// RefreshToken refreshes access token using a refresh token
func (c *OAuthClient) RefreshToken(ctx context.Context) error {
if c.config.RefreshToken == "" {
return fmt.Errorf("no refresh token available")
}
oldGrantType := c.config.GrantType
c.config.GrantType = "refresh_token"
defer func() { c.config.GrantType = oldGrantType }()
tokenResp, err := c.RequestToken(ctx)
if err != nil {
return err
}
c.UpdateToken(tokenResp)
return nil
}
// UpdateToken updates internal token from response
func (c *OAuthClient) UpdateToken(tokenResp *TokenResponse) {
c.config.AccessToken = tokenResp.AccessToken
c.config.TokenType = tokenResp.TokenType
if tokenResp.RefreshToken != "" {
c.config.RefreshToken = tokenResp.RefreshToken
}
if tokenResp.ExpiresIn > 0 {
c.config.Expiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
}
}
// GetAuthorizationURL returns the authorization URL (for authorization_code flow)
func (c *OAuthClient) GetAuthorizationURL(state string) string {
if c.config.AuthURL == "" {
return ""
}
params := url.Values{}
params.Set("client_id", c.config.ClientID)
params.Set("redirect_uri", c.config.RedirectURI)
params.Set("response_type", "code")
params.Set("state", state)
if len(c.config.Scopes) > 0 {
params.Set("scope", strings.Join(c.config.Scopes, " "))
}
baseURL := c.config.AuthURL
if strings.Contains(baseURL, "?") {
return baseURL + "&" + params.Encode()
}
return baseURL + "?" + params.Encode()
}
// ExchangeCode exchanges an authorization code for an access token
func (c *OAuthClient) ExchangeCode(ctx context.Context, code string) (*TokenResponse, error) {
formData := url.Values{}
formData.Set("grant_type", "authorization_code")
formData.Set("code", code)
formData.Set("redirect_uri", c.config.RedirectURI)
formData.Set("client_id", c.config.ClientID)
formData.Set("client_secret", c.config.ClientSecret)
req, err := http.NewRequestWithContext(ctx, "POST", c.config.TokenURL, strings.NewReader(formData.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create code exchange request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("code exchange request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read code exchange response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("code exchange endpoint returned %s", formatOAuthError(resp.StatusCode, body))
}
var tokenResp TokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse code exchange response: %w", err)
}
if tokenResp.Error != "" {
return nil, fmt.Errorf("oauth error: %s - %s", tokenResp.Error, tokenResp.ErrorDesc)
}
return &tokenResp, nil
}
// formatOAuthError converts a non-2xx OAuth response into a human-readable
// error string while avoiding leaking sensitive data. The OAuth provider's
// raw response body is never returned verbatim: we first try to extract
// the structured `error` and `error_description` fields defined in RFC
// 6749 §5.2 (which is what well-behaved providers return), and only fall
// back to a 512-byte truncated snippet of the raw body if the body is
// not structured. This prevents upstream providers that echo back
// secrets, stack traces, or other sensitive payloads in their error
// bodies from leaking those payloads into our logs and error displays.
func formatOAuthError(statusCode int, body []byte) string {
if len(body) == 0 {
return fmt.Sprintf("status %d with empty body", statusCode)
}
var structured struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if err := json.Unmarshal(body, &structured); err == nil && structured.Error != "" {
if structured.ErrorDescription != "" {
return fmt.Sprintf("status %d: %s - %s", statusCode, structured.Error, structured.ErrorDescription)
}
return fmt.Sprintf("status %d: %s", statusCode, structured.Error)
}
const maxSnippet = 512
snippet := string(body)
if len(snippet) > maxSnippet {
snippet = snippet[:maxSnippet] + "... (truncated)"
}
return fmt.Sprintf("status %d: %s", statusCode, snippet)
}