Skip to content

Commit

Permalink
feat(*) first working version
Browse files Browse the repository at this point in the history
Signed-off-by: Rodrigo Chacon <[email protected]>
  • Loading branch information
rochacon committed Dec 30, 2018
1 parent 6b67a90 commit 31009f5
Show file tree
Hide file tree
Showing 22 changed files with 1,290 additions and 1 deletion.
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.terraform
*tfstate*
*tfvars
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.terraform/
*.tfstate
*.tfstate*
*.tfvars
bastrd
bastrd.gz
tmp/
14 changes: 14 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FROM golang:1.11-alpine as build
ENV GO111MODULE=on
RUN apk add --no-cache ca-certificates git
COPY go.mod /go/src/github.com/rochacon/bastrd/go.mod
COPY go.sum /go/src/github.com/rochacon/bastrd/go.sum
WORKDIR /go/src/github.com/rochacon/bastrd
RUN go mod download
COPY . /go/src/github.com/rochacon/bastrd
RUN go install -v -ldflags "-X main.VERSION=$(git describe --abbrev=10 --always --dirty --tags)" -tags "netgo osusergo"

FROM alpine
COPY --from=build /etc/ssl/certs /etc/ssl/certs
COPY --from=build /go/bin/bastrd /bastrd
ENTRYPOINT ["/bastrd"]
6 changes: 6 additions & 0 deletions Dockerfile.toolbox
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
FROM alpine
RUN adduser -D -s /bin/sh user
RUN apk add --no-cache ca-certificates curl git groff httpie jq py3-pip openssh-client tmux vim \
&& pip3 install awscli
USER user
WORKDIR /home/user
33 changes: 33 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
CONTAINER_IMAGE ?= "rochacon/bastrd"
CONTAINER_IMAGE_TOOLBOX ?= "rochacon/bastrd-toolbox"
VERSION ?= $$(git describe --abbrev=10 --always --dirty --tags)

default: binary

all: test binary image toolbox publish_binary publish_image publish_toolbox

binary:
go build -v -ldflags "-X main.VERSION=$(VERSION)" -tags "netgo osusergo"

image:
docker build -t $(CONTAINER_IMAGE):$(VERSION) .

publish: publish_binary publish_image publish_image_toolbox

publish_binary:
gzip -f bastrd
aws s3 cp ./bastrd.gz s3://bastrd-dev/bastrd.gz --acl public-read

publish_image:
docker push $(CONTAINER_IMAGE):$(VERSION)

publish_toolbox:
docker push $(CONTAINER_IMAGE_TOOLBOX):$(VERSION)

test:
go test $(ARGS) ./...

toolbox:
docker build -f Dockerfile.toolbox -t $(CONTAINER_IMAGE_TOOLBOX):$(VERSION) .

.PHONY: binary image publish publish_binary publish_image publish_toolbox test toolbox
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# bastrd - bastion server for secure environments

