Skip to content
This repository has been archived by the owner on Nov 14, 2020. It is now read-only.

Support for connections via SSH bastion host #80

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
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: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ require (
github.com/lib/pq v0.0.0-20170117205633-67c3f2a8884c
github.com/sean-/postgresql-acl v0.0.0-20161225120419-d10489e5d217
github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac // indirect
github.com/xanzy/ssh-agent v0.2.1
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2
golang.org/x/text v0.3.0 // indirect
)
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -242,13 +242,17 @@ github.com/ulikunitz/xz v0.5.4 h1:zATC2OoZ8H1TZll3FpbX+ikwmadbO699PE06cIkm9oU=
github.com/ulikunitz/xz v0.5.4/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8=
github.com/xanzy/ssh-agent v0.2.0 h1:Adglfbi5p9Z0BmK2oKU9nTG+zKfniSfnaMYB+ULd+Ro=
github.com/xanzy/ssh-agent v0.2.0/go.mod h1:0NyE30eGUDliuLEHJgYte/zncp2zdTStcOnWhgSqHD8=
github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70=
github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
github.com/xiang90/probing v0.0.0-20160813154853-07dd2e8dfe18/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xlab/treeprint v0.0.0-20161029104018-1d6e34225557 h1:Jpn2j6wHkC9wJv5iMfJhKqrZJx3TahFx+7sbZ7zQdxs=
github.com/xlab/treeprint v0.0.0-20161029104018-1d6e34225557/go.mod h1:ce1O1j6UtZfjr22oyGxGLbauSBp2YVXpARAosm7dHBg=
github.com/zclconf/go-cty v0.0.0-20180302160414-49fa5e03c418 h1:uZKhc0PzQtIg+6+BqQU1m0zzcIgY2hHJk/Xwf00QUNw=
github.com/zclconf/go-cty v0.0.0-20180302160414-49fa5e03c418/go.mod h1:LnDKxj8gN4aatfXUqmUNooaDjvmDcLPbAN3hYBIVoJE=
golang.org/x/crypto v0.0.0-20180211211603-9de5f2eaf759 h1:6W75OzsrwJByqag5GxxtYVTVEyP+Sy+aLDUsJ9CD8OU=
golang.org/x/crypto v0.0.0-20180211211603-9de5f2eaf759/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2 h1:NwxKRvbkH5MsNkvOtPZi3/3kmI8CAzs3mtv+GLQMkNo=
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/net v0.0.0-20171004034648-a04bdaca5b32 h1:NjAulLPqFTaOxQu5S4qUMqscSu+mQdu+wMY0nfqSkuk=
golang.org/x/net v0.0.0-20171004034648-a04bdaca5b32/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/oauth2 v0.0.0-20170928010508-bb50c06baba3 h1:YGx0PRKSN/2n/OcdFycCC0JUA/Ln+i5lPcN8VoNDus0=
Expand All @@ -257,6 +261,8 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FY
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5 h1:x6r4Jo0KNzOOzYd8lbcRsqjuqEASK6ob3auvWYM4/8U=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0 h1:bzeyCHgoAyjZjAhvTpks+qM7sdlh4cCSitmXeCEO3B4=
golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.0.0-20171013141220-c01e4764d870/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
Expand Down
58 changes: 53 additions & 5 deletions postgresql/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"database/sql"
"fmt"
"golang.org/x/crypto/ssh"
"log"
"strings"
"sync"
Expand Down Expand Up @@ -87,6 +88,15 @@ type Config struct {
ConnectTimeoutSec int
MaxConns int
ExpectedVersion semver.Version
Ssh bool
SshUser string
SshPassword string
SshPrivateKey string
SshHost string
SshHostKey string
SshPort int
SshTimeout int
SshAgent bool
}

