--- /dev/null
+# See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/
+
+name: Fly Deploy
+on:
+ push:
+ branches:
+ - main
+jobs:
+ deploy:
+ name: Deploy app
+ runs-on: ubuntu-latest
+ concurrency: deploy-group # optional: ensure only one action runs at a time
+ steps:
+ - uses: actions/checkout@v4
+ - uses: superfly/flyctl-actions/setup-flyctl@master
+ - run: flyctl deploy --remote-only
+ env:
+ FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
-FROM golang:1.23-alpine AS builder
+FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
}
computeClient := compute.NewClient(awsCfg, compute.Config{
- AMI: mustEnv("EC2_AMI"),
- InstanceType: getEnv("EC2_INSTANCE_TYPE", "t3.micro"),
- SecurityGroupID: mustEnv("EC2_SECURITY_GROUP"),
- SubnetID: mustEnv("EC2_SUBNET"),
- IAMInstanceProfile: mustEnv("EC2_IAM_PROFILE"),
+ AMI: mustEnv("EC2_AMI"),
+ InstanceType: getEnv("EC2_INSTANCE_TYPE", "t3.micro"),
+ SecurityGroupID: mustEnv("EC2_SECURITY_GROUP"),
+ SubnetID: mustEnv("EC2_SUBNET"),
})
dnsClient := dns.NewClient(awsCfg, dns.Config{
--- /dev/null
+#!/bin/bash
+# deprovision.sh — exports customer data before EC2 is terminated
+# Usage: deprovision.sh <slug> <s3_bucket>
+
+set -euo pipefail
+
+SLUG=$1
+S3_BUCKET=$2
+EXPORT_KEY="exports/$SLUG-$(date +%s).tar.gz"
+CRIMATA_DIR=/opt/crimata
+
+log() { echo "[crimata] $1"; }
+
+# ── 1. Stop all services ───────────────────────────────────────────────────────
+log "Stopping services..."
+systemctl stop crimata-auth crimata-dock || true
+
+for APP in contacts blog; do
+ systemctl stop "crimata-$APP" || true
+done
+
+# ── 2. Dump Postgres ───────────────────────────────────────────────────────────
+log "Dumping database..."
+sudo -u postgres pg_dump crimata > /tmp/crimata.sql
+
+# ── 3. Package data ────────────────────────────────────────────────────────────
+log "Packaging data..."
+tar -czf /tmp/export.tar.gz \
+ /tmp/crimata.sql \
+ $CRIMATA_DIR/data
+
+# ── 4. Upload to S3 ────────────────────────────────────────────────────────────
+log "Uploading to S3..."
+aws s3 cp /tmp/export.tar.gz "s3://$S3_BUCKET/$EXPORT_KEY"
+
+log "Export complete: s3://$S3_BUCKET/$EXPORT_KEY"
+echo "$EXPORT_KEY"
--- /dev/null
+# fly.toml app configuration file generated for infra-shy-sea-1418 on 2026-02-22T13:41:22-05:00
+#
+# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
+#
+
+app = 'infra-shy-sea-1418'
+primary_region = 'iad'
+
+[build]
+
+[http_service]
+ internal_port = 9000
+ force_https = true
+ auto_stop_machines = 'stop'
+ auto_start_machines = true
+ min_machines_running = 1
+ processes = ['app']
+
+ [[http_service.checks]]
+ interval = '15s'
+ timeout = '10s'
+ grace_period = '30s'
+ method = 'GET'
+ path = '/health'
+
+[[vm]]
+ memory = '1gb'
+ cpus = 1
+ memory_mb = 1024
module github.com/adgundersen/crimata-infra
-go 1.23
+go 1.24.0
require (
+ github.com/aws/aws-sdk-go-v2 v1.26.0
github.com/aws/aws-sdk-go-v2/config v1.27.0
github.com/aws/aws-sdk-go-v2/service/ec2 v1.151.0
github.com/aws/aws-sdk-go-v2/service/route53 v1.40.0
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.53.0
github.com/aws/aws-sdk-go-v2/service/ses v1.22.0
github.com/aws/aws-sdk-go-v2/service/ssm v1.49.0
- github.com/aws/aws-sdk-go-v2/service/s3 v1.53.0
github.com/go-chi/chi/v5 v5.1.0
github.com/lib/pq v1.10.9
+ golang.org/x/crypto v0.48.0
+)
+
+require (
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.1 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.17.0 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.0 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.4 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.4 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.4 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.6 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.6 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.4 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.19.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.22.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.27.0 // indirect
+ github.com/aws/smithy-go v1.20.1 // indirect
+ github.com/jmespath/go-jmespath v0.4.0 // indirect
+ golang.org/x/sys v0.41.0 // indirect
)
--- /dev/null
+github.com/aws/aws-sdk-go-v2 v1.26.0 h1:/Ce4OCiM3EkpW7Y+xUnfAFpchU78K7/Ug01sZni9PgA=
+github.com/aws/aws-sdk-go-v2 v1.26.0/go.mod h1:35hUlJVYd+M++iLI3ALmVwMOyRYMmRqUXpTtRGW+K9I=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.1 h1:gTK2uhtAPtFcdRRJilZPx8uJLL2J85xK11nKtWL0wfU=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.1/go.mod h1:sxpLb+nZk7tIfCWChfd+h4QwHNUR57d8hA1cleTkjJo=
+github.com/aws/aws-sdk-go-v2/config v1.27.0 h1:J5sdGCAHuWKIXLeXiqr8II/adSvetkx0qdZwdbXXpb0=
+github.com/aws/aws-sdk-go-v2/config v1.27.0/go.mod h1:cfh8v69nuSUohNFMbIISP2fhmblGmYEOKs5V53HiHnk=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.0 h1:lMW2x6sKBsiAJrpi1doOXqWFyEPoE886DTb1X0wb7So=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.0/go.mod h1:uT41FIH8cCIxOdUYIL0PYyHlL1NoneDuDSCwg5VE/5o=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.0 h1:xWCwjjvVz2ojYTP4kBKUuUh9ZrXfcAXpflhOUUeXg1k=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.0/go.mod h1:j3fACuqXg4oMTQOR2yY7m0NmJY0yBK4L4sLsRXq1Ins=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.4 h1:0ScVK/4qZ8CIW0k8jOeFVsyS/sAiXpYxRBLolMkuLQM=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.4/go.mod h1:84KyjNZdHC6QZW08nfHI6yZgPd+qRgaWcYsyLUo3QY8=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.4 h1:sHmMWWX5E7guWEFQ9SVo6A3S4xpPrWnd77a6y4WM6PU=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.4/go.mod h1:WjpDrhWisWOIoS9n3nk67A3Ll1vfULJ9Kq6h29HTD48=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.4 h1:SIkD6T4zGQ+1YIit22wi37CGNkrE7mXV1vNA5VpI3TI=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.4/go.mod h1:XfeqbsG0HNedNs0GT+ju4Bs+pFAwsrlzcRdMvdNVf5s=
+github.com/aws/aws-sdk-go-v2/service/ec2 v1.151.0 h1:gH571JR1hMfIER4zK457aNjCfi1FCuVwriKx0bAyw/I=
+github.com/aws/aws-sdk-go-v2/service/ec2 v1.151.0/go.mod h1:KNJMjsbzK97hci9ev2Vl/27GgUt3ZciRP4RGujAPF2I=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1 h1:EyBZibRTVAs6ECHZOw5/wlylS9OcTzwyjeQMudmREjE=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1/go.mod h1:JKpmtYhhPs7D97NL/ltqz7yCkERFW5dOlHyVl66ZYF8=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.6 h1:NkHCgg0Ck86c5PTOzBZ0JRccI51suJDg5lgFtxBu1ek=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.6/go.mod h1:mjTpxjC8v4SeINTngrnKFgm2QUi+Jm+etTbCxh8W4uU=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.6 h1:b+E7zIUHMmcB4Dckjpkapoy47W6C9QBv/zoUP+Hn8Kc=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.6/go.mod h1:S2fNV0rxrP78NhPbCZeQgY8H9jdDMeGtwcfZIRxzBqU=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.4 h1:uDj2K47EM1reAYU9jVlQ1M5YENI1u6a/TxJpf6AeOLA=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.4/go.mod h1:XKCODf4RKHppc96c2EZBGV/oCUC7OClxAo2MEyg4pIk=
+github.com/aws/aws-sdk-go-v2/service/route53 v1.40.0 h1:MRriK+ntpKpUc8RwcYJbc5W/eLfRV8MGFTYEcZe/QbU=
+github.com/aws/aws-sdk-go-v2/service/route53 v1.40.0/go.mod h1:n6oZO1BbhPw2X46ObAjn8ol00kujRT+Y+Q9AnbrRUe0=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.53.0 h1:r3o2YsgW9zRcIP3Q0WCmttFVhTuugeKIvT5z9xDspc0=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.53.0/go.mod h1:w2E4f8PUfNtyjfL6Iu+mWI96FGttE03z3UdNcUEC4tA=
+github.com/aws/aws-sdk-go-v2/service/ses v1.22.0 h1:1HJMg3WpV9L003TOhH8eEQgkPnegOwCE3/j0Iqlmt0U=
+github.com/aws/aws-sdk-go-v2/service/ses v1.22.0/go.mod h1:9MbVy83ucnbP+4D9lky3IeUBxjECR2bHq63yzkl+WKU=
+github.com/aws/aws-sdk-go-v2/service/ssm v1.49.0 h1:EtNvvxv0m6aP4cbTyo43vBRXeTpyt8juyNPmgKSTyYs=
+github.com/aws/aws-sdk-go-v2/service/ssm v1.49.0/go.mod h1:wzPAvA+afHPFlAMkCf80sg7bm7GbCuFX1INetlm9DAk=
+github.com/aws/aws-sdk-go-v2/service/sso v1.19.0 h1:u6OkVDxtBPnxPkZ9/63ynEe+8kHbtS5IfaC4PzVxzWM=
+github.com/aws/aws-sdk-go-v2/service/sso v1.19.0/go.mod h1:YqbU3RS/pkDVu+v+Nwxvn0i1WB0HkNWEePWbmODEbbs=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.22.0 h1:6DL0qu5+315wbsAEEmzK+P9leRwNbkp+lGjPC+CEvb8=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.22.0/go.mod h1:olUAyg+FaoFaL/zFaeQQONjOZ9HXoxgvI/c7mQTYz7M=
+github.com/aws/aws-sdk-go-v2/service/sts v1.27.0 h1:cjTRjh700H36MQ8M0LnDn33W3JmwC77mdxIIyPWCdpM=
+github.com/aws/aws-sdk-go-v2/service/sts v1.27.0/go.mod h1:nXfOBMWPokIbOY+Gi7a1psWMSvskUCemZzI+SMB7Akc=
+github.com/aws/smithy-go v1.20.1 h1:4SZlSlMr36UEqC7XOyRVb27XMeZubNcBNN+9IgEPIQw=
+github.com/aws/smithy-go v1.20.1/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
+github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
+github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
+github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
+github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
+golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
func (h *Handler) Routes() http.Handler {
r := chi.NewRouter()
+ r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
r.Post("/customers", h.createCustomer)
r.Get("/customers/{slug}", h.getCustomer)
r.Delete("/customers/{slug}", h.deleteCustomer)
return
}
h.store.UpdateEC2(c.ID, instance.InstanceID, instance.PublicIP)
+ h.store.UpdateSSHKey(c.ID, instance.SSHPrivateKey)
// 2. Wait for instance to be ready
- if err := h.compute.WaitUntilReady(ctx, instance.InstanceID); err != nil {
+ if err := h.compute.WaitUntilReady(ctx, instance.InstanceID, instance.PublicIP); err != nil {
fmt.Printf("provision: wait failed for %s: %v\n", c.Email, err)
h.store.UpdateStatus(c.ID, customer.StatusFailed)
return
}
- // 3. Run provisioning script via SSM
- if err := h.compute.Provision(ctx, instance.InstanceID, c.Slug, password, dbPassword); err != nil {
+ // 3. Run provisioning script via SSH
+ if err := h.compute.Provision(ctx, instance.PublicIP, instance.SSHPrivateKey, c.Slug, password, dbPassword); err != nil {
fmt.Printf("provision: script failed for %s: %v\n", c.Email, err)
h.store.UpdateStatus(c.ID, customer.StatusFailed)
return
package compute
import (
+ "bytes"
"context"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ _ "embed"
+ "encoding/base64"
+ "encoding/pem"
"fmt"
+ "net"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ec2"
ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
- "github.com/aws/aws-sdk-go-v2/service/ssm"
+ "golang.org/x/crypto/ssh"
)
+//go:embed provision.sh
+var provisionScript []byte
+
type Config struct {
- AMI string // Ubuntu 24.04 LTS
- InstanceType string // t3.micro
+ AMI string
+ InstanceType string
SecurityGroupID string
SubnetID string
- IAMInstanceProfile string // must have SSM permissions
- KeyName string
}
type Instance struct {
- InstanceID string
- PublicIP string
+ InstanceID string
+ PublicIP string
+ SSHPrivateKey string // PEM encoded
}
type Client struct {
ec2 *ec2.Client
- ssm *ssm.Client
cfg Config
}
func NewClient(awsCfg aws.Config, cfg Config) *Client {
return &Client{
ec2: ec2.NewFromConfig(awsCfg),
- ssm: ssm.NewFromConfig(awsCfg),
cfg: cfg,
}
}
-// Launch starts a new EC2 instance for a customer and returns its details.
+// Launch starts a new EC2 instance, injecting a generated SSH public key.
func (c *Client) Launch(ctx context.Context, slug string) (*Instance, error) {
+ privateKey, publicKey, err := generateSSHKeyPair()
+ if err != nil {
+ return nil, fmt.Errorf("generate ssh key: %w", err)
+ }
+
+ userData := fmt.Sprintf("#!/bin/bash\nmkdir -p /root/.ssh\necho '%s' >> /root/.ssh/authorized_keys\nchmod 600 /root/.ssh/authorized_keys\n", publicKey)
+
out, err := c.ec2.RunInstances(ctx, &ec2.RunInstancesInput{
- ImageId: aws.String(c.cfg.AMI),
- InstanceType: ec2types.InstanceType(c.cfg.InstanceType),
- MinCount: aws.Int32(1),
- MaxCount: aws.Int32(1),
- IamInstanceProfile: &ec2types.IamInstanceProfileSpecification{
- Name: aws.String(c.cfg.IAMInstanceProfile),
- },
+ ImageId: aws.String(c.cfg.AMI),
+ InstanceType: ec2types.InstanceType(c.cfg.InstanceType),
+ MinCount: aws.Int32(1),
+ MaxCount: aws.Int32(1),
SecurityGroupIds: []string{c.cfg.SecurityGroupID},
SubnetId: aws.String(c.cfg.SubnetID),
+ UserData: aws.String(base64.StdEncoding.EncodeToString([]byte(userData))),
TagSpecifications: []ec2types.TagSpecification{
{
ResourceType: ec2types.ResourceTypeInstance,
Tags: []ec2types.Tag{
- {Key: aws.String("Name"), Value: aws.String("crimata-" + slug)},
- {Key: aws.String("crimata:slug"), Value: aws.String(slug)},
+ {Key: aws.String("Name"), Value: aws.String("crimata-" + slug)},
+ {Key: aws.String("crimata:slug"), Value: aws.String(slug)},
{Key: aws.String("crimata:managed"), Value: aws.String("true")},
},
},
},
- UserData: aws.String(bootstrapScript()),
})
if err != nil {
return nil, fmt.Errorf("run instances: %w", err)
instance := out.Instances[0]
return &Instance{
- InstanceID: aws.ToString(instance.InstanceId),
- PublicIP: aws.ToString(instance.PublicIpAddress),
+ InstanceID: aws.ToString(instance.InstanceId),
+ PublicIP: aws.ToString(instance.PublicIpAddress),
+ SSHPrivateKey: privateKey,
}, nil
}
-// WaitUntilReady waits for the instance to be running and SSM-reachable.
-func (c *Client) WaitUntilReady(ctx context.Context, instanceID string) error {
+// WaitUntilReady waits for the instance to be running and SSH-reachable.
+func (c *Client) WaitUntilReady(ctx context.Context, instanceID, publicIP string) error {
+ // Wait for EC2 running state
waiter := ec2.NewInstanceRunningWaiter(c.ec2)
- return waiter.Wait(ctx, &ec2.DescribeInstancesInput{
+ if err := waiter.Wait(ctx, &ec2.DescribeInstancesInput{
InstanceIds: []string{instanceID},
- }, 5*time.Minute)
+ }, 5*time.Minute); err != nil {
+ return err
+ }
+
+ // Poll until SSH port is open
+ deadline := time.Now().Add(5 * time.Minute)
+ for time.Now().Before(deadline) {
+ conn, err := net.DialTimeout("tcp", publicIP+":22", 5*time.Second)
+ if err == nil {
+ conn.Close()
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(10 * time.Second):
+ }
+ }
+ return fmt.Errorf("timed out waiting for SSH on %s", publicIP)
}
-// Provision runs the crimata provisioning script on the instance via SSM.
-func (c *Client) Provision(ctx context.Context, instanceID, slug, password, dbPassword string) error {
- script := fmt.Sprintf(`
- curl -fsSL https://raw.githubusercontent.com/adgundersen/crimata/main/scripts/provision.sh \
- | bash -s -- %s %s %s
- `, slug, password, dbPassword)
-
- out, err := c.ssm.SendCommand(ctx, &ssm.SendCommandInput{
- InstanceIds: []string{instanceID},
- DocumentName: aws.String("AWS-RunShellScript"),
- Parameters: map[string][]string{
- "commands": {script},
- },
- })
+// Provision runs provision.sh on the instance over SSH.
+func (c *Client) Provision(ctx context.Context, publicIP, privateKeyPEM, slug, password, dbPassword string) error {
+ signer, err := parsePrivateKey(privateKeyPEM)
+ if err != nil {
+ return fmt.Errorf("parse private key: %w", err)
+ }
+
+ config := &ssh.ClientConfig{
+ User: "ubuntu",
+ Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
+ HostKeyCallback: ssh.InsecureIgnoreHostKey(),
+ Timeout: 10 * time.Second,
+ }
+
+ // Retry SSH connection — user data script may still be running
+ var client *ssh.Client
+ deadline := time.Now().Add(2 * time.Minute)
+ for time.Now().Before(deadline) {
+ client, err = ssh.Dial("tcp", publicIP+":22", config)
+ if err == nil {
+ break
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(10 * time.Second):
+ }
+ }
+ if client == nil {
+ return fmt.Errorf("could not connect via SSH: %w", err)
+ }
+ defer client.Close()
+
+ // Upload and run provision.sh
+ session, err := client.NewSession()
if err != nil {
- return fmt.Errorf("send command: %w", err)
+ return fmt.Errorf("new ssh session: %w", err)
}
+ defer session.Close()
- // Wait for command to complete
- commandID := aws.ToString(out.Command.CommandId)
- return c.waitForCommand(ctx, instanceID, commandID)
+ session.Stdin = bytes.NewReader(provisionScript)
+ cmd := fmt.Sprintf("bash -s -- %s %s %s", slug, password, dbPassword)
+ if err := session.Run(cmd); err != nil {
+ return fmt.Errorf("provision script: %w", err)
+ }
+ return nil
}
// Terminate shuts down a customer's EC2 instance.
return err
}
-func (c *Client) waitForCommand(ctx context.Context, instanceID, commandID string) error {
- for {
- select {
- case <-ctx.Done():
- return ctx.Err()
- case <-time.After(10 * time.Second):
- out, err := c.ssm.GetCommandInvocation(ctx, &ssm.GetCommandInvocationInput{
- CommandId: aws.String(commandID),
- InstanceId: aws.String(instanceID),
- })
- if err != nil {
- continue
- }
- switch out.Status {
- case "Success":
- return nil
- case "Failed", "Cancelled", "TimedOut":
- return fmt.Errorf("provisioning script failed: %s", aws.ToString(out.StandardErrorContent))
- }
- }
+func generateSSHKeyPair() (privateKeyPEM string, authorizedKey string, err error) {
+ key, err := rsa.GenerateKey(rand.Reader, 4096)
+ if err != nil {
+ return "", "", err
}
+
+ privPEM := pem.EncodeToMemory(&pem.Block{
+ Type: "RSA PRIVATE KEY",
+ Bytes: x509.MarshalPKCS1PrivateKey(key),
+ })
+
+ pub, err := ssh.NewPublicKey(&key.PublicKey)
+ if err != nil {
+ return "", "", err
+ }
+
+ return string(privPEM), string(ssh.MarshalAuthorizedKey(pub)), nil
}
-// bootstrapScript installs the SSM agent on first boot so we can run commands.
-func bootstrapScript() string {
- return `#!/bin/bash
- snap install amazon-ssm-agent --classic
- systemctl enable snap.amazon-ssm-agent.amazon-ssm-agent.service
- systemctl start snap.amazon-ssm-agent.amazon-ssm-agent.service
- `
+func parsePrivateKey(pemBytes string) (ssh.Signer, error) {
+ return ssh.ParsePrivateKey([]byte(pemBytes))
}
--- /dev/null
+#!/bin/bash
+# provision.sh — v0.1 bootstrap: installs nginx and serves a welcome page
+# Usage: provision.sh <slug> <password> <db_password>
+
+set -euo pipefail
+
+SLUG=$1
+
+log() { echo "[crimata] $1"; }
+
+# ── 1. System dependencies ─────────────────────────────────────────────────────
+log "Installing nginx..."
+apt-get update -qq
+apt-get install -y -qq nginx
+
+# ── 2. Welcome page ────────────────────────────────────────────────────────────
+log "Creating welcome page..."
+mkdir -p /var/www/crimata
+
+cat > /var/www/crimata/index.html << EOF
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Crimata — $SLUG</title>
+ <style>
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: #0a0a0a;
+ color: #fff;
+ height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 12px;
+ }
+ h1 { font-size: 2rem; font-weight: 600; }
+ p { color: #888; font-size: 1rem; }
+ </style>
+</head>
+<body>
+ <h1>Welcome, $SLUG.</h1>
+ <p>Your Crimata hub is ready.</p>
+</body>
+</html>
+EOF
+
+# ── 3. Nginx config ────────────────────────────────────────────────────────────
+log "Configuring nginx..."
+cat > /etc/nginx/sites-available/crimata << EOF
+server {
+ listen 80;
+ server_name $SLUG.crimata.com;
+
+ root /var/www/crimata;
+ index index.html;
+
+ location / {
+ try_files \$uri \$uri/ /index.html;
+ }
+}
+EOF
+
+ln -sf /etc/nginx/sites-available/crimata /etc/nginx/sites-enabled/crimata
+rm -f /etc/nginx/sites-enabled/default
+nginx -t
+systemctl enable nginx
+systemctl restart nginx
+
+log "Provisioning complete. $SLUG.crimata.com is ready."
Slug string
EC2InstanceID string
EC2PublicIP string
+ SSHPrivateKey string
Status Status
CreatedAt time.Time
}
slug TEXT UNIQUE NOT NULL,
ec2_instance_id TEXT NOT NULL DEFAULT '',
ec2_public_ip TEXT NOT NULL DEFAULT '',
+ ssh_private_key TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'provisioning',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
- return err
+ if err != nil {
+ return err
+ }
+ // Add ssh_private_key column if it doesn't exist (migration for existing tables)
+ _, _ = s.db.Exec(`ALTER TABLE customers ADD COLUMN IF NOT EXISTS ssh_private_key TEXT NOT NULL DEFAULT ''`)
+ return nil
}
func (s *Store) Create(c *Customer) error {
c := &Customer{}
err := s.db.QueryRow(`
SELECT id, stripe_customer_id, stripe_subscription_id, email, slug,
- ec2_instance_id, ec2_public_ip, status, created_at
+ ec2_instance_id, ec2_public_ip, ssh_private_key, status, created_at
FROM customers WHERE stripe_customer_id = $1`,
stripeCustomerID,
).Scan(
&c.ID, &c.StripeCustomerID, &c.StripeSubscriptionID,
&c.Email, &c.Slug, &c.EC2InstanceID, &c.EC2PublicIP,
- &c.Status, &c.CreatedAt,
+ &c.SSHPrivateKey, &c.Status, &c.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
c := &Customer{}
err := s.db.QueryRow(`
SELECT id, stripe_customer_id, stripe_subscription_id, email, slug,
- ec2_instance_id, ec2_public_ip, status, created_at
+ ec2_instance_id, ec2_public_ip, ssh_private_key, status, created_at
FROM customers WHERE slug = $1`,
slug,
).Scan(
&c.ID, &c.StripeCustomerID, &c.StripeSubscriptionID,
&c.Email, &c.Slug, &c.EC2InstanceID, &c.EC2PublicIP,
- &c.Status, &c.CreatedAt,
+ &c.SSHPrivateKey, &c.Status, &c.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
return c, err
}
+func (s *Store) UpdateSSHKey(id int64, privateKey string) error {
+ _, err := s.db.Exec(`UPDATE customers SET ssh_private_key = $1 WHERE id = $2`, privateKey, id)
+ return err
+}
+
func (s *Store) UpdateEC2(id int64, instanceID, publicIP string) error {
_, err := s.db.Exec(
`UPDATE customers SET ec2_instance_id = $1, ec2_public_ip = $2 WHERE id = $3`,
--- /dev/null
+#!/bin/bash
+# provision.sh — v0.1 bootstrap: installs nginx and serves a welcome page
+# Usage: provision.sh <slug> <password> <db_password>
+
+set -euo pipefail
+
+SLUG=$1
+
+log() { echo "[crimata] $1"; }
+
+# ── 1. System dependencies ─────────────────────────────────────────────────────
+log "Installing nginx..."
+apt-get update -qq
+apt-get install -y -qq nginx
+
+# ── 2. Welcome page ────────────────────────────────────────────────────────────
+log "Creating welcome page..."
+mkdir -p /var/www/crimata
+
+cat > /var/www/crimata/index.html << EOF
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Crimata — $SLUG</title>
+ <style>
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: #0a0a0a;
+ color: #fff;
+ height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 12px;
+ }
+ h1 { font-size: 2rem; font-weight: 600; }
+ p { color: #888; font-size: 1rem; }
+ </style>
+</head>
+<body>
+ <h1>Welcome, $SLUG.</h1>
+ <p>Your Crimata hub is ready.</p>
+</body>
+</html>
+EOF
+
+# ── 3. Nginx config ────────────────────────────────────────────────────────────
+log "Configuring nginx..."
+cat > /etc/nginx/sites-available/crimata << EOF
+server {
+ listen 80;
+ server_name $SLUG.crimata.com;
+
+ root /var/www/crimata;
+ index index.html;
+
+ location / {
+ try_files \$uri \$uri/ /index.html;
+ }
+}
+EOF
+
+ln -sf /etc/nginx/sites-available/crimata /etc/nginx/sites-enabled/crimata
+rm -f /etc/nginx/sites-enabled/default
+nginx -t
+systemctl enable nginx
+systemctl restart nginx
+
+log "Provisioning complete. $SLUG.crimata.com is ready."
--- /dev/null
+.terraform/
+terraform.tfvars
+*.tfstate
+*.tfstate.backup
--- /dev/null
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/aws" {
+ version = "5.100.0"
+ constraints = "~> 5.0"
+ hashes = [
+ "h1:hd45qFU5cFuJMpFGdUniU9mVIr5LYVWP1uMeunBpYYs=",
+ "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
+ "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
+ "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
+ "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
+ "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
+ "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
+ "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
+ "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
+ "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
+ "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
+ "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
+ "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
+ "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
+ "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
+ "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
+ ]
+}
--- /dev/null
+terraform {
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 5.0"
+ }
+ }
+}
+
+provider "aws" {
+ region = var.aws_region
+}
+
+# ── Data: default VPC ──────────────────────────────────────────────────────────
+data "aws_vpc" "default" {
+ default = true
+}
+
+data "aws_subnets" "default" {
+ filter {
+ name = "vpc-id"
+ values = [data.aws_vpc.default.id]
+ }
+}
+
+# ── Security group: customer EC2 instances ─────────────────────────────────────
+resource "aws_security_group" "customer_ec2" {
+ name = "customer"
+ description = "Customer EC2 instances"
+ vpc_id = data.aws_vpc.default.id
+
+ ingress {
+ from_port = 22
+ to_port = 22
+ protocol = "tcp"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+
+ ingress {
+ from_port = 80
+ to_port = 80
+ protocol = "tcp"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+
+ ingress {
+ from_port = 443
+ to_port = 443
+ protocol = "tcp"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+
+ egress {
+ from_port = 0
+ to_port = 0
+ protocol = "-1"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+}
+
+# ── IAM role for customer EC2 instances (SSM access) ──────────────────────────
+resource "aws_iam_role" "crimata_ec2" {
+ name = "crimata-ec2"
+
+ assume_role_policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Principal = { Service = "ec2.amazonaws.com" }
+ Action = "sts:AssumeRole"
+ }]
+ })
+}
+
+resource "aws_iam_role_policy_attachment" "ssm" {
+ role = aws_iam_role.crimata_ec2.name
+ policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
+}
+
+resource "aws_iam_role_policy_attachment" "s3" {
+ role = aws_iam_role.crimata_ec2.name
+ policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
+}
+
+resource "aws_iam_instance_profile" "crimata_ec2" {
+ name = "crimata-ec2-profile"
+ role = aws_iam_role.crimata_ec2.name
+}
+
+# ── S3 bucket for data exports ─────────────────────────────────────────────────
+resource "aws_s3_bucket" "exports" {
+ bucket = var.s3_export_bucket
+}
+
+resource "aws_s3_bucket_lifecycle_configuration" "exports" {
+ bucket = aws_s3_bucket.exports.id
+
+ rule {
+ id = "expire-exports"
+ status = "Enabled"
+
+ filter {}
+
+ expiration {
+ days = 30
+ }
+ }
+}
+
+# ── Security group: RDS ────────────────────────────────────────────────────────
+resource "aws_security_group" "rds" {
+ name = "crimata-rds"
+ description = "Allow Postgres from infra service"
+ vpc_id = data.aws_vpc.default.id
+
+ ingress {
+ description = "Postgres from infra service"
+ from_port = 5432
+ to_port = 5432
+ protocol = "tcp"
+ cidr_blocks = ["${var.infra_service_ip}/32"]
+ }
+
+ egress {
+ from_port = 0
+ to_port = 0
+ protocol = "-1"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+}
+
+# ── RDS subnet group ───────────────────────────────────────────────────────────
+resource "aws_db_subnet_group" "crimata" {
+ name = "crimata"
+ subnet_ids = data.aws_subnets.default.ids
+}
+
+# ── RDS instance ───────────────────────────────────────────────────────────────
+resource "aws_db_instance" "crimata" {
+ identifier = "crimata"
+ engine = "postgres"
+ engine_version = "16"
+ instance_class = "db.t3.micro"
+ allocated_storage = 20
+ storage_type = "gp2"
+
+ db_name = "crimata"
+ username = "crimata"
+ password = var.db_password
+
+ db_subnet_group_name = aws_db_subnet_group.crimata.name
+ vpc_security_group_ids = [aws_security_group.rds.id]
+
+ publicly_accessible = true
+ skip_final_snapshot = false
+ final_snapshot_identifier = "crimata-final"
+ deletion_protection = true
+
+ backup_retention_period = 0
+ maintenance_window = "Mon:04:00-Mon:05:00"
+}
--- /dev/null
+output "customer_security_group_id" {
+ description = "Security group ID for customer EC2 instances"
+ value = aws_security_group.customer_ec2.id
+}
+
+output "ec2_iam_instance_profile" {
+ description = "IAM instance profile name for customer EC2 instances"
+ value = aws_iam_instance_profile.crimata_ec2.name
+}
+
+output "s3_export_bucket" {
+ description = "S3 bucket for customer data exports"
+ value = aws_s3_bucket.exports.bucket
+}
+
+output "rds_endpoint" {
+ description = "RDS connection endpoint (host:port)"
+ value = aws_db_instance.crimata.endpoint
+}
+
+output "database_url" {
+ description = "Full DATABASE_URL for the infra service"
+ value = "postgresql://crimata:${var.db_password}@${aws_db_instance.crimata.endpoint}/crimata"
+ sensitive = true
+}
--- /dev/null
+aws_region = "us-east-1"
+db_password = "..."
+infra_service_ip = "..." # your DO droplet's public IP
--- /dev/null
+variable "aws_region" {
+ description = "AWS region"
+ type = string
+ default = "us-east-1"
+}
+
+variable "db_password" {
+ description = "RDS master password"
+ type = string
+ sensitive = true
+}
+
+variable "infra_service_ip" {
+ description = "Public IP of the infra service (Fly.io) allowed to reach RDS"
+ type = string
+}
+
+variable "s3_export_bucket" {
+ description = "S3 bucket name for customer data exports"
+ type = string
+ default = "crimata-exports"
+}