`bastrd` builds on top of the ideas behind [keymaker](https://github.com/kislyuk/keymaker/) and [toolbox](https://github.com/coreos/toolbox) to build a secure shared bastion server for restricted environments.

:warning: `bastrd` is in early development stage

## How does it work?

`bastrd` has 3 components:

1. `bastrd sync`, an agent to sync AWS IAM groups and users to Linux
1. `bastrd authorized-keys`, SSH authorized keys command to authenticate the user login against AWS IAM registered SSH Public Keys and groups
1. `bastrd toolbox`, a session wrapper for a customizable toolbox container, the user must provide an AWS IAM account MFA token for authentication and setup of the session scoped credentials.

## Toolbox features

The toolbox container has the following features:

* Validates MFA against user's AWS IAM MFA device
* Create temporary user session AWS credentials
* Mount temporary credentials as `/home/user/.aws/` using a tmpfs mount
* Customizable session container image for advanced tools, check `Dockerfile.toolbox` for the default settings
* Session resuming, for easier recovery of connections issues
* SSH-agent forwarding (note: doesn't work on session resuming)
* Firewall rule to block containers from hijacking the AWS EC2 instance profile used by bastrd itself
* Reduced container capabilities for improved security, e.g., no socket binding

## Installing on AWS with Terraform

This repository was configured to be used as a quick way to create a `bastrd` instance on your AWS environment, fork it and customize as necessary.

1. Clone this repo
1. Configure `main.tf` with your state and `terrraform.tfvars` for your desired settings and run `terraform init`
1. Run `terraform apply` to bootstrap the CoreOS instance and setup required AWS IAM groups
1. Now wait a few minutes while your instance starts and connect to it via `ssh -A my-iam-username@$(terraform output)`

## Uninstall

1. `terraform destroy` to remove instance and related resources
110 changes: 110 additions & 0 deletions cmd/authorized_keys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package cmd

import (
"fmt"
"log"
"strings"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/urfave/cli"
)

var AuthorizedKeys = cli.Command{
Name: "authorized-keys",
Usage: "List AWS IAM user registered SSH public keys.",
ArgsUsage: "username",
Action: getAuthorizedKeysForUser,
Aliases: []string{"authorized_keys"},
Flags: []cli.Flag{
cli.StringSliceFlag{
Name: "allowed-groups",
Usage: "Comma separated list of AWS IAM Groups allowed to SSH. (defaults to bastrd)",
},
},
}

// getAuthorizedKeysForUser retrieves
func getAuthorizedKeysForUser(ctx *cli.Context) error {
username := ctx.Args().Get(0)
if username == "" {
return fmt.Errorf("Username argument is required.")
}
allowedGroups := ctx.StringSlice("allowed-groups")
if len(allowedGroups) == 0 {
allowedGroups = append(allowedGroups, "bastrd")
}

awsSession := session.Must(session.NewSession(&aws.Config{}))
iamSvc := iam.New(awsSession)

if !userBelongsToAllowedGroups(iamSvc, username, allowedGroups) {
return fmt.Errorf("User %q is not allowed to SSH into this instance, this incident will be reported.", username)
}

keys, err := getUserSSHPublicKeys(iamSvc, username)
if err != nil {
return fmt.Errorf("Error while retrieving user SSH public keys for user %q: %s", username, err)
}
if len(keys) == 0 {
return fmt.Errorf("Found no SSH public keys for user %q.", username)
}
fmt.Println(strings.Join(keys, "\n"))
return nil
}

// getUserSSHPublicKeys retrieves AWS IAM user SSH public keys
func getUserSSHPublicKeys(iamSvc awsIAM, username string) ([]string, error) {
keys := []string{}
sshKeys, err := iamSvc.ListSSHPublicKeys(&iam.ListSSHPublicKeysInput{
UserName: aws.String(username),
})
if err != nil {
return keys, err
}
for _, key := range sshKeys.SSHPublicKeys {
if *key.Status != iam.StatusTypeActive {
log.Printf("authorized-keys: skipping key %q, status %q", *key.SSHPublicKeyId, *key.Status)
continue
}
k, err := iamSvc.GetSSHPublicKey(&iam.GetSSHPublicKeyInput{
Encoding: aws.String(iam.EncodingTypeSsh),
SSHPublicKeyId: key.SSHPublicKeyId,
UserName: aws.String(username),
})
if err != nil {
return keys, err
}
keys = append(keys, *k.SSHPublicKey.SSHPublicKeyBody)
}
return keys, nil
}

// userBelongsToAllowedGroups checks wether user is a member of SSH allowed groups
func userBelongsToAllowedGroups(iamSvc awsIAM, username string, allowedGroups []string) bool {
userGroups, err := iamSvc.ListGroupsForUser(&iam.ListGroupsForUserInput{
UserName: aws.String(username),
})
if err != nil {
log.Println("authorized-keys: iam.ListGroupsForUser returned error:", err)
return false
}
for _, group := range userGroups.Groups {
if stringIn(*group.GroupName, allowedGroups) {
// log.Printf("authorized-keys: user %q belongs to allowed group %q", username, *group.GroupName)
return true
}
}
return false
}

// stringIn matches if a string exist in a string slice
func stringIn(s string, ss []string) bool {
for _, item := range ss {
if s == item {
return true
}
}
return false
}
22 changes: 22 additions & 0 deletions cmd/aws.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package cmd

import (
"github.com/aws/aws-sdk-go/service/iam"
"github.com/aws/aws-sdk-go/service/sts"
)

// awsIAM interface holds required method signatures of IAM for easier test mocking
type awsIAM interface {
CreateAccessKey(input *iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error)
DeleteAccessKey(input *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error)
GetSSHPublicKey(input *iam.GetSSHPublicKeyInput) (*iam.GetSSHPublicKeyOutput, error)
ListGroupsForUser(input *iam.ListGroupsForUserInput) (*iam.ListGroupsForUserOutput, error)
ListSSHPublicKeys(input *iam.ListSSHPublicKeysInput) (*iam.ListSSHPublicKeysOutput, error)
}

// awsSTS interface holds required method signatures of STS for easier test mocking
type awsSTS interface {
AssumeRole(input *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error)
GetCallerIdentity(input *sts.GetCallerIdentityInput) (*sts.GetCallerIdentityOutput, error)
GetSessionToken(input *sts.GetSessionTokenInput) (*sts.GetSessionTokenOutput, error)
}
133 changes: 133 additions & 0 deletions cmd/sync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package cmd

import (
"fmt"
"log"
"os"
"os/signal"
"strings"
"time"

"github.com/rochacon/bastrd/pkg/user"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/urfave/cli"
)

var Sync = cli.Command{
Name: "sync",
Usage: "Sync AWS IAM users.",
Action: syncMain,
Aliases: []string{"sync-users", "sync_users"},
Flags: []cli.Flag{
cli.BoolFlag{
Name: "disable-sandbox",
Usage: "Disable users sandboxed sessions.",
},
cli.StringSliceFlag{
Name: "groups",
Usage: "AWS IAM group names to be synced. ATTENTION: Make sure these groups names don't conflict with existent system groups.",
},
cli.DurationFlag{
Name: "interval",
Usage: "Time interval between sync loops.",
},
},
}

func syncMain(ctx *cli.Context) error {
groups := ctx.StringSlice("groups")
if len(groups) == 0 {
return fmt.Errorf("You must provide at least 1 AWS IAM group name.")
}
interval := ctx.Duration("interval")
if interval.Minutes() == 0.0 && interval.Seconds() == 0.0 {
log.Println("Defaulting interval to 1m")
interval = time.Second * 60
}
quit := make(chan os.Signal)
signal.Notify(quit, os.Interrupt)
log.Println("Executing initial sync")
err := syncGroupsUsers(ctx)
if err != nil {
log.Printf("initial sync failed: %s", err)
}
log.Printf("Initiating sync loop for groups: %s", strings.Join(groups, ", "))
for {
select {
case <-time.After(interval):
log.Printf("Starting sync")
err = syncGroupsUsers(ctx)
if err != nil {
return err
}
log.Printf("Finished sync")
case <-quit:
log.Println("Received SIGINT, quitting.")
return nil
}
}
}

// syncGroupsUsers synchronizes users from AWS IAM
func syncGroupsUsers(ctx *cli.Context) error {
isSandboxed := ctx.Bool("disable-sandbox") == false
groupNames := ctx.StringSlice("groups")
groups := []*user.Group{}
for _, name := range groupNames {
groups = append(groups, &user.Group{Name: name})
}

awsSession := session.Must(session.NewSession(&aws.Config{}))
iamSvc := iam.New(awsSession)

iamUsers, err := user.FromIAMGroups(iamSvc, groups...)
if err != nil {
return fmt.Errorf("failed to retrieve AWS IAM users list: %s", err)
}
sysUsers, err := user.FromSystemGroups(groups...)
if err != nil {
return fmt.Errorf("failed to retrieve system users list: %s", err)
}

// Ensure groups in the system
for _, group := range groups {
log.Printf("Ensuring group %q", group.Name)
err = group.Ensure()
if err != nil {
log.Printf("Failed to ensure group %q in the system: %s", group.Name, err)
continue
}
}

// create AWS IAM users that do not exist in the system
for _, u := range iamUsers.Diff(sysUsers) {
log.Printf("Ensuring user %q", u.Username)
err = u.Ensure(isSandboxed)
if err != nil {
log.Printf("Failed to ensure user %q in the system: %s", u.Username, err)
continue
}
for _, g := range u.Groups {
log.Printf("Ensuring user %q in group %q", u.Username, g.Name)
err = g.EnsureUser(u)
if err != nil {
log.Printf("Failed to ensure user %q in the system group %q: %s", u.Username, g.Name, err)
continue
}
}
}

// remove system users that aren't on AWS IAM anymore
for _, u := range sysUsers.Diff(iamUsers) {
log.Printf("Removing user %q from the system", u.Username)
err = u.Remove()
if err != nil {
log.Printf("Failed to remove user %q from the system: %s", u.Username, err)
continue
}
}
return err
}
Loading

0 comments on commit 31009f5

Please sign in to comment.