// Client struct holding connection string
Expand Down Expand Up @@ -117,17 +127,47 @@ func (c *Config) NewClient(database string) (*Client, error) {
dbRegistryLock.Lock()
defer dbRegistryLock.Unlock()

// TODO add ssh config to connstr
dsn := c.connStr(database)
dbEntry, found := dbRegistry[dsn]
if !found {
db, err := sql.Open("postgres", dsn)
driverName := "postgres"

if c.Ssh {
// Establish connection via an ssh bastion host
driverName = "postgres+ssh"

// Configuration
sshConfig, err := prepareSSHConfig(c)
if err != nil {
return nil, err
}

// TODO can we use the sshConfig.connection approach?

// TODO how to support timeout?

// Connect
sshClient, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", c.SshHost, c.SshPort), sshConfig.config)
if err != nil {
return nil, err
}

// TODO when to close the ssh connection?

// Register postgres+ssh
sql.Register(driverName, &postgresqlSshDriver{
sshClient: sshClient})
}

db, err := sql.Open(driverName, dsn)
if err != nil {
return nil, errwrap.Wrapf("Error connecting to PostgreSQL server: {{err}}", err)
}

// We don't want to retain connection
// So when we connect on a specific database which might be managed by terraform,
// we don't keep opened connection in case of the db has to be dopped in the plan.
// we don't keep opened connection in case of the db has to be dropped in the plan.
db.SetMaxIdleConns(0)
db.SetMaxOpenConns(c.MaxConns)

Expand Down Expand Up @@ -179,7 +219,11 @@ func (c *Config) connStr(database string) string {
"user=%s",
"password=%s",
"sslmode=%s",
"connect_timeout=%d",
}

// connect_timeout is not supported when using ssh tunnel
if !c.Ssh {
dsnFmtParts = append(dsnFmtParts, "connect_timeout=%d")
}

if c.featureSupported(featureFallbackApplicationName) {
Expand Down Expand Up @@ -226,7 +270,9 @@ func (c *Config) connStr(database string) string {
quote(c.Username),
quote("<redacted>"),
quote(c.SSLMode),
c.ConnectTimeoutSec,
}
if !c.Ssh {
logValues = append(logValues, c.ConnectTimeoutSec)
}
if c.featureSupported(featureFallbackApplicationName) {
logValues = append(logValues, quote(c.ApplicationName))
Expand All @@ -245,7 +291,9 @@ func (c *Config) connStr(database string) string {
quote(c.Username),
quote(c.Password),
quote(c.SSLMode),
c.ConnectTimeoutSec,
}
if !c.Ssh {
connValues = append(connValues, c.ConnectTimeoutSec)
}
if c.featureSupported(featureFallbackApplicationName) {
connValues = append(connValues, quote(c.ApplicationName))
Expand Down
88 changes: 86 additions & 2 deletions postgresql/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package postgresql

import (
"fmt"
"time"

"github.com/blang/semver"
"github.com/hashicorp/errwrap"
Expand All @@ -11,7 +12,16 @@ import (

const (
defaultProviderMaxOpenConnections = uint(4)
defaultExpectedPostgreSQLVersion = "9.0.0"

defaultExpectedPostgreSQLVersion = "9.0.0"

defaultSshUser = "root"

// defaultSshPort is used if there is no port given
defaultSshPort = 22

// defaultSshTimeout is used if there is no timeout given
defaultSshTimeout = 5 * time.Minute
)

// Provider returns a terraform.ResourceProvider.
Expand Down Expand Up @@ -49,7 +59,7 @@ func Provider() terraform.ResourceProvider {
Description: "Password to be used if the PostgreSQL server demands password authentication",
Sensitive: true,
},
// Conection username can be different than database username with user name mapas (e.g.: in Azure)
// Connection username can be different than database username with user name maps (e.g.: in Azure)
// See https://www.postgresql.org/docs/current/auth-username-maps.html
"database_username": {
Type: schema.TypeString,
Expand Down Expand Up @@ -97,6 +107,60 @@ func Provider() terraform.ResourceProvider {
Description: "Specify the expected version of PostgreSQL.",
ValidateFunc: validateExpectedVersion,
},
"connection": {
Type: schema.TypeList,
Optional: true,
Description: "", // TODO
MaxItems: 1,
// TODO validate the connection configuration
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"bastion_user": {
Type: schema.TypeString,
Optional: true,
Default: defaultSshUser,
Description: "The user for the connection to the bastion host. Defaults to the value of the user field.",
},
"bastion_password": {
Type: schema.TypeString,
Optional: true,
Description: "",
},
"bastion_private_key": {
Type: schema.TypeString,
Optional: true,
Description: "The contents of an SSH key file to use for the bastion host.",
},
"bastion_host": {
Type: schema.TypeString,
Required: true,
Description: "Setting this enables the bastion Host connection. This host will be connected to first, and then the host connection will be made from there.",
},
"bastion_host_key": {
Type: schema.TypeString,
Optional: true,
Description: "The public key from the remote host or the signing CA, used to verify the host connection.",
},
"bastion_port": {
Type: schema.TypeInt,
Optional: true,
Default: defaultSshPort,
Description: "The port to use connect to the bastion host. Defaults to the value of the port field.",
},
"timeout": {
Type: schema.TypeString,
Optional: true,
Description: "The timeout to wait for the connection to become available. This defaults to 5 minutes.",
},
"agent": {
Type: schema.TypeBool,
Optional: true,
Default: true,
Description: "Set to false to disable using ssh-agent to authenticate.",
},
},
},
},
},

ResourcesMap: map[string]*schema.Resource{
Expand Down Expand Up @@ -162,6 +226,26 @@ func providerConfigure(d *schema.ResourceData) (interface{}, error) {
ExpectedVersion: version,
}

// TODO configure using a hashset?

if conns, ok := d.Get("connection").([]interface{}); ok && len(conns) == 1 {
conn := conns[0].(map[string]interface{})

config.SshUser = conn["bastion_user"].(string)
config.SshPassword = conn["bastion_password"].(string)
config.SshPrivateKey = conn["bastion_private_key"].(string)
config.SshHost = conn["bastion_host"].(string)
config.SshHostKey = conn["bastion_host_key"].(string)
config.SshPort = conn["bastion_port"].(int)

// TODO allow configure timeout (with correct parsing)
//config.Timeout = conn["timeout"].(int)

config.SshAgent = conn["agent"].(bool)

config.Ssh = config.SshHost != ""
}

client, err := config.NewClient(d.Get("database").(string))
if err != nil {
return nil, errwrap.Wrapf("Error initializing PostgreSQL client: {{err}}", err)
Expand Down
76 changes: 72 additions & 4 deletions postgresql/provider_test.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
package postgresql

import (
"os"
"testing"

"github.com/hashicorp/terraform/config"
"github.com/hashicorp/terraform/helper/schema"
"github.com/hashicorp/terraform/terraform"
"os"
"testing"
)

var testAccProviders map[string]terraform.ResourceProvider
var testAccProvider *schema.Provider
var testAccSshProvider *schema.Provider

func init() {
testAccProvider = Provider().(*schema.Provider)
testAccSshProvider = Provider().(*schema.Provider)

testAccProviders = map[string]terraform.ResourceProvider{
"postgresql": testAccProvider,
"postgresql": testAccProvider,
"postgresql+ssh": testAccSshProvider,
}
}

Expand Down Expand Up @@ -42,3 +46,67 @@ func testAccPreCheck(t *testing.T) {
t.Fatal(err)
}
}

func testAccPreCheckSsh(t *testing.T) {
//var host string
//if host = os.Getenv("PGHOST"); host == "" {
// t.Fatal("PGHOST must be set for acceptance tests")
//}

if v := os.Getenv("PGUSER"); v == "" {
t.Fatal("PGUSER must be set for acceptance tests")
}

bastionPrivateKey := `
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
NhAAAAAwEAAQAAAQEAu5wYi5SxCTcmChVWaS34MYV25GC0eyrhPLt54lmBNHmA+088a038
azBMs8/XxYcMpcIqE92UBXuMXRe230leV36t0Qw0n3/eg/OP9ctSCOD1pISjsLFSi5UTp9
dbUlePYbYmbsRm14+3vhGXlOLUc6ApfdO4wn13NSi/zQVrEtlz15GUNWmgFKPfFHNTlQDO
QKKpMgNKeUxbeq1kfrA4b/9PwZyaQcQn84SqNAOYTuy8ZqnC7A6yRJFLfnqxOu6TYUkO3V
/SH7oivh02bCgKMCTi1R8z0qLwz+vn+xRYb3ysDxUzqMSoZ8rLBXjnrF9xv3Uoq0b6bfbK
lmhywslkxwAAA9AXdYZFF3WGRQAAAAdzc2gtcnNhAAABAQC7nBiLlLEJNyYKFVZpLfgxhX
bkYLR7KuE8u3niWYE0eYD7TzxrTfxrMEyzz9fFhwylwioT3ZQFe4xdF7bfSV5Xfq3RDDSf
f96D84/1y1II4PWkhKOwsVKLlROn11tSV49htiZuxGbXj7e+EZeU4tRzoCl907jCfXc1KL
/NBWsS2XPXkZQ1aaAUo98Uc1OVAM5AoqkyA0p5TFt6rWR+sDhv/0/BnJpBxCfzhKo0A5hO
7LxmqcLsDrJEkUt+erE67pNhSQ7dX9IfuiK+HTZsKAowJOLVHzPSovDP6+f7FFhvfKwPFT
OoxKhnyssFeOesX3G/dSirRvpt9sqWaHLCyWTHAAAAAwEAAQAAAQEArrpRjeYc/9UyA2Ae
C3V52z1PHqIGVVP5VGPSv4HWuPWUr/n67oFCXt4sAafIcLo3iEWOhNPwIS8Q6j7E3a5qRB
jCb5jrhcVEiyYTZLtJGuXRQbka7twnYcKk/MOw1L6h1kIcBzu6AHdkjIu73jln3oxDOGIw
iErr9EGQaLTsJS9xXFD7R8opqNNTb7uQHGbDux5TWXSLNRtUhi/m/i+tcPBf+edhT/I0lP
9msEzIxhCjr+1/M9yQgsLIs2pyXYRlkBs2J3ZIo5PuF+SclOC7YudorA8g/KbX1nbTK84Z
MIrvBIjQxcPQ9rGHow2tOy2fQDsErj9H61RrvjkCIWC3KQAAAIALFVGwKDQX82jAOjaLAy
NUCYPYQPJ3XfSITyh/59SOexDkNxY2IpSx1cD6FrJUkqbKAbJ9PgqA18aAauQnaMpIUJ4Y
128iVH7H2AgrQPFNpgbRj7lIsn6Y9W6Uj5PA3jQHepypaT3S+F4HhcD15S7V2bi9olBmYv
2O+fKSNH47KwAAAIEA7uhpTBM9C6CXKoFu2FISKLaJ63gk0Z32ezyRyv2KH2ctJV1psuqd
MiaihieGg+6tXuth/EoMWteagDG+845G/BA4OOMV1vidAf2MhzmkO4XPRGJ3eFIaHhXl5i
mIhcDsJWQbZZ3lInWOyGgP2GmLt/1WlBVa/ueYfnkXeMmYEysAAACBAMkIKV6oPTmGCu8w
CxaFi27NPbGtVlfVUqxTLXUqb4KR6I3xdzNkX89dMUciY3ty0U6KHlyvuZ8NbHOS0DFRAj
kEUUUYbBzkLkM1RxzAWiy+BueGdxNZnPuFqi5qsgguHPhMe0+2H4TlQn9J3pRK5bM3SoK2
j6FW0DcOmqu981bVAAAAGmxla3NlQERvbWluaWtzLU1CUC0yLmxvY2Fs
-----END OPENSSH PRIVATE KEY-----
`

c := map[string]interface{}{
"host": "postgres",
"port": 5432,
"connection": []map[string]interface{}{
{
"bastion_host": "localhost",
"bastion_port": 20022,
"bastion_user": "test",
"bastion_private_key": bastionPrivateKey,
},
},
}

rc, err := config.NewRawConfig(c)
if err != nil {
t.Fatalf("err: %s", err)
}

err = testAccSshProvider.Configure(terraform.NewResourceConfig(rc))
if err != nil {
t.Fatal(err)
}
}
Loading