mirror of https://github.com/coder/coder.git
feat: Add Azure instance identitity authentication (#1064)
This enables zero-trust authentication for Azure instances. Now
we support the three major clouds: AWS, Azure, and GCP 😎.
This commit is contained in:
parent
118a47e4e1
commit
c8246e3e8a
|
@ -55,6 +55,7 @@
|
|||
"trimprefix",
|
||||
"unconvert",
|
||||
"Untar",
|
||||
"VMID",
|
||||
"webrtc",
|
||||
"xerrors",
|
||||
"yamux"
|
||||
|
|
14
cli/agent.go
14
cli/agent.go
|
@ -76,7 +76,19 @@ func workspaceAgent() *cobra.Command {
|
|||
return client.AuthWorkspaceAWSInstanceIdentity(ctx)
|
||||
}
|
||||
case "azure-instance-identity":
|
||||
return xerrors.Errorf("not implemented")
|
||||
// This is *only* done for testing to mock client authentication.
|
||||
// This will never be set in a production scenario.
|
||||
var azureClient *http.Client
|
||||
azureClientRaw := cmd.Context().Value("azure-client")
|
||||
if azureClientRaw != nil {
|
||||
azureClient, _ = azureClientRaw.(*http.Client)
|
||||
if azureClient != nil {
|
||||
client.HTTPClient = azureClient
|
||||
}
|
||||
}
|
||||
exchangeToken = func(ctx context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error) {
|
||||
return client.AuthWorkspaceAzureInstanceIdentity(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
if exchangeToken != nil {
|
||||
|
|
|
@ -15,12 +15,66 @@ import (
|
|||
|
||||
func TestWorkspaceAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Azure", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
instanceID := "instanceidentifier"
|
||||
certificates, metadataClient := coderdtest.NewAzureInstanceIdentity(t, instanceID)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
AzureCertificates: certificates,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
coderdtest.NewProvisionerDaemon(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "somename",
|
||||
Type: "someinstance",
|
||||
Agents: []*proto.Agent{{
|
||||
Auth: &proto.Agent_InstanceId{
|
||||
InstanceId: instanceID,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
cmd, _ := clitest.New(t, "agent", "--auth", "azure-instance-identity", "--url", client.URL.String())
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
go func() {
|
||||
// A linting error occurs for weakly typing the context value here,
|
||||
// but it seems reasonable for a one-off test.
|
||||
// nolint
|
||||
ctx = context.WithValue(ctx, "azure-client", metadataClient)
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer dialer.Close()
|
||||
_, err = dialer.Ping()
|
||||
require.NoError(t, err)
|
||||
cancelFunc()
|
||||
})
|
||||
|
||||
t.Run("AWS", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
instanceID := "instanceidentifier"
|
||||
certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
AWSInstanceIdentity: certificates,
|
||||
AWSCertificates: certificates,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
coderdtest.NewProvisionerDaemon(t, client)
|
||||
|
@ -74,7 +128,7 @@ func TestWorkspaceAgent(t *testing.T) {
|
|||
instanceID := "instanceidentifier"
|
||||
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
GoogleInstanceIdentity: validator,
|
||||
GoogleTokenValidator: validator,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
coderdtest.NewProvisionerDaemon(t, client)
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
package azureidentity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"go.mozilla.org/pkcs7"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// allowedSigners matches valid common names listed here:
|
||||
// https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service?tabs=linux#tabgroup_14
|
||||
var allowedSigners = regexp.MustCompile(`^(.*\.)?metadata\.(azure\.(com|us|cn)|microsoftazure\.de)$`)
|
||||
|
||||
type metadata struct {
|
||||
VMID string `json:"vmId"`
|
||||
}
|
||||
|
||||
// Validate ensures the signature was signed by an Azure certificate.
|
||||
// It returns the associated VM ID if successful.
|
||||
func Validate(ctx context.Context, signature string, options x509.VerifyOptions) (string, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(signature)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("decode base64: %w", err)
|
||||
}
|
||||
pkcs7Data, err := pkcs7.Parse(data)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("parse pkcs7: %w", err)
|
||||
}
|
||||
signer := pkcs7Data.GetOnlySigner()
|
||||
if signer == nil {
|
||||
return "", xerrors.New("no signers for signature")
|
||||
}
|
||||
if !allowedSigners.MatchString(signer.Subject.CommonName) {
|
||||
return "", xerrors.Errorf("unmatched common name of signer: %q", signer.Subject.CommonName)
|
||||
}
|
||||
if options.Intermediates == nil {
|
||||
options.Intermediates = x509.NewCertPool()
|
||||
for _, certURL := range signer.IssuingCertificateURL {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", certURL, nil)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("new request %q: %w", certURL, err)
|
||||
}
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("perform request %q: %w", certURL, err)
|
||||
}
|
||||
data, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("read body %q: %w", certURL, err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(data)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("parse certificate %q: %w", certURL, err)
|
||||
}
|
||||
options.Intermediates.AddCert(cert)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = signer.Verify(options)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("verify certificates: %w", err)
|
||||
}
|
||||
|
||||
var metadata metadata
|
||||
err = json.Unmarshal(pkcs7Data.Content, &metadata)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("unmarshal metadata: %w", err)
|
||||
}
|
||||
return metadata.VMID, nil
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package azureidentity_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/coderd/azureidentity"
|
||||
)
|
||||
|
||||
const (
|
||||
signature = `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`
|
||||
)
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
vm, err := azureidentity.Validate(context.Background(), signature, x509.VerifyOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "bd8e7443-24a0-41f3-b949-8baf4fd1c573", vm)
|
||||
}
|
|
@ -2,6 +2,7 @@ package coderd
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
@ -35,6 +36,7 @@ type Options struct {
|
|||
|
||||
AgentConnectionUpdateFrequency time.Duration
|
||||
AWSCertificates awsidentity.Certificates
|
||||
AzureCertificates x509.VerifyOptions
|
||||
GoogleTokenValidator *idtoken.Validator
|
||||
ICEServers []webrtc.ICEServer
|
||||
SecureAuthCookie bool
|
||||
|
@ -172,6 +174,7 @@ func New(options *Options) (http.Handler, func()) {
|
|||
})
|
||||
})
|
||||
r.Route("/workspaceagents", func(r chi.Router) {
|
||||
r.Post("/azure-instance-identity", api.postWorkspaceAuthAzureInstanceIdentity)
|
||||
r.Post("/aws-instance-identity", api.postWorkspaceAuthAWSInstanceIdentity)
|
||||
r.Post("/google-instance-identity", api.postWorkspaceAuthGoogleInstanceIdentity)
|
||||
r.Route("/me", func(r chi.Router) {
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
|
@ -24,6 +25,7 @@ import (
|
|||
"time"
|
||||
|
||||
"cloud.google.com/go/compute/metadata"
|
||||
"github.com/fullsailor/pkcs7"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/moby/moby/pkg/namesgenerator"
|
||||
|
@ -49,9 +51,10 @@ import (
|
|||
)
|
||||
|
||||
type Options struct {
|
||||
AWSInstanceIdentity awsidentity.Certificates
|
||||
GoogleInstanceIdentity *idtoken.Validator
|
||||
SSHKeygenAlgorithm gitsshkey.Algorithm
|
||||
AWSCertificates awsidentity.Certificates
|
||||
AzureCertificates x509.VerifyOptions
|
||||
GoogleTokenValidator *idtoken.Validator
|
||||
SSHKeygenAlgorithm gitsshkey.Algorithm
|
||||
}
|
||||
|
||||
// New constructs an in-memory coderd instance and returns
|
||||
|
@ -60,11 +63,11 @@ func New(t *testing.T, options *Options) *codersdk.Client {
|
|||
if options == nil {
|
||||
options = &Options{}
|
||||
}
|
||||
if options.GoogleInstanceIdentity == nil {
|
||||
if options.GoogleTokenValidator == nil {
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancelFunc)
|
||||
var err error
|
||||
options.GoogleInstanceIdentity, err = idtoken.NewValidator(ctx, option.WithoutAuthentication())
|
||||
options.GoogleTokenValidator, err = idtoken.NewValidator(ctx, option.WithoutAuthentication())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
|
@ -117,8 +120,9 @@ func New(t *testing.T, options *Options) *codersdk.Client {
|
|||
Database: db,
|
||||
Pubsub: pubsub,
|
||||
|
||||
AWSCertificates: options.AWSInstanceIdentity,
|
||||
GoogleTokenValidator: options.GoogleInstanceIdentity,
|
||||
AWSCertificates: options.AWSCertificates,
|
||||
AzureCertificates: options.AzureCertificates,
|
||||
GoogleTokenValidator: options.GoogleTokenValidator,
|
||||
SSHKeygenAlgorithm: options.SSHKeygenAlgorithm,
|
||||
TURNServer: turnServer,
|
||||
})
|
||||
|
@ -414,6 +418,65 @@ func NewAWSInstanceIdentity(t *testing.T, instanceID string) (awsidentity.Certif
|
|||
}
|
||||
}
|
||||
|
||||
// NewAzureInstanceIdentity returns a metadata client and ID token validator for faking
|
||||
// instance authentication for Azure.
|
||||
func NewAzureInstanceIdentity(t *testing.T, instanceID string) (x509.VerifyOptions, *http.Client) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
rawCertificate, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2022),
|
||||
NotAfter: time.Now().AddDate(1, 0, 0),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "metadata.azure.com",
|
||||
},
|
||||
}, &x509.Certificate{}, &privateKey.PublicKey, privateKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
certificate, err := x509.ParseCertificate(rawCertificate)
|
||||
require.NoError(t, err)
|
||||
|
||||
signed, err := pkcs7.NewSignedData([]byte(`{"vmId":"` + instanceID + `"}`))
|
||||
require.NoError(t, err)
|
||||
err = signed.AddSigner(certificate, privateKey, pkcs7.SignerInfoConfig{})
|
||||
require.NoError(t, err)
|
||||
signatureRaw, err := signed.Finish()
|
||||
require.NoError(t, err)
|
||||
signature := make([]byte, base64.StdEncoding.EncodedLen(len(signatureRaw)))
|
||||
base64.StdEncoding.Encode(signature, signatureRaw)
|
||||
|
||||
payload, err := json.Marshal(codersdk.AzureInstanceIdentityToken{
|
||||
Signature: string(signature),
|
||||
Encoding: "pkcs7",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
certPool := x509.NewCertPool()
|
||||
certPool.AddCert(certificate)
|
||||
|
||||
return x509.VerifyOptions{
|
||||
Intermediates: certPool,
|
||||
Roots: certPool,
|
||||
}, &http.Client{
|
||||
Transport: roundTripper(func(r *http.Request) (*http.Response, error) {
|
||||
// Only handle metadata server requests.
|
||||
if r.URL.Host != "169.254.169.254" {
|
||||
return http.DefaultTransport.RoundTrip(r)
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/metadata/attested/document":
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(payload)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
default:
|
||||
panic("unhandled route: " + r.URL.Path)
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func randomUsername() string {
|
||||
return strings.ReplaceAll(namesgenerator.GetRandomName(0), "_", "-")
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/coder/coder/coderd/awsidentity"
|
||||
"github.com/coder/coder/coderd/azureidentity"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/codersdk"
|
||||
|
@ -15,6 +16,23 @@ import (
|
|||
"github.com/mitchellh/mapstructure"
|
||||
)
|
||||
|
||||
// Azure supports instance identity verification:
|
||||
// https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service?tabs=linux#tabgroup_14
|
||||
func (api *api) postWorkspaceAuthAzureInstanceIdentity(rw http.ResponseWriter, r *http.Request) {
|
||||
var req codersdk.AzureInstanceIdentityToken
|
||||
if !httpapi.Read(rw, r, &req) {
|
||||
return
|
||||
}
|
||||
instanceID, err := azureidentity.Validate(r.Context(), req.Signature, api.AzureCertificates)
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
|
||||
Message: fmt.Sprintf("validate: %s", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
api.handleAuthInstanceID(rw, r, instanceID)
|
||||
}
|
||||
|
||||
// AWS supports instance identity verification:
|
||||
// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-identity-documents.html
|
||||
// Using this, we can exchange a signed instance payload for an agent token.
|
||||
|
|
|
@ -13,6 +13,43 @@ import (
|
|||
"github.com/coder/coder/provisionersdk/proto"
|
||||
)
|
||||
|
||||
func TestPostWorkspaceAuthAzureInstanceIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
instanceID := "instanceidentifier"
|
||||
certificates, metadataClient := coderdtest.NewAzureInstanceIdentity(t, instanceID)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
AzureCertificates: certificates,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
coderdtest.NewProvisionerDaemon(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "somename",
|
||||
Type: "someinstance",
|
||||
Agents: []*proto.Agent{{
|
||||
Auth: &proto.Agent_InstanceId{
|
||||
InstanceId: instanceID,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
client.HTTPClient = metadataClient
|
||||
_, err := client.AuthWorkspaceAzureInstanceIdentity(context.Background())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPostWorkspaceAuthAWSInstanceIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
|
@ -20,7 +57,7 @@ func TestPostWorkspaceAuthAWSInstanceIdentity(t *testing.T) {
|
|||
instanceID := "instanceidentifier"
|
||||
certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
AWSInstanceIdentity: certificates,
|
||||
AWSCertificates: certificates,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
coderdtest.NewProvisionerDaemon(t, client)
|
||||
|
@ -60,7 +97,7 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) {
|
|||
instanceID := "instanceidentifier"
|
||||
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, true)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
GoogleInstanceIdentity: validator,
|
||||
GoogleTokenValidator: validator,
|
||||
})
|
||||
_, err := client.AuthWorkspaceGoogleInstanceIdentity(context.Background(), "", metadata)
|
||||
var apiErr *codersdk.Error
|
||||
|
@ -73,7 +110,7 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) {
|
|||
instanceID := "instanceidentifier"
|
||||
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
GoogleInstanceIdentity: validator,
|
||||
GoogleTokenValidator: validator,
|
||||
})
|
||||
_, err := client.AuthWorkspaceGoogleInstanceIdentity(context.Background(), "", metadata)
|
||||
var apiErr *codersdk.Error
|
||||
|
@ -86,7 +123,7 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) {
|
|||
instanceID := "instanceidentifier"
|
||||
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
GoogleInstanceIdentity: validator,
|
||||
GoogleTokenValidator: validator,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
coderdtest.NewProvisionerDaemon(t, client)
|
||||
|
|
|
@ -37,6 +37,11 @@ type AWSInstanceIdentityToken struct {
|
|||
Document string `json:"document" validate:"required"`
|
||||
}
|
||||
|
||||
type AzureInstanceIdentityToken struct {
|
||||
Signature string `json:"signature" validate:"required"`
|
||||
Encoding string `json:"encoding" validate:"required"`
|
||||
}
|
||||
|
||||
// WorkspaceAgentAuthenticateResponse is returned when an instance ID
|
||||
// has been exchanged for a session token.
|
||||
type WorkspaceAgentAuthenticateResponse struct {
|
||||
|
@ -139,6 +144,38 @@ func (c *Client) AuthWorkspaceAWSInstanceIdentity(ctx context.Context) (Workspac
|
|||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// AuthWorkspaceAzureInstanceIdentity uses the Azure Instance Metadata Service to
|
||||
// fetch a signed payload, and exchange it for a session token for a workspace agent.
|
||||
func (c *Client) AuthWorkspaceAzureInstanceIdentity(ctx context.Context) (WorkspaceAgentAuthenticateResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://169.254.169.254/metadata/attested/document?api-version=2020-09-01", nil)
|
||||
if err != nil {
|
||||
return WorkspaceAgentAuthenticateResponse{}, nil
|
||||
}
|
||||
req.Header.Set("Metadata", "true")
|
||||
res, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return WorkspaceAgentAuthenticateResponse{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var token AzureInstanceIdentityToken
|
||||
err = json.NewDecoder(res.Body).Decode(&token)
|
||||
if err != nil {
|
||||
return WorkspaceAgentAuthenticateResponse{}, err
|
||||
}
|
||||
|
||||
res, err = c.request(ctx, http.MethodPost, "/api/v2/workspaceagents/azure-instance-identity", token)
|
||||
if err != nil {
|
||||
return WorkspaceAgentAuthenticateResponse{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return WorkspaceAgentAuthenticateResponse{}, readBodyAsError(res)
|
||||
}
|
||||
var resp WorkspaceAgentAuthenticateResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// ListenWorkspaceAgent connects as a workspace agent identifying with the session token.
|
||||
// On each inbound connection request, connection info is fetched.
|
||||
func (c *Client) ListenWorkspaceAgent(ctx context.Context, logger slog.Logger) (*peerbroker.Listener, error) {
|
||||
|
|
2
go.mod
2
go.mod
|
@ -52,6 +52,7 @@ require (
|
|||
github.com/fatedier/frp v0.36.2-0.20220414032436-21240ed96251
|
||||
github.com/fatedier/golib v0.1.1-0.20220321042308-c306138b83ac
|
||||
github.com/fatih/color v1.13.0
|
||||
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa
|
||||
github.com/gliderlabs/ssh v0.3.3
|
||||
github.com/go-chi/chi/v5 v5.0.7
|
||||
github.com/go-chi/httprate v0.5.3
|
||||
|
@ -90,6 +91,7 @@ require (
|
|||
github.com/stretchr/testify v1.7.1
|
||||
github.com/tabbed/pqtype v0.1.1
|
||||
github.com/unrolled/secure v1.10.0
|
||||
go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1
|
||||
go.uber.org/atomic v1.9.0
|
||||
go.uber.org/goleak v1.1.12
|
||||
golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd
|
||||
|
|
2
go.sum
2
go.sum
|
@ -558,6 +558,7 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
|
|||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
|
||||
github.com/fsouza/fake-gcs-server v1.17.0/go.mod h1:D1rTE4YCyHFNa99oyJJ5HyclvN/0uQR+pM/VdlL83bw=
|
||||
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa h1:RDBNVkRviHZtvDvId8XSGPu3rmpmSe+wKRcEWNgsfWU=
|
||||
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
|
||||
github.com/gabriel-vasile/mimetype v1.3.1/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
|
||||
github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
|
||||
|
@ -1698,6 +1699,7 @@ go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
|||
go.etcd.io/etcd v0.5.0-alpha.5.0.20200910180754-dd1b699fc489/go.mod h1:yVHk9ub3CSBatqGNg7GRmsnfLWtoW60w4eDYfh7vHDg=
|
||||
go.mongodb.org/mongo-driver v1.5.1/go.mod h1:gRXCHX4Jo7J0IJ1oDQyUxF7jfy19UfxniMS4xxMmUqw=
|
||||
go.mongodb.org/mongo-driver v1.7.0/go.mod h1:Q4oFMbo1+MSNqICAdYMlC/zSTrwCogR4R8NzkI+yfU8=
|
||||
go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1 h1:A/5uWzF44DlIgdm/PQFwfMkW0JX+cIcQi/SwLAmZP5M=
|
||||
go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.31.0/go.mod h1:PFmBsWbldL1kiWZk9+0LBZz2brhByaGsvp6pRICMlPE=
|
||||
go.opentelemetry.io/otel v0.11.0/go.mod h1:G8UCk+KooF2HLkgo8RHX9epABH/aRGYET7gQOqBVdB0=
|
||||
|
|
|
@ -136,7 +136,13 @@ export interface AWSInstanceIdentityToken {
|
|||
readonly document: string
|
||||
}
|
||||
|
||||
// From codersdk/workspaceagents.go:42:6.
|
||||
// From codersdk/workspaceagents.go:40:6.
|
||||
export interface AzureInstanceIdentityToken {
|
||||
readonly signature: string
|
||||
readonly encoding: string
|
||||
}
|
||||
|
||||
// From codersdk/workspaceagents.go:47:6.
|
||||
export interface WorkspaceAgentAuthenticateResponse {
|
||||
readonly session_token: string
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue