Skip to content

Commit

Permalink
wip terraform_ssh_tunnels ephemeral resource type
Browse files Browse the repository at this point in the history
  • Loading branch information
apparentlymart committed May 10, 2024
1 parent 0a1b9d3 commit fd3dc2a
Show file tree
Hide file tree
Showing 2 changed files with 318 additions and 0 deletions.
311 changes: 311 additions & 0 deletions internal/builtin/providers/terraform/ephemeral_ssh_tunnels.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package terraform

import (
"bytes"
"encoding/binary"
"fmt"
"log"
"sync"
"unsafe"

"github.com/zclconf/go-cty/cty"
"golang.org/x/crypto/ssh"

"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/providers"
"github.com/hashicorp/terraform/internal/tfdiags"
)

func ephemeralSSHTunnelsSchema() providers.Schema {
return providers.Schema{
Block: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"server": {Type: cty.String, Required: true},
"username": {Type: cty.String, Required: true},

"auth_methods": {
Type: cty.List(
// This object type is acting like a sum type rather
// than a product type, requiring that exactly one
// of its attributes is set to decide which member
// to instantiate.
cty.Object(map[string]cty.Type{
"password": cty.String,
// TODO: SSH keys, etc
}),
),
Required: true,
},

"tcp_to_remote": {
Type: cty.Map(cty.Object(map[string]cty.Type{
"local_host": cty.String,
"local_port": cty.String,
"local": cty.String,
"remote": cty.String,
})),
Computed: true,
},
"tcp_from_remote": {
Type: cty.Map(cty.Object(map[string]cty.Type{
"remote_port": cty.String,
})),
Computed: true,
},
},
BlockTypes: map[string]*configschema.NestedBlock{
"tcp_local_to_remote": {
Nesting: configschema.NestingMap,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"remote": {Type: cty.String, Required: true},
"local": {Type: cty.String, Optional: true},
},
},
},
"tcp_remote_to_local": {
Nesting: configschema.NestingMap,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"remote": {Type: cty.String, Required: true},
"local": {Type: cty.String, Required: true},
},
},
},
},
},
}
}

type ephemeralSSHTunnelsConns struct {
// Keys here are the addresses of the corresponding ephemeralSSHTunnelState
// objects. This probably isn't a good idea in the long run, but it's
// fine for a prototype.
active map[uintptr]*ephemeralSSHTunnelConn
mu sync.Mutex
}

type ephemeralSSHTunnelConn struct {
client *ssh.Client
}

var ephemeralSSHTunnels ephemeralSSHTunnelsConns

func init() {
ephemeralSSHTunnels.mu.Lock()
ephemeralSSHTunnels.active = make(map[uintptr]*ephemeralSSHTunnelConn)
ephemeralSSHTunnels.mu.Unlock()
}

func openEphemeralSSHTunnels(req providers.OpenEphemeralRequest) providers.OpenEphemeralResponse {
log.Printf("[TRACE] terraform_ssh_tunnel: opening connection")
var resp providers.OpenEphemeralResponse

serverAddr, clientConfig, diags := makeEphemeralSSHTunnelClientConfig(req.Config)
resp.Diagnostics = resp.Diagnostics.Append(diags)
if diags.HasErrors() {
return resp
}
log.Printf("[DEBUG] terraform_ssh_tunnel: connecting to %s as %q", serverAddr, clientConfig.User)
client, err := ssh.Dial("tcp", serverAddr, clientConfig)
if err != nil {
resp.Diagnostics = resp.Diagnostics.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Can't connect to SSH server",
fmt.Sprintf("Failed to connect to SSH server to establish tunnels: %s.", err),
nil, // A number of different arguments could potentially cause a connection failure
))
return resp
}

ephemeralSSHTunnels.mu.Lock()
defer ephemeralSSHTunnels.mu.Unlock()

conn := &ephemeralSSHTunnelConn{
client: client,
}
connID := uintptr(unsafe.Pointer(conn))
ephemeralSSHTunnels.active[connID] = conn

var intCtx bytes.Buffer
intCtx.Grow(8)
binary.Write(&intCtx, binary.LittleEndian, uint64(connID))
resp.InternalContext = intCtx.Bytes()

resp.Result = cty.ObjectVal(map[string]cty.Value{
"server": req.Config.GetAttr("server"),
"username": req.Config.GetAttr("username"),
"auth_methods": req.Config.GetAttr("auth_methods"),
"tcp_local_to_remote": req.Config.GetAttr("tcp_local_to_remote"),
"tcp_remote_to_local": req.Config.GetAttr("tcp_remote_to_local"),

// TODO: Actually populate these
"tcp_to_remote": cty.MapValEmpty(cty.Object(map[string]cty.Type{
"local_host": cty.String,
"local_port": cty.String,
"local": cty.String,
"remote": cty.String,
})),
"tcp_from_remote": cty.MapValEmpty(cty.Object(map[string]cty.Type{
"remote_port": cty.String,
})),
})

return resp
}

func renewEphemeralSSHTunnels(req providers.RenewEphemeralRequest) providers.RenewEphemeralResponse {
// SSH tunnel connections don't need to be explicitly renewed, so this
// should never get called. (The SSH library handles keepalives internally
// itself, without our help.)
return providers.RenewEphemeralResponse{}
}

