diff --git a/go.mod b/go.mod index 08ab82b9..876b8b5f 100644 --- a/go.mod +++ b/go.mod @@ -50,9 +50,12 @@ require ( golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 golang.org/x/term v0.43.0 + golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 + golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 google.golang.org/api v0.282.0 google.golang.org/grpc v1.81.1 gopkg.in/yaml.v3 v3.0.1 + gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c k8s.io/api v0.36.1 k8s.io/apimachinery v0.36.1 k8s.io/client-go v0.36.1 @@ -123,6 +126,7 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-containerregistry v0.20.6 // indirect github.com/google/go-github/v73 v73.0.0 // indirect @@ -237,6 +241,7 @@ require ( golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.44.0 // indirect golang.org/x/vuln v1.3.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genai v1.54.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect diff --git a/go.sum b/go.sum index 4f2f5663..1e480f37 100644 --- a/go.sum +++ b/go.sum @@ -221,6 +221,8 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= @@ -662,6 +664,12 @@ golang.org/x/vuln v1.3.0 h1:hZYzR8uRhYhDSX88d+40TWbKAVw7BIvRWm26rtEn8jw= golang.org/x/vuln v1.3.0/go.mod h1:MIY2PaR1y52stzZM3uHBboUAdVJvSVMl5nP3OQrwQaE= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= @@ -715,8 +723,10 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= -gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= +gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= +gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= +gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= +gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= diff --git a/internal/app/envvars.go b/internal/app/envvars.go index 3dcb5bd6..cc635da3 100644 --- a/internal/app/envvars.go +++ b/internal/app/envvars.go @@ -95,7 +95,6 @@ func GetApplicationEnvVars(ctx context.Context, slug, name, env string) ([]EnvVa return nil, nil } - // Select the newest group by creation time. newest := groups[0] for _, g := range groups[1:] { if g.Created.After(newest.Created) { diff --git a/internal/naisapi/gql/generated.go b/internal/naisapi/gql/generated.go index 587cbfe5..8dc5cd8a 100644 --- a/internal/naisapi/gql/generated.go +++ b/internal/naisapi/gql/generated.go @@ -790,6 +790,81 @@ func (v *CreateSecretResponse) GetCreateSecret() CreateSecretCreateSecretCreateS return v.CreateSecret } +// CreateTunnelCreateTunnelCreateTunnelPayload includes the requested fields of the GraphQL type CreateTunnelPayload. +type CreateTunnelCreateTunnelCreateTunnelPayload struct { + Tunnel CreateTunnelCreateTunnelCreateTunnelPayloadTunnel `json:"tunnel"` +} + +// GetTunnel returns CreateTunnelCreateTunnelCreateTunnelPayload.Tunnel, and is useful for accessing the field via an interface. +func (v *CreateTunnelCreateTunnelCreateTunnelPayload) GetTunnel() CreateTunnelCreateTunnelCreateTunnelPayloadTunnel { + return v.Tunnel +} + +// CreateTunnelCreateTunnelCreateTunnelPayloadTunnel includes the requested fields of the GraphQL type Tunnel. +type CreateTunnelCreateTunnelCreateTunnelPayloadTunnel struct { + Id string `json:"id"` + Name string `json:"name"` + Phase TunnelPhase `json:"phase"` + GatewayPublicKey string `json:"gatewayPublicKey"` + ForwarderEndpoint string `json:"forwarderEndpoint"` + Message string `json:"message"` +} + +// GetId returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.Id, and is useful for accessing the field via an interface. +func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetId() string { return v.Id } + +// GetName returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.Name, and is useful for accessing the field via an interface. +func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetName() string { return v.Name } + +// GetPhase returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.Phase, and is useful for accessing the field via an interface. +func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetPhase() TunnelPhase { return v.Phase } + +// GetGatewayPublicKey returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.GatewayPublicKey, and is useful for accessing the field via an interface. +func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetGatewayPublicKey() string { + return v.GatewayPublicKey +} + +// GetForwarderEndpoint returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.ForwarderEndpoint, and is useful for accessing the field via an interface. +func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetForwarderEndpoint() string { + return v.ForwarderEndpoint +} + +// GetMessage returns CreateTunnelCreateTunnelCreateTunnelPayloadTunnel.Message, and is useful for accessing the field via an interface. +func (v *CreateTunnelCreateTunnelCreateTunnelPayloadTunnel) GetMessage() string { return v.Message } + +type CreateTunnelInput struct { + TeamSlug string `json:"teamSlug"` + EnvironmentName string `json:"environmentName"` + TargetHost string `json:"targetHost"` + TargetPort int `json:"targetPort"` + ClientPublicKey string `json:"clientPublicKey"` +} + +// GetTeamSlug returns CreateTunnelInput.TeamSlug, and is useful for accessing the field via an interface. +func (v *CreateTunnelInput) GetTeamSlug() string { return v.TeamSlug } + +// GetEnvironmentName returns CreateTunnelInput.EnvironmentName, and is useful for accessing the field via an interface. +func (v *CreateTunnelInput) GetEnvironmentName() string { return v.EnvironmentName } + +// GetTargetHost returns CreateTunnelInput.TargetHost, and is useful for accessing the field via an interface. +func (v *CreateTunnelInput) GetTargetHost() string { return v.TargetHost } + +// GetTargetPort returns CreateTunnelInput.TargetPort, and is useful for accessing the field via an interface. +func (v *CreateTunnelInput) GetTargetPort() int { return v.TargetPort } + +// GetClientPublicKey returns CreateTunnelInput.ClientPublicKey, and is useful for accessing the field via an interface. +func (v *CreateTunnelInput) GetClientPublicKey() string { return v.ClientPublicKey } + +// CreateTunnelResponse is returned by CreateTunnel on success. +type CreateTunnelResponse struct { + CreateTunnel CreateTunnelCreateTunnelCreateTunnelPayload `json:"createTunnel"` +} + +// GetCreateTunnel returns CreateTunnelResponse.CreateTunnel, and is useful for accessing the field via an interface. +func (v *CreateTunnelResponse) GetCreateTunnel() CreateTunnelCreateTunnelCreateTunnelPayload { + return v.CreateTunnel +} + // CreateValkeyCreateValkeyCreateValkeyPayload includes the requested fields of the GraphQL type CreateValkeyPayload. type CreateValkeyCreateValkeyCreateValkeyPayload struct { // Valkey instance that was created. @@ -984,6 +1059,24 @@ func (v *DeleteSecretResponse) GetDeleteSecret() DeleteSecretDeleteSecretDeleteS return v.DeleteSecret } +// DeleteTunnelDeleteTunnelDeleteTunnelPayload includes the requested fields of the GraphQL type DeleteTunnelPayload. +type DeleteTunnelDeleteTunnelDeleteTunnelPayload struct { + Success bool `json:"success"` +} + +// GetSuccess returns DeleteTunnelDeleteTunnelDeleteTunnelPayload.Success, and is useful for accessing the field via an interface. +func (v *DeleteTunnelDeleteTunnelDeleteTunnelPayload) GetSuccess() bool { return v.Success } + +// DeleteTunnelResponse is returned by DeleteTunnel on success. +type DeleteTunnelResponse struct { + DeleteTunnel DeleteTunnelDeleteTunnelDeleteTunnelPayload `json:"deleteTunnel"` +} + +// GetDeleteTunnel returns DeleteTunnelResponse.DeleteTunnel, and is useful for accessing the field via an interface. +func (v *DeleteTunnelResponse) GetDeleteTunnel() DeleteTunnelDeleteTunnelDeleteTunnelPayload { + return v.DeleteTunnel +} + // DeleteValkeyDeleteValkeyDeleteValkeyPayload includes the requested fields of the GraphQL type DeleteValkeyPayload. type DeleteValkeyDeleteValkeyDeleteValkeyPayload struct { // Whether or not the job was deleted. @@ -27532,6 +27625,67 @@ func (v *GetTeamVulnerabilitySummaryTeamVulnerabilitySummary) GetLastUpdated() t return v.LastUpdated } +// GetTunnelResponse is returned by GetTunnel on success. +type GetTunnelResponse struct { + // Get a team by its slug. + Team GetTunnelTeam `json:"team"` +} + +// GetTeam returns GetTunnelResponse.Team, and is useful for accessing the field via an interface. +func (v *GetTunnelResponse) GetTeam() GetTunnelTeam { return v.Team } + +// GetTunnelTeam includes the requested fields of the GraphQL type Team. +// The GraphQL type's documentation follows. +// +// The team type represents a team on the [Nais platform](https://nais.io/). +// +// Learn more about what Nais teams are and what they can be used for in the [official Nais documentation](https://docs.nais.io/explanations/team/). +// +// External resources (e.g. entraIDGroupID, gitHubTeamSlug) are managed by [Nais API reconcilers](https://github.com/nais/api-reconcilers). +type GetTunnelTeam struct { + // Get a specific environment for the team. + Environment GetTunnelTeamEnvironment `json:"environment"` +} + +// GetEnvironment returns GetTunnelTeam.Environment, and is useful for accessing the field via an interface. +func (v *GetTunnelTeam) GetEnvironment() GetTunnelTeamEnvironment { return v.Environment } + +// GetTunnelTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. +type GetTunnelTeamEnvironment struct { + Tunnel GetTunnelTeamEnvironmentTunnel `json:"tunnel"` +} + +// GetTunnel returns GetTunnelTeamEnvironment.Tunnel, and is useful for accessing the field via an interface. +func (v *GetTunnelTeamEnvironment) GetTunnel() GetTunnelTeamEnvironmentTunnel { return v.Tunnel } + +// GetTunnelTeamEnvironmentTunnel includes the requested fields of the GraphQL type Tunnel. +type GetTunnelTeamEnvironmentTunnel struct { + Id string `json:"id"` + Name string `json:"name"` + Phase TunnelPhase `json:"phase"` + GatewayPublicKey string `json:"gatewayPublicKey"` + ForwarderEndpoint string `json:"forwarderEndpoint"` + Message string `json:"message"` +} + +// GetId returns GetTunnelTeamEnvironmentTunnel.Id, and is useful for accessing the field via an interface. +func (v *GetTunnelTeamEnvironmentTunnel) GetId() string { return v.Id } + +// GetName returns GetTunnelTeamEnvironmentTunnel.Name, and is useful for accessing the field via an interface. +func (v *GetTunnelTeamEnvironmentTunnel) GetName() string { return v.Name } + +// GetPhase returns GetTunnelTeamEnvironmentTunnel.Phase, and is useful for accessing the field via an interface. +func (v *GetTunnelTeamEnvironmentTunnel) GetPhase() TunnelPhase { return v.Phase } + +// GetGatewayPublicKey returns GetTunnelTeamEnvironmentTunnel.GatewayPublicKey, and is useful for accessing the field via an interface. +func (v *GetTunnelTeamEnvironmentTunnel) GetGatewayPublicKey() string { return v.GatewayPublicKey } + +// GetForwarderEndpoint returns GetTunnelTeamEnvironmentTunnel.ForwarderEndpoint, and is useful for accessing the field via an interface. +func (v *GetTunnelTeamEnvironmentTunnel) GetForwarderEndpoint() string { return v.ForwarderEndpoint } + +// GetMessage returns GetTunnelTeamEnvironmentTunnel.Message, and is useful for accessing the field via an interface. +func (v *GetTunnelTeamEnvironmentTunnel) GetMessage() string { return v.Message } + // GetValkeyResponse is returned by GetValkey on success. type GetValkeyResponse struct { // Get a team by its slug. @@ -30875,6 +31029,26 @@ type TriggerJobTriggerJobTriggerJobPayloadJobRun struct { // GetName returns TriggerJobTriggerJobTriggerJobPayloadJobRun.Name, and is useful for accessing the field via an interface. func (v *TriggerJobTriggerJobTriggerJobPayloadJobRun) GetName() string { return v.Name } +type TunnelPhase string + +const ( + TunnelPhasePending TunnelPhase = "PENDING" + TunnelPhaseProvisioning TunnelPhase = "PROVISIONING" + TunnelPhaseReady TunnelPhase = "READY" + TunnelPhaseConnected TunnelPhase = "CONNECTED" + TunnelPhaseFailed TunnelPhase = "FAILED" + TunnelPhaseTerminated TunnelPhase = "TERMINATED" +) + +var AllTunnelPhase = []TunnelPhase{ + TunnelPhasePending, + TunnelPhaseProvisioning, + TunnelPhaseReady, + TunnelPhaseConnected, + TunnelPhaseFailed, + TunnelPhaseTerminated, +} + // UpdateConfigValueResponse is returned by UpdateConfigValue on success. type UpdateConfigValueResponse struct { // Update a value within a config. @@ -31664,6 +31838,14 @@ func (v *__CreateSecretInput) GetEnvironment() string { return v.Environment } // GetTeam returns __CreateSecretInput.Team, and is useful for accessing the field via an interface. func (v *__CreateSecretInput) GetTeam() string { return v.Team } +// __CreateTunnelInput is used internally by genqlient +type __CreateTunnelInput struct { + Input CreateTunnelInput `json:"input"` +} + +// GetInput returns __CreateTunnelInput.Input, and is useful for accessing the field via an interface. +func (v *__CreateTunnelInput) GetInput() CreateTunnelInput { return v.Input } + // __CreateValkeyCredentialsInput is used internally by genqlient type __CreateValkeyCredentialsInput struct { TeamSlug string `json:"teamSlug"` @@ -31780,6 +31962,22 @@ func (v *__DeleteSecretInput) GetEnvironment() string { return v.Environment } // GetTeam returns __DeleteSecretInput.Team, and is useful for accessing the field via an interface. func (v *__DeleteSecretInput) GetTeam() string { return v.Team } +// __DeleteTunnelInput is used internally by genqlient +type __DeleteTunnelInput struct { + TeamSlug string `json:"teamSlug"` + EnvironmentName string `json:"environmentName"` + TunnelName string `json:"tunnelName"` +} + +// GetTeamSlug returns __DeleteTunnelInput.TeamSlug, and is useful for accessing the field via an interface. +func (v *__DeleteTunnelInput) GetTeamSlug() string { return v.TeamSlug } + +// GetEnvironmentName returns __DeleteTunnelInput.EnvironmentName, and is useful for accessing the field via an interface. +func (v *__DeleteTunnelInput) GetEnvironmentName() string { return v.EnvironmentName } + +// GetTunnelName returns __DeleteTunnelInput.TunnelName, and is useful for accessing the field via an interface. +func (v *__DeleteTunnelInput) GetTunnelName() string { return v.TunnelName } + // __DeleteValkeyInput is used internally by genqlient type __DeleteValkeyInput struct { Name string `json:"name"` @@ -32212,6 +32410,22 @@ func (v *__GetTeamVulnerabilitySummaryInput) GetFilter() *TeamVulnerabilitySumma return v.Filter } +// __GetTunnelInput is used internally by genqlient +type __GetTunnelInput struct { + TeamSlug string `json:"teamSlug"` + EnvironmentName string `json:"environmentName"` + Name string `json:"name"` +} + +// GetTeamSlug returns __GetTunnelInput.TeamSlug, and is useful for accessing the field via an interface. +func (v *__GetTunnelInput) GetTeamSlug() string { return v.TeamSlug } + +// GetEnvironmentName returns __GetTunnelInput.EnvironmentName, and is useful for accessing the field via an interface. +func (v *__GetTunnelInput) GetEnvironmentName() string { return v.EnvironmentName } + +// GetName returns __GetTunnelInput.Name, and is useful for accessing the field via an interface. +func (v *__GetTunnelInput) GetName() string { return v.Name } + // __GetValkeyInput is used internally by genqlient type __GetValkeyInput struct { Name string `json:"name"` @@ -32965,6 +33179,47 @@ func CreateSecret( return data_, err_ } +// The mutation executed by CreateTunnel. +const CreateTunnel_Operation = ` +mutation CreateTunnel ($input: CreateTunnelInput!) { + createTunnel(input: $input) { + tunnel { + id + name + phase + gatewayPublicKey + forwarderEndpoint + message + } + } +} +` + +func CreateTunnel( + ctx_ context.Context, + client_ graphql.Client, + input CreateTunnelInput, +) (data_ *CreateTunnelResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "CreateTunnel", + Query: CreateTunnel_Operation, + Variables: &__CreateTunnelInput{ + Input: input, + }, + } + + data_ = &CreateTunnelResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The mutation executed by CreateValkey. const CreateValkey_Operation = ` mutation CreateValkey ($name: String!, $environmentName: String!, $teamSlug: Slug!, $memory: ValkeyMemory!, $tier: ValkeyTier!, $maxMemoryPolicy: ValkeyMaxMemoryPolicy) { @@ -33212,6 +33467,44 @@ func DeleteSecret( return data_, err_ } +// The mutation executed by DeleteTunnel. +const DeleteTunnel_Operation = ` +mutation DeleteTunnel ($teamSlug: Slug!, $environmentName: String!, $tunnelName: String!) { + deleteTunnel(input: {teamSlug:$teamSlug,environmentName:$environmentName,tunnelName:$tunnelName}) { + success + } +} +` + +func DeleteTunnel( + ctx_ context.Context, + client_ graphql.Client, + teamSlug string, + environmentName string, + tunnelName string, +) (data_ *DeleteTunnelResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "DeleteTunnel", + Query: DeleteTunnel_Operation, + Variables: &__DeleteTunnelInput{ + TeamSlug: teamSlug, + EnvironmentName: environmentName, + TunnelName: tunnelName, + }, + } + + data_ = &DeleteTunnelResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The mutation executed by DeleteValkey. const DeleteValkey_Operation = ` mutation DeleteValkey ($name: String!, $environmentName: String!, $teamSlug: Slug!) { @@ -35005,6 +35298,53 @@ func GetTeamVulnerabilitySummary( return data_, err_ } +// The query executed by GetTunnel. +const GetTunnel_Operation = ` +query GetTunnel ($teamSlug: Slug!, $environmentName: String!, $name: String!) { + team(slug: $teamSlug) { + environment(name: $environmentName) { + tunnel(name: $name) { + id + name + phase + gatewayPublicKey + forwarderEndpoint + message + } + } + } +} +` + +func GetTunnel( + ctx_ context.Context, + client_ graphql.Client, + teamSlug string, + environmentName string, + name string, +) (data_ *GetTunnelResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetTunnel", + Query: GetTunnel_Operation, + Variables: &__GetTunnelInput{ + TeamSlug: teamSlug, + EnvironmentName: environmentName, + Name: name, + }, + } + + data_ = &GetTunnelResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by GetValkey. const GetValkey_Operation = ` query GetValkey ($name: String!, $environmentName: String!, $teamSlug: Slug!) { diff --git a/internal/secret/command/get.go b/internal/secret/command/get.go index f7da566d..5541bb7a 100644 --- a/internal/secret/command/get.go +++ b/internal/secret/command/get.go @@ -17,8 +17,6 @@ import ( "github.com/nais/naistrix/output" ) -// Entry represents a key-value pair in a secret. When values are not fetched, -// the Value field is empty and omitted from JSON output. type Entry struct { Key string `json:"key"` Value string `json:"value,omitempty"` diff --git a/internal/status/command/status.go b/internal/status/command/status.go index 73921fe2..d4d113e6 100644 --- a/internal/status/command/status.go +++ b/internal/status/command/status.go @@ -31,10 +31,11 @@ func (f workloadsWithIssues) String() string { } var issues strings.Builder - issues.WriteString(fmt.Sprintf("%v workloads with issues\n\n", len(f))) + fmt.Fprintf(&issues, "%v workloads with issues\n\n", len(f)) for _, w := range f { - issues.WriteString(fmt.Sprintf("%s (%s): %s\n", w.Kind, w.Environment, w.Name)) - issues.WriteString(formatErrorTypes(w.ErrorTypes) + "\n\n") + fmt.Fprintf(&issues, "%s (%s): %s\n", w.Kind, w.Environment, w.Name) + issues.WriteString(formatErrorTypes(w.ErrorTypes)) + issues.WriteString("\n\n") } return strings.TrimRight(issues.String(), "\n") @@ -67,7 +68,7 @@ func Status(parentFlags *flags.GlobalFlags) *naistrix.Command { var entries []statusEntry for _, t := range ret { - workloadsWithCriticalIssues := make([]gql.TeamStatusMeUserTeamsTeamMemberConnectionNodesTeamMemberTeamWorkloadsWorkloadConnectionNodesWorkload, 0) + var workloadsWithCriticalIssues []gql.TeamStatusMeUserTeamsTeamMemberConnectionNodesTeamMemberTeamWorkloadsWorkloadConnectionNodesWorkload for _, w := range t.Team.Workloads.Nodes { if w.GetIssues().PageInfo.TotalCount > 0 { workloadsWithCriticalIssues = append(workloadsWithCriticalIssues, w) @@ -81,7 +82,6 @@ func Status(parentFlags *flags.GlobalFlags) *naistrix.Command { }, Workloads: t.Team.Workloads.PageInfo.TotalCount, NotNais: len(workloadsWithCriticalIssues), - Issues: make(workloadsWithIssues, 0), } for _, f := range workloadsWithCriticalIssues { a := workload{ diff --git a/internal/tunnel/queries.go b/internal/tunnel/queries.go new file mode 100644 index 00000000..60f3db77 --- /dev/null +++ b/internal/tunnel/queries.go @@ -0,0 +1,43 @@ +package tunnel + +// genqlient operation definitions used by go generate. + +var _ = `# @genqlient +mutation CreateTunnel($input: CreateTunnelInput!) { + createTunnel(input: $input) { + tunnel { + id + name + phase + gatewayPublicKey + forwarderEndpoint + message + } + } +} +` + +var _ = `# @genqlient +query GetTunnel($teamSlug: Slug!, $environmentName: String!, $name: String!) { + team(slug: $teamSlug) { + environment(name: $environmentName) { + tunnel(name: $name) { + id + name + phase + gatewayPublicKey + forwarderEndpoint + message + } + } + } +} +` + +var _ = `# @genqlient +mutation DeleteTunnel($teamSlug: Slug!, $environmentName: String!, $tunnelName: String!) { + deleteTunnel(input: { teamSlug: $teamSlug, environmentName: $environmentName, tunnelName: $tunnelName }) { + success + } +} +` diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go new file mode 100644 index 00000000..7cf1e296 --- /dev/null +++ b/internal/tunnel/tunnel.go @@ -0,0 +1,144 @@ +package tunnel + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/nais/cli/internal/naisapi" + "github.com/nais/cli/internal/naisapi/gql" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +type TunnelInfo struct { + TunnelName string + TeamSlug string + EnvironmentName string + GatewayPublicKey wgtypes.Key + GatewayEndpoint string + PrivateKey wgtypes.Key + WireGuardTunnel *WireGuardTunnel +} + +type Config struct { + TeamSlug string + Environment string + ListenAddr string + TargetHost string + TargetPort int +} + +func CreateAndConnect(ctx context.Context, cfg Config, progress func(string)) (ret *TunnelInfo, retErr error) { + privateKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + return nil, fmt.Errorf("generate wireguard key: %w", err) + } + publicKey := privateKey.PublicKey() + + client, err := naisapi.GraphqlClient(ctx) + if err != nil { + return nil, fmt.Errorf("get graphql client: %w", err) + } + + progress("Creating tunnel") + + createResp, err := gql.CreateTunnel(ctx, client, gql.CreateTunnelInput{ + TeamSlug: cfg.TeamSlug, + EnvironmentName: cfg.Environment, + TargetHost: cfg.TargetHost, + TargetPort: cfg.TargetPort, + ClientPublicKey: publicKey.String(), + }) + if err != nil { + return nil, fmt.Errorf("create tunnel: %w", err) + } + tunnelName := createResp.CreateTunnel.Tunnel.Name + + // Clean up the server-side tunnel on any failure after creation. + defer func() { + if retErr != nil { + _ = DeleteTunnel(context.Background(), cfg.TeamSlug, cfg.Environment, tunnelName) + } + }() + + progress("Waiting for gateway") + + timeout := time.After(60 * time.Second) + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + var gatewayPublicKey string + var forwarderEndpoint string + var lastPollErr error + + for { + select { + case <-timeout: + if lastPollErr != nil { + return nil, fmt.Errorf("gateway did not become ready within 60s (last error: %v)", lastPollErr) + } + return nil, fmt.Errorf("gateway did not become ready within 60s — try again or check cluster status") + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + pollResp, err := gql.GetTunnel(ctx, client, cfg.TeamSlug, cfg.Environment, tunnelName) + if err != nil { + lastPollErr = err + continue + } + lastPollErr = nil + t := pollResp.Team.Environment.Tunnel + if t.Id == "" { + continue + } + switch phase := gql.TunnelPhase(strings.ToUpper(string(t.Phase))); phase { + case gql.TunnelPhaseReady, gql.TunnelPhaseConnected: + gatewayPublicKey = t.GatewayPublicKey + forwarderEndpoint = t.ForwarderEndpoint + progress("Gateway ready") + case gql.TunnelPhaseFailed: + return nil, fmt.Errorf("gateway failed to start: %s", t.Message) + case gql.TunnelPhaseTerminated: + return nil, fmt.Errorf("gateway terminated unexpectedly: %s", t.Message) + default: + progress(fmt.Sprintf("Gateway %s", t.Phase)) + continue + } + } + if gatewayPublicKey != "" { + break + } + } + + gwKey, err := wgtypes.ParseKey(gatewayPublicKey) + if err != nil { + return nil, fmt.Errorf("parse gateway public key: %w", err) + } + + progress("Connecting WireGuard") + + wgTunnel, err := SetupWireGuard(privateKey, gwKey, forwarderEndpoint) + if err != nil { + return nil, fmt.Errorf("setup wireguard: %w", err) + } + + return &TunnelInfo{ + TunnelName: tunnelName, + TeamSlug: cfg.TeamSlug, + EnvironmentName: cfg.Environment, + GatewayPublicKey: gwKey, + GatewayEndpoint: forwarderEndpoint, + PrivateKey: privateKey, + WireGuardTunnel: wgTunnel, + }, nil +} + +func DeleteTunnel(ctx context.Context, teamSlug, environmentName, tunnelName string) error { + client, err := naisapi.GraphqlClient(ctx) + if err != nil { + return fmt.Errorf("get graphql client: %w", err) + } + _, err = gql.DeleteTunnel(ctx, client, teamSlug, environmentName, tunnelName) + return err +} diff --git a/internal/tunnel/wireguard.go b/internal/tunnel/wireguard.go new file mode 100644 index 00000000..3f5bb2ae --- /dev/null +++ b/internal/tunnel/wireguard.go @@ -0,0 +1,126 @@ +package tunnel + +import ( + "encoding/hex" + "fmt" + "net" + "net/netip" + "unsafe" + + "golang.zx2c4.com/wireguard/conn" + "golang.zx2c4.com/wireguard/device" + "golang.zx2c4.com/wireguard/tun/netstack" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/stack" + "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" +) + +const ( + // TunnelIPClient is the WireGuard tunnel IP for the CLI side. + TunnelIPClient = "10.0.0.1/30" + // TunnelIPGateway is the WireGuard tunnel IP for the gateway side. + TunnelIPGateway = "10.0.0.2/30" + // PersistentKeepalive in seconds — keeps NAT mappings alive (< Cloud NAT 30s timeout). + PersistentKeepalive = 20 +) + +// WireGuardTunnel wraps a userspace WireGuard device with an associated netstack. +type WireGuardTunnel struct { + dev *device.Device + net *netstack.Net +} + +// SetupWireGuard creates a userspace WireGuard device for the CLI side of the tunnel. +func SetupWireGuard(privateKey wgtypes.Key, gatewayPublicKey wgtypes.Key, gatewayEndpoint string) (*WireGuardTunnel, error) { + return setupWireGuard(privateKey, gatewayPublicKey, gatewayEndpoint, conn.NewDefaultBind(), "listen_port=0\n") +} + +func setupWireGuard(privateKey wgtypes.Key, gatewayPublicKey wgtypes.Key, gatewayEndpoint string, bind conn.Bind, listenPortConfig string) (*WireGuardTunnel, error) { + prefix, err := netip.ParsePrefix(TunnelIPClient) + if err != nil { + return nil, fmt.Errorf("parse tunnel IP: %w", err) + } + + tun, wgNet, err := netstack.CreateNetTUN( + []netip.Addr{prefix.Addr()}, + []netip.Addr{}, // no DNS + 1400, // GKE VPC MTU is 1460; WireGuard overhead is 60 bytes (20 IPv4 + 8 UDP + 32 WG) + ) + if err != nil { + return nil, fmt.Errorf("create netstack TUN: %w", err) + } + if bind == nil { + return nil, fmt.Errorf("wireguard bind is nil") + } + + tuneStack((*netstackView)(unsafe.Pointer(wgNet)).stack) // #nosec G103 -- mirrors netstack.Net layout to access unexported stack field + + logger := device.NewLogger(device.LogLevelError, "[wireguard-cli] ") + dev := device.NewDevice(tun, bind, logger) + + cfg := fmt.Sprintf(`private_key=%s +%spublic_key=%s +persistent_keepalive_interval=%d +allowed_ip=0.0.0.0/0 +endpoint=%s +`, encodeKeyHex(privateKey), listenPortConfig, encodeKeyHex(gatewayPublicKey), PersistentKeepalive, gatewayEndpoint) + + if err := dev.IpcSet(cfg); err != nil { + dev.Close() + return nil, fmt.Errorf("configure wireguard: %w", err) + } + + if err := dev.Up(); err != nil { + dev.Close() + return nil, fmt.Errorf("bring up wireguard: %w", err) + } + + return &WireGuardTunnel{dev: dev, net: wgNet}, nil +} + +// netstackView mirrors the memory layout of netstack.Net to access the unexported stack field. +type netstackView struct { + _ unsafe.Pointer + stack *stack.Stack +} + +// tuneStack adjusts gVisor netstack TCP parameters for better throughput. +func tuneStack(s *stack.Stack) { + if s == nil { + return + } + s.SetTransportProtocolOption(tcp.ProtocolNumber, &tcpip.TCPReceiveBufferSizeRangeOption{ + Min: tcp.MinBufferSize, + Default: tcp.DefaultReceiveBufferSize, + Max: 8 << 20, // 8MiB — Tailscale production value (tailscale/tailscale#12994) + }) + s.SetTransportProtocolOption(tcp.ProtocolNumber, &tcpip.TCPSendBufferSizeRangeOption{ + Min: tcp.MinBufferSize, + Default: tcp.DefaultSendBufferSize, + Max: 6 << 20, // 6MiB + }) + // RACK disabled: gVisor bug causes spurious retransmissions (tailscale/tailscale#9707) + rackOpt := tcpip.TCPRecovery(0) + s.SetTransportProtocolOption(tcp.ProtocolNumber, &rackOpt) +} + +// Net returns the netstack network for creating TCP connections through the tunnel. +func (t *WireGuardTunnel) Net() *netstack.Net { + return t.net +} + +// DialTCP creates a TCP connection through the WireGuard tunnel. +func (t *WireGuardTunnel) DialTCP(addr string) (net.Conn, error) { + return t.net.Dial("tcp", addr) +} + +func (t *WireGuardTunnel) Close() { + if t != nil && t.dev != nil { + t.dev.Close() + } +} + +func encodeKeyHex(key wgtypes.Key) string { + return hex.EncodeToString(key[:]) +} diff --git a/internal/tunnel/wireguard_test.go b/internal/tunnel/wireguard_test.go new file mode 100644 index 00000000..f2f2e471 --- /dev/null +++ b/internal/tunnel/wireguard_test.go @@ -0,0 +1,28 @@ +package tunnel + +import ( + "testing" + + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +func TestSetupWireGuard(t *testing.T) { + privKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + peerKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + t.Fatalf("generate peer key: %v", err) + } + + tun, err := SetupWireGuard(privKey, peerKey.PublicKey(), "127.0.0.1:51820") + if err != nil { + t.Fatalf("setup wireguard: %v", err) + } + defer tun.Close() + + if tun.Net() == nil { + t.Error("expected non-nil net") + } +} diff --git a/internal/valkey/command/command.go b/internal/valkey/command/command.go index 96e889fe..09615a2d 100644 --- a/internal/valkey/command/command.go +++ b/internal/valkey/command/command.go @@ -27,6 +27,7 @@ func Valkey(parentFlags *flags.GlobalFlags) *naistrix.Command { delete(f), get(f), list(f), + proxy(f), updateValkey(f), }, } diff --git a/internal/valkey/command/flag/flag.go b/internal/valkey/command/flag/flag.go index 9f2503af..dcbc3c3a 100644 --- a/internal/valkey/command/flag/flag.go +++ b/internal/valkey/command/flag/flag.go @@ -140,6 +140,11 @@ func (m *MaxMemoryPolicy) IsValid() bool { return false } +type Proxy struct { + *Valkey + ListenAddr string `name:"listen-addr" short:"a" usage:"Address to listen on for the proxy. Defaults to |localhost:6379|."` +} + type Credentials struct { *Valkey Permission Permission `name:"permission" short:"p" usage:"Permission level for the credentials (READ, WRITE, READWRITE, ADMIN)."` diff --git a/internal/valkey/command/proxy.go b/internal/valkey/command/proxy.go new file mode 100644 index 00000000..ba83f08a --- /dev/null +++ b/internal/valkey/command/proxy.go @@ -0,0 +1,134 @@ +package command + +import ( + "context" + "fmt" + "io" + "net" + "os/signal" + "sync" + "syscall" + + "github.com/nais/cli/internal/naisapi/gql" + "github.com/nais/cli/internal/tunnel" + "github.com/nais/cli/internal/validation" + "github.com/nais/cli/internal/valkey" + "github.com/nais/cli/internal/valkey/command/flag" + "github.com/nais/naistrix" + "github.com/pterm/pterm" +) + +func proxy(parentFlags *flag.Valkey) *naistrix.Command { + flags := &flag.Proxy{ + Valkey: parentFlags, + ListenAddr: "localhost:6379", + } + + return &naistrix.Command{ + Name: "proxy", + Title: "Create a proxy to a Valkey instance.", + Description: "Allows your user to connect to Valkey instances and starts a proxy.", + Flags: flags, + Args: defaultArgs, + ValidateFunc: naistrix.ValidateFuncs( + validation.RequireEnvironment(flags), + validateArgs, + ), + AutoCompleteFunc: func(ctx context.Context, args *naistrix.Arguments, _ string) ([]string, string) { + if args.Len() != 0 { + return nil, "" + } + return autoCompleteValkeyNames(ctx, flags.Team, string(flags.Environment), true) + }, + Examples: []naistrix.Example{ + { + Description: "Create a proxy to a Valkey instance named my-valkey in environment dev.", + Command: "my-valkey --environment dev", + }, + }, + RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + creds, err := valkey.CreateCredentials( + ctx, + flags.Team, + string(flags.Environment), + args.Get("name"), + gql.CredentialPermissionReadwrite, + "1h", + ) + if err != nil { + return fmt.Errorf("get valkey credentials: %w", err) + } + + spinner, _ := pterm.DefaultSpinner.Start("Creating tunnel") + + tunnelInfo, err := tunnel.CreateAndConnect(ctx, tunnel.Config{ + TeamSlug: flags.Team, + Environment: string(flags.Environment), + ListenAddr: flags.ListenAddr, + TargetHost: creds.Host, + TargetPort: creds.Port, + }, func(msg string) { spinner.UpdateText(msg) }) + if err != nil { + spinner.Fail() + return fmt.Errorf("create tunnel: %w", err) + } + defer tunnelInfo.WireGuardTunnel.Close() + defer tunnel.DeleteTunnel(context.Background(), tunnelInfo.TeamSlug, tunnelInfo.EnvironmentName, tunnelInfo.TunnelName) //nolint:errcheck + + ctx, stop := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT) + defer stop() + + lc := net.ListenConfig{} + listener, err := lc.Listen(ctx, "tcp", flags.ListenAddr) + if err != nil { + spinner.Fail() + return fmt.Errorf("listen on %s: %w", flags.ListenAddr, err) + } + defer listener.Close() + + spinner.Success(fmt.Sprintf("Listening on %s, forwarding to %s via WireGuard tunnel", + listener.Addr().String(), args.Get("name"))) + + out.Println(fmt.Sprintf("VALKEY_USERNAME=%q", creds.Username)) + out.Println(fmt.Sprintf("VALKEY_PASSWORD=%q", creds.Password)) + out.Println(fmt.Sprintf("VALKEY_URI=%q", fmt.Sprintf("rediss://%s:%s@%s", creds.Username, creds.Password, listener.Addr().String()))) + + go func() { + <-ctx.Done() + listener.Close() // #nosec G104 -- best-effort shutdown on context cancellation + }() + + gatewayAddr := fmt.Sprintf("10.0.0.2:%d", creds.Port) + var wg sync.WaitGroup + for ctx.Err() == nil { + conn, err := listener.Accept() + if err != nil { + if ctx.Err() != nil { + break + } + out.Infof("accept error: %v\n", err) + continue + } + + wg.Go(func() { + defer conn.Close() + + remote, err := tunnelInfo.WireGuardTunnel.DialTCP(gatewayAddr) + if err != nil { + out.Infof("dial through tunnel: %v\n", err) + return + } + defer remote.Close() + + var copyWg sync.WaitGroup + copyWg.Go(func() { io.Copy(remote, conn) }) // #nosec G104 //nolint:errcheck + copyWg.Go(func() { io.Copy(conn, remote) }) // #nosec G104 //nolint:errcheck + copyWg.Wait() + }) + } + + wg.Wait() + return nil + }, + } +} diff --git a/schema.graphql b/schema.graphql index cd519f0a..863b6707 100644 --- a/schema.graphql +++ b/schema.graphql @@ -12925,4 +12925,59 @@ The vulnerability. node: WorkloadWithVulnerability! } +enum TunnelPhase { + PENDING + PROVISIONING + READY + CONNECTED + FAILED + TERMINATED +} + +type TunnelTarget { + host: String! + port: Int! +} + +type Tunnel implements Node { + id: ID! + name: String! + phase: TunnelPhase! + gatewayPublicKey: String! + forwarderEndpoint: String! + target: TunnelTarget! + message: String! + createdAt: Time! +} + +input CreateTunnelInput { + teamSlug: Slug! + environmentName: String! + targetHost: String! + targetPort: Int! + clientPublicKey: String! +} + +type CreateTunnelPayload { + tunnel: Tunnel! +} + +input DeleteTunnelInput { + teamSlug: Slug! + environmentName: String! + tunnelName: String! +} + +type DeleteTunnelPayload { + success: Boolean! +} + +extend type Mutation { + createTunnel(input: CreateTunnelInput!): CreateTunnelPayload! + deleteTunnel(input: DeleteTunnelInput!): DeleteTunnelPayload! +} + +extend type TeamEnvironment { + tunnel(name: String!): Tunnel +}