package azure import ( "fmt" "net/http" "net/url" "os" "regexp" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" "github.com/seaweedfs/seaweedfs/weed/glog" ) // Azure storage account names are 3 to 24 lowercase letters and digits. The // name lands in the service URL, where a stray "/", "?" or "@" would move the // authority somewhere else and send an authenticated request to that host. var validAzureAccountName = regexp.MustCompile(`^[a-z0-9]{3,24}$`) // NewAzBlobClient builds a blob service client for accountName. // // An empty accountKey selects Entra ID instead of a shared key: azidentity // resolves a workload identity, a managed identity, or a developer login from // the environment, and access is granted through RBAC. Fleets that cannot // distribute and rotate storage account keys authenticate that way. clientID // pins a user-assigned identity when the environment offers more than one. // // endpoint is the blob service URL, for accounts outside the public cloud. An // empty endpoint derives the public one from accountName. // // A non-nil httpClient overrides the SDK transport; the volume server passes one // whose dialer pins the validated endpoint against DNS rebinding. func NewAzBlobClient(accountName, accountKey, clientID, endpoint string, httpClient *http.Client) (*azblob.Client, error) { if accountName == "" { return nil, fmt.Errorf("azure account name is required") } if !validAzureAccountName.MatchString(accountName) { return nil, fmt.Errorf("invalid azure account name %q: expecting 3 to 24 lowercase letters and digits", accountName) } serviceURL, err := azureServiceURL(accountName, endpoint) if err != nil { return nil, err } options := DefaultAzBlobClientOptions() if httpClient != nil { options.Transport = httpClient } if accountKey == "" { credential, err := newAzureTokenCredential(clientID) if err != nil { return nil, fmt.Errorf("failed to create Azure Entra ID credential for account %s: %w", accountName, err) } glog.V(1).Infof("azure %s: authenticating with Entra ID", accountName) client, err := azblob.NewClient(serviceURL, credential, options) if err != nil { return nil, fmt.Errorf("failed to create Azure client: %w", err) } return client, nil } credential, err := azblob.NewSharedKeyCredential(accountName, accountKey) if err != nil { return nil, fmt.Errorf("failed to create Azure credential with account name:%s: %w", accountName, err) } client, err := azblob.NewClientWithSharedKeyCredential(serviceURL, credential, options) if err != nil { return nil, fmt.Errorf("failed to create Azure client: %w", err) } return client, nil } // azureServiceURL locates the blob service. Sovereign clouds and private // endpoints do not live under blob.core.windows.net, so they name the service // URL outright rather than having it derived from the account. func azureServiceURL(accountName, endpoint string) (string, error) { if endpoint == "" { return fmt.Sprintf("https://%s.blob.core.windows.net/", accountName), nil } parsed, err := url.Parse(endpoint) if err != nil { return "", fmt.Errorf("invalid azure endpoint %q: %w", endpoint, err) } // plain http would carry the account key or the bearer token in the clear if parsed.Scheme != "https" || parsed.Hostname() == "" { return "", fmt.Errorf("invalid azure endpoint %q: expecting an https service url, such as https://%s.blob.core.usgovcloudapi.net/", endpoint, accountName) } return endpoint, nil } // newAzureTokenCredential resolves an Entra ID credential. Without a pinned // clientID the default chain discovers whatever the host offers. With one, the // federated token file projected by the Azure workload identity webhook tells // the two identity flavors apart. func newAzureTokenCredential(clientID string) (azcore.TokenCredential, error) { if clientID == "" { return azidentity.NewDefaultAzureCredential(nil) } if os.Getenv("AZURE_FEDERATED_TOKEN_FILE") != "" { return azidentity.NewWorkloadIdentityCredential(&azidentity.WorkloadIdentityCredentialOptions{ ClientID: clientID, }) } return azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{ ID: azidentity.ClientID(clientID), }) }