feat(infra): add HCP Terraform for the prod file share (PLAT-77)

The prod host will live on a subnet in the syslog VPC. The data volume stays unmanaged and is attached only after a snapshot copy.
This commit is contained in:
Adam Moussa 2026-09-28 16:38:03 -04:00
parent e4f156bb98
commit 7cb3f6510f
No known key found for this signature in database
16 changed files with 1158 additions and 7 deletions

View file

@ -13,3 +13,29 @@ jobs:
with:
node-version: "24"
run-tests: true
terraform:
name: Terraform
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: terraform
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.16.0"
terraform_wrapper: false
- name: Terraform fmt
run: terraform fmt -check -recursive
- name: Terraform init
run: terraform init -backend=false
- name: Terraform validate
run: terraform validate

6
.gitignore vendored
View file

@ -4,3 +4,9 @@ cdk.out/
*.d.ts
*.js.map
.env
.terraform/
*.tfstate
*.tfstate.*
crash.log
override.tf
override.tf.json

View file

@ -6,9 +6,14 @@
Personal file share server on AWS — Samba for macOS Finder integration and FileBrowser for web-based file management. Accessible exclusively over the site-to-site VPN.
## Infrastructure (CDK)
## Infrastructure
All infrastructure is defined as code with the [AWS CDK](https://docs.aws.amazon.com/cdk/) (TypeScript). The app synthesizes a single CloudFormation stack — `file-share` — that provisions everything described under [Architecture](#architecture), deployed to account `328440206208` in `us-east-1`.
The live share is still the management-account CDK stack until cutover proof. The prod replacement is HCP Terraform in `terraform/`, workspace `file-share-prod`, trigger `terraform/**`. CDK deploy on push to main is frozen.
| Path | Role |
|---|---|
| `terraform/` | Prod EC2, subnet in the syslog VPC, DLM, and HCP roles |
| `lib/file-share-stack.ts` | Management-account CDK stack, still the live path until decommission |
```
bin/app.ts # CDK app entry point — instantiates the stack
@ -80,12 +85,11 @@ aws secretsmanager create-secret --name file-share/filebrowser-password --secret
## Deploy
```bash
npm install
npx cdk deploy
```
Prod changes go through HCP Terraform workspace `file-share-prod` (manual apply until the move is sealed). The bootstrap apply creates the HCP roles and the instance boundary. The following apply, using `hcptf-file-share`, creates the subnet, security group, and DLM policy. `data_volume_id` stays empty until the snapshot copy exists, so those applies do not boot an instance.
The stack outputs the instance's private IP for SMB and FileBrowser access.
The management-account CDK workflow no longer runs on push. `workflow_dispatch` remains for an explicit rollback of that stack.
Clients use the private IP. Office routing must include `10.40.20.0/24` on the existing syslog IPsec before SMB from the office will work. A check from `10.10.70.0/24` on 2026-09-28 reached the gateway for `10.40.10.254` and got no hop-1 reply for `10.40.20.1`.
## Expanding Storage

26
terraform/.terraform.lock.hcl generated Normal file
View file

@ -0,0 +1,26 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "6.66.0"
constraints = "~> 6.64"
hashes = [
"h1:OnLj4nhqJnEcUzyyRKUjp1FgWG00Y8maikJEYSf9Zjw=",
"zh:156fe7164a3d26ef6b35734c43e99fb198df90575ed897d1182b8e930b8cd523",
"zh:1af52b22b35be00f8d16e3ebebff9fa699ec4db2ef69e6032ba5c536f80c03d9",
"zh:2545a8478bd551fdc9694f6cc1a1ad24617f6736f8bde0ad6cae90987c65380f",
"zh:4070db1ee369ccb41cb610bfd887386bc0a9b9ecad60aeb4dbce58443d2519dd",
"zh:53da7d3c1840ef875c7d34e967732502a64fe677af0e78824773d4c15a8fe740",
"zh:576a93a28bf611a4de2a2e6ced697a41d5126b8fd31d30782b16797e410a9706",
"zh:58fed5fa9a033355b9d4f3092c817b70d934100e0d8678d6e4c93f3c9493d4e4",
"zh:6a9ca2f24e2ee9156dd785d159a850b35d190e9cf7eca21cb9582970c2db80cd",
"zh:729edd30f99cc16009deba5c013265b0c81eda261a3d0821cbd011d3287fd230",
"zh:7ae460049b75bd4aefee465ef7c53a01ac2df46d4d3e3ac00824afa8b5cb83fb",
"zh:9051fa85c8034ade8a57a5c6f232fd33da28f3800bb5aa40bc8625dbc5e27632",
"zh:906547e4319805e7acf7fbdf2bac28a4b1a7370790a2a430c7adb1b29bb934eb",
"zh:998f27410a66158a35ee5ed142c27e5b21fe8601941da55da2157f8042d6dcca",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:9c1804eff1dda0446dc2d215231015bb65a2fc6c3b7ba24584fe45f1ddd3fa9f",
"zh:b03ff5efdee310502aaaeb460144dc059bce72a0d8217e6b989099ef8aef9283",
]
}

29
terraform/dlm.tf Normal file
View file

@ -0,0 +1,29 @@
resource "aws_dlm_lifecycle_policy" "nightly" {
description = "Nightly EBS snapshots for file share"
execution_role_arn = local.dlm_service_role_arn
state = "ENABLED"
policy_details {
resource_types = ["INSTANCE"]
target_tags = {
"file-share-backup" = "true"
}
schedule {
name = "file-share-nightly"
create_rule {
interval = 24
interval_unit = "HOURS"
times = ["06:00"]
}
retain_rule {
count = 30
}
copy_tags = true
}
}
}

36
terraform/ec2.tf Normal file
View file

@ -0,0 +1,36 @@
resource "aws_instance" "this" {
count = local.create_instance ? 1 : 0
ami = var.ami_id
instance_type = "t4g.small"
subnet_id = aws_subnet.file_share.id
vpc_security_group_ids = [aws_security_group.file_share.id]
iam_instance_profile = aws_iam_instance_profile.this.name
associate_public_ip_address = true
user_data = local.user_data
user_data_replace_on_change = false
root_block_device {
volume_size = 20
volume_type = "gp3"
encrypted = true
}
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
}
tags = {
Name = "file-share"
"file-share-backup" = "true"
}
}
resource "aws_volume_attachment" "data" {
count = local.create_instance ? 1 : 0
device_name = "/dev/xvdf"
volume_id = var.data_volume_id
instance_id = aws_instance.this[0].id
}

513
terraform/hcp_iam.tf Normal file
View file

@ -0,0 +1,513 @@
# HCP plan/apply roles for file-share-prod (PLAT-77).
# Copy of the syslog-server EC2 shape, narrowed to this stack.
# Create, do not import.
#
# Live seahaven-hcptf-iam-management DenySelfMutation blocks DetachRolePolicy
# and PutRolePolicy on hcptf-* (including this role). First-apply sequence:
# 1. seahaven-org-baseline scripts/create-hcptf-bootstrap-roles.sh
# --account prod --allow-workspace file-share-prod
# 2. Point this workspace's TFC_AWS_* at hcptf-bootstrap /
# hcptf-bootstrap-plan (workspace vars, never a project set).
# 3. One Manual apply. Bootstrap can create these roles and the boundary.
# Subnet, DLM, and the instance are created on the following apply
# after TFC_AWS_* points at hcptf-file-share. Tolerate that partial
# state.
# 4. Point TFC_AWS_* back at hcptf-file-share / hcptf-file-share-plan.
# 5. Re-run the create script without --allow-workspace to pin trust
# back to iam-bootstrap-prod only.
# Later apply-role IAM edits use the same window. Do not add StringLike
# on bootstrap trust. CreatePolicy stays on hcptf-bootstrap only.
data "aws_iam_policy_document" "hcptf_apply_trust" {
statement {
sid = "HcpApply"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/app.terraform.io"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:aud"
values = ["aws.workload.identity"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = [
"organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:apply",
]
}
}
}
data "aws_iam_policy_document" "hcptf_plan_trust" {
statement {
sid = "HcpPlan"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/app.terraform.io"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:aud"
values = ["aws.workload.identity"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = [
"organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:plan",
]
}
}
}
data "aws_iam_policy_document" "hcptf_scoped_iam" {
statement {
sid = "DenyCreatePolicy"
effect = "Deny"
actions = [
"iam:CreatePolicy",
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion",
]
resources = ["*"]
}
statement {
sid = "CreateExecRoleWithBoundary"
effect = "Allow"
actions = ["iam:CreateRole"]
resources = [
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/tf-managed/${local.stack_prefix}*",
]
condition {
test = "StringLike"
variable = "iam:PermissionsBoundary"
values = [
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/tf-managed/${local.stack_prefix}*",
]
}
}
statement {
sid = "MutateExecRoleWithBoundary"
effect = "Allow"
actions = [
"iam:AttachRolePolicy",
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary",
]
resources = [
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/tf-managed/${local.stack_prefix}*",
]
condition {
test = "StringLike"
variable = "iam:PermissionsBoundary"
values = [
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/tf-managed/${local.stack_prefix}*",
]
}
}
statement {
sid = "WriteExecRoles"
effect = "Allow"
actions = [
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DetachRolePolicy",
"iam:TagRole",
"iam:UntagRole",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
]
resources = [
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/tf-managed/${local.stack_prefix}*",
]
}
statement {
sid = "PassExecRoleToEc2"
effect = "Allow"
actions = ["iam:PassRole"]
resources = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/tf-managed/${local.instance_role_name}"]
condition {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["ec2.amazonaws.com"]
}
}
statement {
sid = "PassDlmServiceRole"
effect = "Allow"
actions = ["iam:PassRole"]
resources = [local.dlm_service_role_arn]
condition {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["dlm.amazonaws.com"]
}
}
statement {
sid = "CreateDlmServiceLinkedRole"
effect = "Allow"
actions = [
"iam:CreateServiceLinkedRole",
]
resources = [local.dlm_service_role_arn]
condition {
test = "StringEquals"
variable = "iam:AWSServiceName"
values = ["dlm.amazonaws.com"]
}
}
statement {
sid = "InstanceProfiles"
effect = "Allow"
actions = [
"iam:AddRoleToInstanceProfile",
"iam:CreateInstanceProfile",
"iam:DeleteInstanceProfile",
"iam:GetInstanceProfile",
"iam:ListInstanceProfileTags",
"iam:RemoveRoleFromInstanceProfile",
"iam:TagInstanceProfile",
"iam:UntagInstanceProfile",
]
resources = [
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:instance-profile/tf-managed/${local.instance_profile_name}",
]
}
statement {
sid = "IamReadOnly"
effect = "Allow"
actions = [
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:GetInstanceProfile",
"iam:ListAttachedRolePolicies",
"iam:ListInstanceProfiles",
"iam:ListInstanceProfilesForRole",
"iam:ListPolicies",
"iam:ListPolicyVersions",
"iam:ListRolePolicies",
"iam:ListRoleTags",
"iam:ListRoles",
]
resources = ["*"]
}
statement {
sid = "DenySelfMutation"
effect = "Deny"
actions = [
"iam:AttachRolePolicy",
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DeleteRolePermissionsBoundary",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
]
resources = [
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/hcptf-*",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/github-cfn-execution-role",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/githubdeploy-*",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/cdk-hnb659fds-*",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/OrganizationAccountAccessRole",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/seahaven-*",
]
}
statement {
sid = "DenyBoundaryTampering"
effect = "Deny"
actions = [
"iam:DeleteRolePermissionsBoundary",
"iam:DeleteUserPermissionsBoundary",
]
resources = [
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/*",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:user/*",
]
}
statement {
sid = "DenyBoundaryPolicyEdit"
effect = "Deny"
actions = [
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion",
]
resources = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/seahaven-*"]
}
statement {
sid = "ReadTfManagedBoundary"
effect = "Allow"
actions = [
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:ListPolicyVersions",
"iam:ListPolicyTags",
"iam:TagPolicy",
"iam:UntagPolicy",
]
resources = [
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/tf-managed/${local.stack_prefix}*",
]
}
}
data "aws_iam_policy_document" "hcptf_apply_services" {
# checkov:skip=CKV_AWS_111: EC2 describe and RunInstances APIs require Resource=*. This role cannot CreateVolume or DeleteVolume.
statement {
sid = "Ec2Network"
effect = "Allow"
actions = [
"ec2:AssociateRouteTable",
"ec2:AuthorizeSecurityGroupEgress",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:CreateSecurityGroup",
"ec2:CreateSubnet",
"ec2:CreateTags",
"ec2:DeleteSecurityGroup",
"ec2:DeleteSubnet",
"ec2:DeleteTags",
"ec2:DescribeAccountAttributes",
"ec2:DescribeAvailabilityZones",
"ec2:DescribeImages",
"ec2:DescribeInstanceAttribute",
"ec2:DescribeInstanceCreditSpecifications",
"ec2:DescribeInstanceStatus",
"ec2:DescribeInstanceTypes",
"ec2:DescribeInstances",
"ec2:DescribeInternetGateways",
"ec2:DescribeNetworkInterfaces",
"ec2:DescribePrefixLists",
"ec2:DescribeRouteTables",
"ec2:DescribeSecurityGroupRules",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSubnets",
"ec2:DescribeTags",
"ec2:DescribeVolumeAttribute",
"ec2:DescribeVolumeStatus",
"ec2:DescribeVolumes",
"ec2:DescribeVpcAttribute",
"ec2:DescribeVpcs",
"ec2:DescribeVpnGateways",
"ec2:DisassociateRouteTable",
"ec2:ModifySecurityGroupRules",
"ec2:ModifySubnetAttribute",
"ec2:RevokeSecurityGroupEgress",
"ec2:RevokeSecurityGroupIngress",
"ec2:UpdateSecurityGroupRuleDescriptionsEgress",
"ec2:UpdateSecurityGroupRuleDescriptionsIngress",
]
resources = ["*"]
}
statement {
sid = "Ec2Instance"
effect = "Allow"
actions = [
"ec2:AssociateIamInstanceProfile",
"ec2:AttachVolume",
"ec2:DescribeIamInstanceProfileAssociations",
"ec2:DetachVolume",
"ec2:DisassociateIamInstanceProfile",
"ec2:GetConsoleOutput",
"ec2:ModifyInstanceAttribute",
"ec2:MonitorInstances",
"ec2:ReplaceIamInstanceProfileAssociation",
"ec2:RunInstances",
"ec2:StartInstances",
"ec2:StopInstances",
"ec2:TerminateInstances",
"ec2:UnmonitorInstances",
]
resources = ["*"]
}
statement {
sid = "DlmPolicy"
effect = "Allow"
actions = [
"dlm:CreateLifecyclePolicy",
"dlm:DeleteLifecyclePolicy",
"dlm:GetLifecyclePolicy",
"dlm:ListTagsForResource",
"dlm:TagResource",
"dlm:UntagResource",
"dlm:UpdateLifecyclePolicy",
]
resources = ["*"]
}
}
data "aws_iam_policy_document" "hcptf_plan_refresh" {
statement {
sid = "RefreshIamRoles"
effect = "Allow"
actions = [
"iam:GetRole",
"iam:GetRolePolicy",
"iam:GetInstanceProfile",
"iam:ListRolePolicies",
"iam:ListAttachedRolePolicies",
"iam:ListInstanceProfilesForRole",
"iam:ListRoleTags",
]
resources = [
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/tf-managed/${local.stack_prefix}*",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:instance-profile/tf-managed/${local.stack_prefix}*",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${local.apply_role}",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${local.plan_role}",
]
}
statement {
sid = "RefreshManagedPolicies"
effect = "Allow"
actions = [
"iam:GetPolicy",
"iam:GetPolicyVersion",
]
resources = [
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/tf-managed/${local.stack_prefix}*",
"arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
"arn:aws:iam::aws:policy/job-function/ViewOnlyAccess",
]
}
statement {
sid = "RefreshEc2"
effect = "Allow"
actions = [
"ec2:DescribeAccountAttributes",
"ec2:DescribeAvailabilityZones",
"ec2:DescribeIamInstanceProfileAssociations",
"ec2:DescribeImages",
"ec2:DescribeInstanceAttribute",
"ec2:DescribeInstanceCreditSpecifications",
"ec2:DescribeInstanceStatus",
"ec2:DescribeInstanceTypes",
"ec2:DescribeInstances",
"ec2:DescribeInternetGateways",
"ec2:DescribeNetworkInterfaces",
"ec2:DescribePrefixLists",
"ec2:DescribeRouteTables",
"ec2:DescribeSecurityGroupRules",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSubnets",
"ec2:DescribeTags",
"ec2:DescribeVolumeAttribute",
"ec2:DescribeVolumeStatus",
"ec2:DescribeVolumes",
"ec2:DescribeVpcAttribute",
"ec2:DescribeVpcs",
"ec2:DescribeVpnGateways",
"ec2:GetConsoleOutput",
]
resources = ["*"]
}
statement {
sid = "RefreshDlm"
effect = "Allow"
actions = [
"dlm:GetLifecyclePolicy",
"dlm:ListTagsForResource",
]
resources = ["*"]
}
}
resource "aws_iam_role" "hcptf_apply" {
name = local.apply_role
assume_role_policy = data.aws_iam_policy_document.hcptf_apply_trust.json
max_session_duration = 3600
tags = {
Owner = "adam@seahavenind.com"
ManagedBy = "terraform"
}
}
resource "aws_iam_role" "hcptf_plan" {
name = local.plan_role
assume_role_policy = data.aws_iam_policy_document.hcptf_plan_trust.json
max_session_duration = 3600
tags = {
Owner = "adam@seahavenind.com"
ManagedBy = "terraform"
}
}
resource "aws_iam_role_policy" "hcptf_scoped_iam" {
name = "scoped-iam-management"
role = aws_iam_role.hcptf_apply.id
policy = data.aws_iam_policy_document.hcptf_scoped_iam.json
}
resource "aws_iam_role_policy" "hcptf_apply_services" {
# checkov:skip=CKV_AWS_111: EC2 describe and RunInstances APIs require Resource=*. This role cannot CreateVolume or DeleteVolume.
name = "file-share-services"
role = aws_iam_role.hcptf_apply.id
policy = data.aws_iam_policy_document.hcptf_apply_services.json
}
resource "aws_iam_role_policy" "hcptf_plan_refresh" {
# checkov:skip=CKV_AWS_107: ViewOnlyAccess plus this sidecar is the HCP plan-role pattern. Actions are named. It does not add iam:CreateAccessKey, secretsmanager:GetSecretValue, or ssm:GetParameter on *.
name = "file-share-plan-refresh"
role = aws_iam_role.hcptf_plan.id
policy = data.aws_iam_policy_document.hcptf_plan_refresh.json
}
resource "aws_iam_role_policy_attachment" "hcptf_plan_view_only" {
role = aws_iam_role.hcptf_plan.name
policy_arn = "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"
}
resource "aws_iam_role_policy_attachments_exclusive" "hcptf_apply" {
role_name = aws_iam_role.hcptf_apply.name
policy_arns = []
}
resource "aws_iam_role_policy_attachments_exclusive" "hcptf_plan" {
role_name = aws_iam_role.hcptf_plan.name
policy_arns = [
"arn:aws:iam::aws:policy/job-function/ViewOnlyAccess",
]
}

167
terraform/iam.tf Normal file
View file

@ -0,0 +1,167 @@
# Instance permissions boundary.
# The scoped apply role denies iam:CreatePolicy / CreatePolicyVersion,
# so later edits to this document need the hcptf-bootstrap window.
data "aws_iam_policy_document" "instance_boundary" {
# checkov:skip=CKV_AWS_111: SSM agent APIs require Resource=*. Secret reads are ARN-scoped.
statement {
sid = "SecretsRead"
effect = "Allow"
actions = [
"secretsmanager:GetSecretValue",
]
resources = [
var.smb_password_secret_arn,
var.filebrowser_password_secret_arn,
]
}
statement {
sid = "Ec2DescribeForAgent"
effect = "Allow"
actions = [
"ec2:DescribeTags",
"ec2:DescribeVolumes",
"ec2:DescribeInstances",
]
resources = ["*"]
}
statement {
sid = "SsmAgentBuckets"
effect = "Allow"
actions = [
"s3:GetObject",
]
resources = [
"arn:aws:s3:::aws-ssm-*/*",
"arn:aws:s3:::aws-windows-downloads-*/*",
"arn:aws:s3:::amazon-ssm-*/*",
"arn:aws:s3:::amazon-ssm-packages-*/*",
"arn:aws:s3:::patch-baseline-snapshot-*/*",
]
}
statement {
sid = "SsmManagedInstance"
effect = "Allow"
actions = [
"ssm:DescribeAssociation",
"ssm:GetDeployablePatchSnapshotForInstance",
"ssm:GetDocument",
"ssm:DescribeDocument",
"ssm:GetManifest",
"ssm:ListAssociations",
"ssm:ListInstanceAssociations",
"ssm:PutInventory",
"ssm:PutComplianceItems",
"ssm:PutConfigurePackageResult",
"ssm:UpdateAssociationStatus",
"ssm:UpdateInstanceAssociationStatus",
"ssm:UpdateInstanceInformation",
]
resources = ["*"]
}
statement {
sid = "SsmAgentParameters"
effect = "Allow"
actions = [
"ssm:GetParameter",
"ssm:GetParameters",
]
resources = [
"arn:aws:ssm:${var.aws_region}::parameter/aws/service/*",
"arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/aws/service/*",
]
}
statement {
sid = "SsmMessages"
effect = "Allow"
actions = [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel",
]
resources = ["*"]
}
statement {
sid = "Ec2Messages"
effect = "Allow"
actions = [
"ec2messages:AcknowledgeMessage",
"ec2messages:DeleteMessage",
"ec2messages:FailMessage",
"ec2messages:GetEndpoint",
"ec2messages:GetMessages",
"ec2messages:SendReply",
]
resources = ["*"]
}
}
resource "aws_iam_policy" "instance_boundary" {
# checkov:skip=CKV_AWS_111: SSM agent APIs require Resource=*. Secret reads are ARN-scoped.
name = local.boundary_name
path = "/tf-managed/"
description = "EC2 permissions boundary for file-share."
policy = data.aws_iam_policy_document.instance_boundary.json
}
data "aws_iam_policy_document" "instance_assume" {
statement {
sid = "Ec2Assume"
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role" "instance" {
name = local.instance_role_name
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.instance_assume.json
permissions_boundary = aws_iam_policy.instance_boundary.arn
tags = {
Name = local.instance_role_name
}
}
resource "aws_iam_role_policy_attachment" "ssm" {
role = aws_iam_role.instance.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
data "aws_iam_policy_document" "instance_secrets" {
statement {
sid = "SecretsRead"
effect = "Allow"
actions = [
"secretsmanager:GetSecretValue",
]
resources = [
var.smb_password_secret_arn,
var.filebrowser_password_secret_arn,
]
}
}
resource "aws_iam_role_policy" "instance_secrets" {
name = "file-share-secrets"
role = aws_iam_role.instance.id
policy = data.aws_iam_policy_document.instance_secrets.json
}
resource "aws_iam_instance_profile" "this" {
name = local.instance_profile_name
path = "/tf-managed/"
role = aws_iam_role.instance.name
}

28
terraform/locals.tf Normal file
View file

@ -0,0 +1,28 @@
locals {
project = "file-share"
environment = "prod"
hcp_project = "seahaven-prod"
hcp_workspace = "file-share-prod"
apply_role = "hcptf-file-share"
plan_role = "hcptf-file-share-plan"
stack_name = local.project
stack_prefix = "file-share-"
instance_role_name = "file-share-role"
instance_profile_name = "file-share-profile"
boundary_name = "file-share-instance-boundary"
office_lan_cidrs = ["10.10.0.0/16", "10.30.0.0/16"]
subnet_cidr = "10.40.20.0/24"
subnet_az = "us-east-1a"
create_instance = var.data_volume_id != ""
dlm_service_role_arn = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/aws-service-role/dlm.amazonaws.com/AWSServiceRoleForDataLifecycleManager"
user_data = templatefile("${path.module}/user_data.sh.tftpl", {
aws_region = var.aws_region
filebrowser_version = var.filebrowser_version
})
}

59
terraform/network.tf Normal file
View file

@ -0,0 +1,59 @@
data "aws_vpc" "syslog" {
filter {
name = "tag:Name"
values = ["syslog-server-vpc"]
}
}
data "aws_internet_gateway" "syslog" {
filter {
name = "tag:Name"
values = ["syslog-server-igw"]
}
}
data "aws_vpn_gateway" "syslog" {
filter {
name = "tag:Name"
values = ["syslog-server-office"]
}
attached_vpc_id = data.aws_vpc.syslog.id
}
data "aws_route_table" "syslog_public" {
vpc_id = data.aws_vpc.syslog.id
filter {
name = "tag:Name"
values = ["syslog-server-public"]
}
}
resource "aws_subnet" "file_share" {
vpc_id = data.aws_vpc.syslog.id
cidr_block = local.subnet_cidr
availability_zone = local.subnet_az
map_public_ip_on_launch = true
tags = {
Name = "file-share"
}
lifecycle {
precondition {
condition = one(data.aws_internet_gateway.syslog.attachments[*].vpc_id) == data.aws_vpc.syslog.id
error_message = "syslog IGW is not attached to the syslog VPC."
}
precondition {
condition = data.aws_vpn_gateway.syslog.attached_vpc_id == data.aws_vpc.syslog.id
error_message = "syslog VPN gateway is not attached to the syslog VPC."
}
}
}
resource "aws_route_table_association" "file_share" {
subnet_id = aws_subnet.file_share.id
route_table_id = data.aws_route_table.syslog_public.id
}

17
terraform/outputs.tf Normal file
View file

@ -0,0 +1,17 @@
output "private_ip" {
description = "SMB (smb://ip/files), FileBrowser (http://ip:8080), and SFTP. Clients use this address, not the public IP."
value = one(aws_instance.this[*].private_ip)
}
output "public_ip" {
description = "Egress address for package install and SSM. Not a client endpoint."
value = one(aws_instance.this[*].public_ip)
}
output "instance_id" {
value = one(aws_instance.this[*].id)
}
output "subnet_id" {
value = aws_subnet.file_share.id
}

14
terraform/providers.tf Normal file
View file

@ -0,0 +1,14 @@
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = local.project
Environment = "prod"
ManagedBy = "terraform"
Workspace = local.hcp_workspace
}
}
}
data "aws_caller_identity" "current" {}

49
terraform/security.tf Normal file
View file

@ -0,0 +1,49 @@
resource "aws_security_group" "file_share" {
name = "file-share"
description = "SMB, FileBrowser, and SFTP from office LANs"
vpc_id = data.aws_vpc.syslog.id
tags = {
Name = "file-share"
}
}
resource "aws_vpc_security_group_egress_rule" "all" {
security_group_id = aws_security_group.file_share.id
ip_protocol = "-1"
cidr_ipv4 = "0.0.0.0/0"
description = "Outbound for package install, Secrets Manager, and SSM"
}
resource "aws_vpc_security_group_ingress_rule" "smb" {
for_each = toset(local.office_lan_cidrs)
security_group_id = aws_security_group.file_share.id
ip_protocol = "tcp"
from_port = 445
to_port = 445
cidr_ipv4 = each.value
description = "SMB from office LAN"
}
resource "aws_vpc_security_group_ingress_rule" "filebrowser" {
for_each = toset(local.office_lan_cidrs)
security_group_id = aws_security_group.file_share.id
ip_protocol = "tcp"
from_port = 8080
to_port = 8080
cidr_ipv4 = each.value
description = "FileBrowser from office LAN"
}
resource "aws_vpc_security_group_ingress_rule" "sftp" {
for_each = toset(local.office_lan_cidrs)
security_group_id = aws_security_group.file_share.id
ip_protocol = "tcp"
from_port = 22
to_port = 22
cidr_ipv4 = each.value
description = "SFTP from office LAN"
}

View file

@ -0,0 +1,111 @@
#!/bin/bash
set -euo pipefail
until lsblk -dno NAME | grep -v "$(lsblk -no PKNAME "$(findmnt -n -o SOURCE /)" 2>/dev/null || echo xvda)" | grep -q .; do
echo "Waiting for data volume..."
sleep 5
done
DATA_DEVICE="/dev/$(lsblk -dno NAME | grep -v "$(lsblk -no PKNAME "$(findmnt -n -o SOURCE /)" 2>/dev/null || echo xvda)" | head -1)"
if ! blkid "$DATA_DEVICE"; then
mkfs.ext4 -L file-share-data "$DATA_DEVICE"
fi
mkdir -p /data
grep -q 'LABEL=file-share-data /data ' /etc/fstab || echo "LABEL=file-share-data /data ext4 defaults,nofail 0 2" >> /etc/fstab
mount -a
mkdir -p /data/share
dnf install -y samba samba-common
useradd --system --no-create-home --shell /sbin/nologin adam || true
chown adam:adam /data/share
SMB_PASSWORD="$(aws secretsmanager get-secret-value --secret-id file-share/smb-password --query SecretString --output text --region ${aws_region})"
(printf '%s\n' "$SMB_PASSWORD"; printf '%s\n' "$SMB_PASSWORD") | smbpasswd -s -a adam
if passwd --help 2>&1 | grep -q -- '--stdin'; then
printf '%s\n' "$SMB_PASSWORD" | passwd --stdin adam
else
printf 'adam:%s\n' "$SMB_PASSWORD" | chpasswd
fi
unset SMB_PASSWORD
cat > /etc/samba/smb.conf << 'SMBEOF'
[global]
workgroup = SEAHAVEN
server string = Sea Haven File Share
security = user
map to guest = never
log file = /var/log/samba/log.%m
max log size = 1000
server min protocol = SMB3
# macOS Finder optimizations
vfs objects = catia fruit streams_xattr
fruit:metadata = stream
fruit:model = MacSamba
fruit:posix_rename = yes
fruit:veto_appledouble = no
fruit:nfs_aces = no
fruit:wipe_intentionally_left_blank_rfork = yes
fruit:delete_empty_adfiles = yes
[files]
path = /data/share
browseable = yes
writable = yes
valid users = adam
create mask = 0644
directory mask = 0755
SMBEOF
systemctl enable --now smb nmb
curl -sfL "https://github.com/filebrowser/filebrowser/releases/download/${filebrowser_version}/linux-arm64-filebrowser.tar.gz" | tar xz -C /usr/local/bin filebrowser
chmod +x /usr/local/bin/filebrowser
mkdir -p /etc/filebrowser
FB_PASSWORD="$(aws secretsmanager get-secret-value --secret-id file-share/filebrowser-password --query SecretString --output text --region ${aws_region})"
cat > /etc/filebrowser/config.json << 'FBEOF'
{
"address": "0.0.0.0",
"port": 8080,
"root": "/data/share",
"database": "/etc/filebrowser/filebrowser.db",
"log": "/var/log/filebrowser.log"
}
FBEOF
filebrowser config init --config /etc/filebrowser/config.json
filebrowser users add admin "$FB_PASSWORD" --config /etc/filebrowser/config.json --perm.admin
unset FB_PASSWORD
cat > /etc/systemd/system/filebrowser.service << 'SVCEOF'
[Unit]
Description=FileBrowser
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/filebrowser --config /etc/filebrowser/config.json
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
SVCEOF
systemctl daemon-reload
systemctl enable --now filebrowser
usermod -s /bin/bash -d /data/share adam
if ! grep -q 'Match User adam' /etc/ssh/sshd_config; then
cat >> /etc/ssh/sshd_config << 'SSHEOF'
Match User adam
ForceCommand internal-sftp
PasswordAuthentication yes
AllowTcpForwarding no
X11Forwarding no
SSHEOF
fi
systemctl restart sshd

48
terraform/variables.tf Normal file
View file

@ -0,0 +1,48 @@
variable "aws_region" {
description = "Region every resource in this configuration is created in."
type = string
default = "us-east-1"
}
variable "ami_id" {
description = "Pinned Amazon Linux 2023 arm64 AMI. Changing this replaces the instance. Snapshot the data volume the same day and confirm before apply."
type = string
default = "ami-0eb45f74aa8a20238"
}
variable "filebrowser_version" {
description = "Pinned FileBrowser release. Do not track releases/latest."
type = string
default = "v2.63.23"
}
variable "smb_password_secret_arn" {
description = "Exact ARN of file-share/smb-password in this account. Set as an HCP workspace variable. Never the secret value."
type = string
validation {
condition = startswith(var.smb_password_secret_arn, "arn:aws:secretsmanager:")
error_message = "smb_password_secret_arn must be a Secrets Manager ARN."
}
}
variable "filebrowser_password_secret_arn" {
description = "Exact ARN of file-share/filebrowser-password in this account. Set as an HCP workspace variable. Never the secret value."
type = string
validation {
condition = startswith(var.filebrowser_password_secret_arn, "arn:aws:secretsmanager:")
error_message = "filebrowser_password_secret_arn must be a Secrets Manager ARN."
}
}
variable "data_volume_id" {
description = "Imported data volume id. Empty until cutover. Terraform attaches this volume and must not create or delete it."
type = string
default = ""
validation {
condition = var.data_volume_id == "" || startswith(var.data_volume_id, "vol-")
error_message = "data_volume_id must be empty or an EBS volume id."
}
}

18
terraform/versions.tf Normal file
View file

@ -0,0 +1,18 @@
terraform {
required_version = ">= 1.14.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.64"
}
}
cloud {
organization = "seahaven"
workspaces {
name = "file-share-prod"
}
}
}