func closeEphemeralSSHTunnels(req providers.CloseEphemeralRequest) providers.CloseEphemeralResponse {
log.Printf("[TRACE] terraform_ssh_tunnel: closing connection")
var resp providers.CloseEphemeralResponse

intCtx := bytes.NewReader(req.InternalContext)
var connIDInt uint64
if err := binary.Read(intCtx, binary.LittleEndian, &connIDInt); err != nil {
// Should not get here if the client is behaving correctly, because
// we should only get InternalContext values that we returned previously
// from [openEphemeralSSHTunnels].
resp.Diagnostics = resp.Diagnostics.Append(err)
return resp
}
connID := uintptr(connIDInt)

ephemeralSSHTunnels.mu.Lock()
defer ephemeralSSHTunnels.mu.Unlock()

conn, ok := ephemeralSSHTunnels.active[connID]
if !ok {
// Should not get here because client should only pass InternalContext
// values that we returned previously from [openEphemeralSSHTunnels].
resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("trying to close unknown connection %#v", connID))
return resp
}

err := conn.client.Close()
if err != nil {
// Perhaps the connection already got terminated exceptionally before
// we got around to closing it?
resp.Diagnostics = resp.Diagnostics.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Could not close SSH connection",
fmt.Sprintf("Failed to close tunnel SSH connection: %s.", err),
nil,
))
}
// We'll delete it even if we failed to close it, because we're not going
// to get any opportunity to do anything with it again anyway, and it
// seems to be somehow broken.
delete(ephemeralSSHTunnels.active, connID)

return resp
}

func makeEphemeralSSHTunnelClientConfig(configVal cty.Value) (serverAddr string, clientConfig *ssh.ClientConfig, diags tfdiags.Diagnostics) {
clientConfig = &ssh.ClientConfig{}

// FIXME: In a real implementation we ought to constrain this better,
// such as by having the configuration include a set of allowed host
// keys.
clientConfig.HostKeyCallback = ssh.InsecureIgnoreHostKey()

if serverVal := configVal.GetAttr("server"); serverVal.IsKnown() {
serverAddr = serverVal.AsString()
} else {
// FIXME: Terrible error message just for prototype.
// In a real implementation we would hopefully be able to "defer"
// this, but deferred actions is being implemented concurrently with
// this prototype and so this is best to avoid conflicting with that
// other project.
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"SSH server address not known",
"The SSH server address is derived from a value that isn't known yet.",
cty.GetAttrPath("server"),
))
}
if usernameVal := configVal.GetAttr("username"); usernameVal.IsKnown() {
clientConfig.User = usernameVal.AsString()
} else {
// FIXME: Terrible error message just for prototype.
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"SSH username not known",
"The username is derived from a value that isn't known yet.",
cty.GetAttrPath("server"),
))
}

if authMethodsVal := configVal.GetAttr("auth_methods"); authMethodsVal.IsWhollyKnown() {
for it := authMethodsVal.ElementIterator(); it.Next(); {
idx, authMethodObj := it.Element()
if authMethodObj.IsNull() {
continue // FIXME: should probably be an error, actually
}

// The following makes sure that exactly one attribute is set
// and checks which it is. This pattern treats the object type
// as a sum type rather than as a product type.
var attrName string
var attrVal cty.Value
for n := range authMethodObj.Type().AttributeTypes() {
val := authMethodObj.GetAttr(n)
if val.IsNull() {
continue
}
if attrName != "" {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Ambiguous auth method selection",
fmt.Sprintf("Cannot set both %q and %q.", attrName, n),
cty.GetAttrPath("auth_methods").Index(idx),
))
continue
}
attrName = n
attrVal = val
}
if attrName == "" {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"No auth method selection",
"Must set one of the possible attributes to select the auth method type.",
cty.GetAttrPath("auth_methods").Index(idx),
))
continue
}

switch attrName {
case "password":
if attrVal.IsNull() {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Password cannot be null",
"When authenticating using a password, the password must be specified.",
cty.GetAttrPath("auth_methods").Index(idx).GetAttr("password"),
))
continue
}
clientConfig.Auth = append(clientConfig.Auth, ssh.Password(attrVal.AsString()))
}
}
} else {
// FIXME: Terrible error message just for prototype.
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"SSH server auth methods not known",
"The auth_methods structure contains unknown values.",
cty.GetAttrPath("auth_methods"),
))
}

return serverAddr, clientConfig, diags
}
7 changes: 7 additions & 0 deletions internal/builtin/providers/terraform/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func (p *Provider) GetProviderSchema() providers.GetProviderSchemaResponse {
},
EphemeralResourceTypes: map[string]providers.Schema{
"terraform_random_number": ephemeralRandomNumberSchema(),
"terraform_ssh_tunnels": ephemeralSSHTunnelsSchema(),
},
Functions: map[string]providers.FunctionDecl{
"encode_tfvars": {
Expand Down Expand Up @@ -188,6 +189,8 @@ func (p *Provider) OpenEphemeral(req providers.OpenEphemeralRequest) providers.O
switch req.TypeName {
case "terraform_random_number":
return openEphemeralRandomNumber(req)
case "terraform_ssh_tunnels":
return openEphemeralSSHTunnels(req)
default:
// This should not happen
var resp providers.OpenEphemeralResponse
Expand All @@ -201,6 +204,8 @@ func (p *Provider) RenewEphemeral(req providers.RenewEphemeralRequest) providers
switch req.TypeName {
case "terraform_random_number":
return renewEphemeralRandomNumber(req)
case "terraform_ssh_tunnels":
return renewEphemeralSSHTunnels(req)
default:
// This should not happen
var resp providers.RenewEphemeralResponse
Expand All @@ -214,6 +219,8 @@ func (p *Provider) CloseEphemeral(req providers.CloseEphemeralRequest) providers
switch req.TypeName {
case "terraform_random_number":
return closeEphemeralRandomNumber(req)
case "terraform_ssh_tunnels":
return closeEphemeralSSHTunnels(req)
default:
// This should not happen
var resp providers.CloseEphemeralResponse
Expand Down

0 comments on commit fd3dc2a

Please sign in to comment.