Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/BobuSumisu/aho-corasick v1.0.3
github.com/Masterminds/sprig/v3 v3.3.0
github.com/awnumar/memguard v0.23.0
github.com/aws/aws-sdk-go-v2 v1.27.2
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
github.com/charmbracelet/lipgloss v0.9.1
github.com/creack/pty v1.1.21
Expand Down Expand Up @@ -63,7 +64,6 @@ require (
github.com/alessio/shellescape v1.4.1 // indirect
github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect
github.com/awnumar/memcall v0.4.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.27.2 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.18 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.18 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.5 // indirect
Expand Down
21 changes: 21 additions & 0 deletions packages/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const (
operationCallRegisterGateway = "CallRegisterGateway"
operationCallConnectGateway = "CallConnectGateway"
operationCallEnrollGateway = "CallEnrollGateway"
operationCallAwsAuthLoginGateway = "CallAwsAuthLoginGateway"
operationCallPAMAccess = "CallPAMAccess"
operationCallPAMAccessApprovalRequest = "CallPAMAccessApprovalRequest"
operationCallPAMSessionCredentials = "CallPAMSessionCredentials"
Expand Down Expand Up @@ -957,6 +958,26 @@ func CallEnrollGateway(httpClient *resty.Client, request EnrollGatewayRequest) (
return resBody, nil
}

func CallAwsAuthLoginGateway(httpClient *resty.Client, request AwsAuthLoginGatewayRequest) (AwsAuthLoginGatewayResponse, error) {
var resBody AwsAuthLoginGatewayResponse
response, err := httpClient.
R().
SetResult(&resBody).
SetHeader("User-Agent", USER_AGENT).
SetBody(request).
Post(fmt.Sprintf("%v/v3/gateways/login", config.INFISICAL_URL))

if err != nil {
return AwsAuthLoginGatewayResponse{}, NewGenericRequestError(operationCallAwsAuthLoginGateway, err)
}

if response.IsError() {
return AwsAuthLoginGatewayResponse{}, NewAPIErrorWithResponse(operationCallAwsAuthLoginGateway, response, nil)
}

return resBody, nil
}

func CallPAMAccess(httpClient *resty.Client, request PAMAccessRequest) (PAMAccessResponse, error) {
var pamAccessResponse PAMAccessResponse
response, err := httpClient.
Expand Down
13 changes: 13 additions & 0 deletions packages/api/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,19 @@ type EnrollGatewayResponse struct {
GatewayID string `json:"gatewayId"`
}

type AwsAuthLoginGatewayRequest struct {
Method string `json:"method"`
GatewayID string `json:"gatewayId"`
HTTPRequestMethod string `json:"iamHttpRequestMethod"`
IamRequestBody string `json:"iamRequestBody"`
IamRequestHeaders string `json:"iamRequestHeaders"`
}

type AwsAuthLoginGatewayResponse struct {
AccessToken string `json:"accessToken"`
TokenType string `json:"tokenType"`
}

type RegisterGatewayResponse struct {
GatewayID string `json:"gatewayId"`
RelayHost string `json:"relayHost"`
Expand Down
89 changes: 83 additions & 6 deletions packages/cmd/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,10 @@ var gatewayStartCmd = &cobra.Command{
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
enrollMethod, _ := cmd.Flags().GetString("enroll-method")
// Fall back to env var for systemd-managed runs where flags aren't set.
if enrollMethod == "" {
enrollMethod = os.Getenv("INFISICAL_GATEWAY_ENROLL_METHOD")
}
var alreadyEnrolled bool
var enrolledAccessToken string // set during fresh enrollment, used directly to avoid env var interference

Expand All @@ -226,6 +230,62 @@ var gatewayStartCmd = &cobra.Command{
util.HandleError(errors.New("gateway name is required (provide as positional argument)"))
}

// --- AWS Auth path ---
if enrollMethod == gatewayv2.EnrollMethodAws {
gatewayID, _ := cmd.Flags().GetString("gateway-id")
if gatewayID == "" {
gatewayID = os.Getenv(gatewayv2.INFISICAL_GATEWAY_ID_KEY)
}
if gatewayID == "" {
stored, _ := gatewayv2.LoadStoredGatewayID(gatewayName)
gatewayID = stored
}
if gatewayID == "" {
util.HandleError(errors.New("--gateway-id is required when --enroll-method=aws"))
}

domain, _ := cmd.Flags().GetString("domain")
if domain != "" {
config.INFISICAL_URL = util.AppendAPIEndpoint(domain)
} else if storedDomain, _ := gatewayv2.LoadStoredDomain(gatewayName); storedDomain != "" {
config.INFISICAL_URL = util.AppendAPIEndpoint(storedDomain)
}

httpClient, err := util.GetRestyClientWithCustomHeaders()
if err != nil {
util.HandleError(err, "unable to create HTTP client")
}

log.Info().Msg("Authenticating gateway via AWS Auth (STS GetCallerIdentity)...")
accessTokenStr, err := gatewayv2.LoginGatewayWithAws(cmd.Context(), httpClient, gatewayID)
if err != nil {
util.HandleError(err, "AWS Auth login failed")
}

enrolledAccessToken = accessTokenStr
alreadyEnrolled = true // skip the stored-token branch below; we have a fresh one in hand

// Don't persist the JWT — AWS-auth re-mints a fresh one on every start, so any
// on-disk copy would be stale and is never read back. enrolledAccessToken (in
// memory) is what downstream code uses.
if err := gatewayv2.SaveGatewayID(gatewayName, gatewayID); err != nil {
util.HandleError(err, "failed to save gateway id to config")
}

effectiveDomain := domain
if effectiveDomain == "" {
effectiveDomain = config.INFISICAL_URL
}
if effectiveDomain != "" {
if err := gatewayv2.SaveDomain(gatewayName, effectiveDomain); err != nil {
util.HandleError(err, "failed to save domain to config")
}
}

log.Info().Msgf("Gateway authenticated via AWS Auth. State saved to %s", gatewayv2.GetConfPathDisplay(gatewayName))
log.Info().Msg("Starting gateway...")
}

// --- Enrollment token path ---
if enrollMethod == gatewayv2.EnrollMethodToken {
enrollToken, err := cmd.Flags().GetString("token")
Expand Down Expand Up @@ -289,7 +349,8 @@ var gatewayStartCmd = &cobra.Command{
// --domain flag takes priority; fall back to domain saved at enrollment time.
// For enrollment flow with alreadyEnrolled, domain was set during original enrollment
// and needs to be loaded from config.
if enrollMethod != gatewayv2.EnrollMethodToken || alreadyEnrolled {
isResourceAuth := enrollMethod == gatewayv2.EnrollMethodToken || enrollMethod == gatewayv2.EnrollMethodAws
if !isResourceAuth || alreadyEnrolled {
if flagDomain, _ := cmd.Flags().GetString("domain"); flagDomain != "" {
config.INFISICAL_URL = util.AppendAPIEndpoint(flagDomain)
} else if storedDomain, _ := gatewayv2.LoadStoredDomain(gatewayName); storedDomain != "" {
Expand All @@ -300,8 +361,8 @@ var gatewayStartCmd = &cobra.Command{
// Only use the stored token when no explicit identity credentials are provided.
// If --token or --auth-method is set, the user wants the identity-based path.
var runningWithStoredToken bool
if enrollMethod == gatewayv2.EnrollMethodToken {
// Just enrolled above; use the freshly saved token.
if isResourceAuth {
// Just enrolled / logged-in above; use the freshly saved token.
runningWithStoredToken = true
} else {
hasExplicitCreds := cmd.Flags().Changed("token") || cmd.Flags().Changed("auth-method")
Expand Down Expand Up @@ -584,6 +645,20 @@ var gatewaySystemdInstallCmd = &cobra.Command{
if installErr := gatewayv2.InstallEnrolledGatewaySystemdService(enrollResp.AccessToken, domain, gatewayName, relayName, serviceLogFile); installErr != nil {
util.HandleError(installErr, "Unable to install systemd service")
}
} else if enrollMethod == gatewayv2.EnrollMethodAws {
// --- AWS Auth path ---
// Don't perform the AWS login at install time — the gateway does it on each service
// start (so a fresh JWT is minted every restart, matching server-side tokenVersion).
gatewayID, _ := cmd.Flags().GetString("gateway-id")
if gatewayID == "" {
util.HandleError(errors.New("--gateway-id is required when --enroll-method=aws"))
}

relayName, _ := util.GetRelayName(cmd, false, "")

if installErr := gatewayv2.InstallAwsAuthGatewaySystemdService(gatewayID, domain, gatewayName, relayName, serviceLogFile); installErr != nil {
util.HandleError(installErr, "Unable to install systemd service")
}
} else {
// --- Machine identity token path ---
token, tokenErr := util.GetInfisicalToken(cmd)
Expand Down Expand Up @@ -681,8 +756,9 @@ func init() {
gatewayStartCmd.Flags().String("name", "", "name of the gateway (deprecated, use positional argument instead)")
_ = gatewayStartCmd.Flags().MarkDeprecated("name", "use positional argument instead: infisical gateway start <name>")
gatewayStartCmd.Flags().String("token", "", "enrollment token or access token for authenticating with Infisical")
gatewayStartCmd.Flags().String("enroll-method", "", "enrollment method [token]. when set to 'token', uses --token as a one-time enrollment token")
gatewayStartCmd.Flags().String("domain", "", "domain of your self-hosted Infisical instance (used with --enroll-method=token)")
gatewayStartCmd.Flags().String("enroll-method", "", "gateway auth method [token, aws]. when set to 'token', uses --token as a one-time enrollment token. when set to 'aws', authenticates via signed STS GetCallerIdentity using --gateway-id")
gatewayStartCmd.Flags().String("gateway-id", "", "gateway id (required when --enroll-method=aws)")
gatewayStartCmd.Flags().String("domain", "", "domain of your self-hosted Infisical instance (used with --enroll-method=token or --enroll-method=aws)")
gatewayStartCmd.Flags().String("auth-method", "", "login method [universal-auth, kubernetes, azure, gcp-id-token, gcp-iam, aws-iam, oidc-auth]. if not provided, you must set the token flag")
gatewayStartCmd.Flags().String("organization-slug", "", "When set, this will scope the login session to the specified sub-organization the machine identity has access to. If left empty, the session defaults to the organization where the machine identity was created in.")
gatewayStartCmd.Flags().String("client-id", "", "client id for universal auth")
Expand All @@ -699,7 +775,8 @@ func init() {

// Systemd install command flags (v2)
gatewaySystemdInstallCmd.Flags().String("token", "", "enrollment token or access token for authenticating with Infisical")
gatewaySystemdInstallCmd.Flags().String("enroll-method", "", "enrollment method [token]. when set to 'token', uses --token as a one-time enrollment token")
gatewaySystemdInstallCmd.Flags().String("enroll-method", "", "gateway auth method [token, aws]. when set to 'token', uses --token as a one-time enrollment token. when set to 'aws', the gateway authenticates via AWS STS on each service start (requires --gateway-id)")
gatewaySystemdInstallCmd.Flags().String("gateway-id", "", "gateway id (required when --enroll-method=aws)")
gatewaySystemdInstallCmd.Flags().String("domain", "", "Domain of your self-hosted Infisical instance")
gatewaySystemdInstallCmd.Flags().String("name", "", "The name of the gateway (deprecated, use positional argument instead)")
_ = gatewaySystemdInstallCmd.Flags().MarkDeprecated("name", "use positional argument instead: infisical gateway systemd install <name>")
Expand Down
105 changes: 105 additions & 0 deletions packages/gateway-v2/aws_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package gatewayv2

import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"

"github.com/Infisical/infisical-merge/packages/api"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/go-resty/resty/v2"
infisicalSdkUtil "github.com/infisical/go-sdk/packages/util"
)

const (
INFISICAL_GATEWAY_ID_KEY = "INFISICAL_GATEWAY_ID"
)

// LoginGatewayWithAws builds a SigV4-signed sts:GetCallerIdentity request using the local AWS
// credentials chain (instance metadata, env vars, profile, etc.) and exchanges it for a
// GATEWAY_ACCESS_TOKEN. The credentials themselves never leave the host — only the signature
// over a single STS API call.
//
// ctx is threaded through SigV4 signing so a shutdown signal during startup cancels the
// outbound STS verification cleanly instead of hanging the process.
func LoginGatewayWithAws(ctx context.Context, httpClient *resty.Client, gatewayID string) (string, error) {
if gatewayID == "" {
return "", errors.New("--gateway-id is required when --enroll-method=aws")
}

awsCredentials, awsRegion, err := infisicalSdkUtil.RetrieveAwsCredentials()
if err != nil {
return "", fmt.Errorf("unable to retrieve AWS credentials (no instance role / no AWS env vars / no profile): %w", err)
}

iamRequestURL := fmt.Sprintf("https://sts.%s.amazonaws.com/", awsRegion)
iamRequestBody := "Action=GetCallerIdentity&Version=2011-06-15"

req, err := http.NewRequest(http.MethodPost, iamRequestURL, strings.NewReader(iamRequestBody))
if err != nil {
return "", fmt.Errorf("error building STS request: %w", err)
}

// Set every header that needs to be on the wire BEFORE signing — the SDK's signer
// reads req.Header to compute SignedHeaders, and any modification afterwards would
// invalidate the signature when the backend forwards the request to STS.
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")

hash := sha256.New()
hash.Write([]byte(iamRequestBody))
payloadHash := fmt.Sprintf("%x", hash.Sum(nil))

signingTime := time.Now()
signer := v4.NewSigner()
if err := signer.SignHTTP(ctx, awsCredentials, req, payloadHash, "sts", awsRegion, signingTime); err != nil {
return "", fmt.Errorf("error signing STS request: %w", err)
}

// Forward every signed header verbatim. Content-Length is computed by the receiving
// HTTP stack from the body, so we don't include it (and the signer doesn't sign it).
// Host isn't in req.Header in Go's http package — it's on req.URL.Host — so we add it
// explicitly with the same value the signer used internally.
headers := make(map[string]string)
for name, values := range req.Header {
if strings.ToLower(name) == "content-length" {
continue
}
headers[name] = values[0]
}
headers["Host"] = fmt.Sprintf("sts.%s.amazonaws.com", awsRegion)

headersJSON, err := json.Marshal(headers)
if err != nil {
return "", fmt.Errorf("error marshalling headers: %w", err)
}

resp, err := api.CallAwsAuthLoginGateway(httpClient, api.AwsAuthLoginGatewayRequest{
Method: EnrollMethodAws,
GatewayID: gatewayID,
HTTPRequestMethod: req.Method,
IamRequestBody: base64.StdEncoding.EncodeToString([]byte(iamRequestBody)),
IamRequestHeaders: base64.StdEncoding.EncodeToString(headersJSON),
})
if err != nil {
return "", err
}

return resp.AccessToken, nil
}

// LoadStoredGatewayID returns the persisted gateway id for a named gateway (set after first
// AWS-auth login so subsequent restarts don't need --gateway-id).
func LoadStoredGatewayID(name string) (string, error) {
return loadConfKey(name, INFISICAL_GATEWAY_ID_KEY)
}

// SaveGatewayID persists the gateway id used during AWS-auth login.
func SaveGatewayID(name, gatewayID string) error {
return saveConfKey(name, INFISICAL_GATEWAY_ID_KEY, gatewayID)
}
5 changes: 5 additions & 0 deletions packages/gateway-v2/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ const (
INFISICAL_TOKEN_ENV_NAME = "INFISICAL_TOKEN"

INFISICAL_HTTP_PROXY_ACTION_HEADER = "x-infisical-action"

// Gateway auth-method discriminators. Used both for matching the user's --enroll-method
// flag value and as the `method` field on the /v3/gateways/login request body.
EnrollMethodAws = "aws"
EnrollMethodToken = "token"
)

type HttpProxyAction string
Expand Down
1 change: 0 additions & 1 deletion packages/gateway-v2/enroll.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ const (
INFISICAL_GATEWAY_ACCESS_TOKEN_KEY = "INFISICAL_GATEWAY_ACCESS_TOKEN"
INFISICAL_GATEWAY_DOMAIN_KEY = "INFISICAL_GATEWAY_DOMAIN"
INFISICAL_GATEWAY_ENROLLMENT_TOKEN_KEY = "INFISICAL_GATEWAY_ENROLLMENT_TOKEN"
EnrollMethodToken = "token"
)

// gatewayConfPath returns the path to the gateway config file scoped by name.
Expand Down
58 changes: 58 additions & 0 deletions packages/gateway-v2/systemd.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,64 @@ func InstallEnrolledGatewaySystemdService(accessToken string, domain string, nam
return nil
}

// InstallAwsAuthGatewaySystemdService installs the systemd service for a gateway using AWS Auth.
// Unlike the token-auth flow, no JWT is written into the env file — the gateway performs a
// fresh STS-signed login on each service start using whatever AWS credentials it can resolve
// (instance role, env vars, shared profile). We just persist the gateway id, domain, and name
// so `gateway start` can re-authenticate.
func InstallAwsAuthGatewaySystemdService(gatewayID string, domain string, name string, relayName string, serviceLogFile string) error {
if runtime.GOOS != "linux" {
log.Info().Msg("Skipping systemd service installation - not on Linux")
return nil
}

if os.Geteuid() != 0 {
log.Info().Msg("Skipping systemd service installation - not running as root/sudo")
return nil
}

configDir := "/etc/infisical"
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %v", err)
}

configContent := fmt.Sprintf("%s=%s\n", INFISICAL_GATEWAY_ID_KEY, gatewayID)
configContent += "INFISICAL_GATEWAY_ENROLL_METHOD=aws\n"
if domain != "" {
configContent += fmt.Sprintf("INFISICAL_API_URL=%s\n", domain)
}
if name != "" {
configContent += fmt.Sprintf("%s=%s\n", GATEWAY_NAME_ENV_NAME, name)
}
if relayName != "" {
configContent += fmt.Sprintf("%s=%s\n", RELAY_NAME_ENV_NAME, relayName)
}

environmentFilePath := filepath.Join(configDir, "gateway.conf")
if err := os.WriteFile(environmentFilePath, []byte(configContent), 0600); err != nil {
return fmt.Errorf("failed to write environment file: %v", err)
}

if err := util.WriteSystemdServiceFile(serviceLogFile, environmentFilePath, "infisical-gateway", "gateway", "Infisical Gateway Service"); err != nil {
return fmt.Errorf("failed to write systemd service file: %v", err)
}

if err := util.WriteLogrotateFile(serviceLogFile, "infisical-gateway"); err != nil {
return fmt.Errorf("failed to write logrotate file: %v", err)
}

reloadCmd := exec.Command("systemctl", "daemon-reload")
if err := reloadCmd.Run(); err != nil {
return fmt.Errorf("failed to reload systemd: %v", err)
}

log.Info().Msg("Successfully installed systemd service")
log.Info().Msg("To start the service, run: sudo systemctl start infisical-gateway")
log.Info().Msg("To enable the service on boot, run: sudo systemctl enable infisical-gateway")

return nil
}

func UninstallGatewaySystemdService() error {
if runtime.GOOS != "linux" {
log.Info().Msg("Skipping systemd service uninstallation - not on Linux")
Expand Down
Loading