chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+568
View File
@@ -0,0 +1,568 @@
# Phone-server AWS IAM least-privilege assessment
Assessment date: 2026-07-12
Account: `302154194530`
Region: `us-east-1`
This document began as a design review. The live hardening described in `README.md` was subsequently applied and verified. The remaining deliberate gap is removal of `AdministratorAccess` from `jade-deploy`, which is blocked until an independent root/MFA recovery login is verified.
## Executive assessment
`jade-deploy` should not retain `AdministratorAccess`. A compromised long-lived deployment credential can currently modify identities, disable cost guardrails, exfiltrate data, create arbitrary infrastructure, and establish persistence anywhere in the account.
Replace it with an assume-role-only principal and three distinct privilege planes:
1. **`JcodePhoneOperator`** for normal deployments and maintenance of the existing stack.
2. **`JcodePhoneProvisioner`** for infrequent rebuilds, MFA-gated, time-limited, restricted to `us-east-1`, `jcode-phone-*` resources, and bounded runtime roles.
3. **A separate emergency administrator path** protected by MFA and never used by automation. This is required to avoid lockout while removing the existing administrator attachment.
Also replace the instance's `AmazonBedrockFullAccess` with inference-only permissions. The code uses model catalog discovery and `ConverseStream`; it does not need Bedrock administration.
## Evidence and stated live resources
Repository evidence:
- `README.md` states account `302154194530`, region `us-east-1`, instance `i-08214cf66cd3f80c7`, Elastic IP `54.196.207.97`, and API ID `8c3wp4cbag`.
- `wake-lambda.py` calls `ec2:DescribeInstances` and `ec2:StartInstances` for that instance.
- `breaker-lambda.py` calls `ec2:DescribeInstances`, `ec2:StopInstances`, and `sns:Publish` to `arn:aws:sns:us-east-1:302154194530:jcode-guard-warn`.
- The Bedrock provider calls `ListFoundationModels`, `ListInferenceProfiles`, and the streaming Converse API. Converse streaming is authorized by `bedrock:InvokeModelWithResponseStream`.
- The rebuild instructions require EC2, EIP, security group, IAM instance profile, Lambda, API Gateway v2, SNS, CloudWatch alarms, and Budgets administration.
- TestFlight automation is App Store Connect only and requires no AWS permission.
The SSM-based wake implementation is now deployed. The Lambda generates pair codes through SSM Run Command, and the legacy public `:7644` path is disabled.
Stated named resources:
| Type | Resource |
|---|---|
| EC2 instance | `arn:aws:ec2:us-east-1:302154194530:instance/i-08214cf66cd3f80c7` |
| Elastic IP | `54.196.207.97`; allocation ID must be inventoried |
| Lambda | `jcode-phone-wake` |
| Lambda | `jcode-guard-breaker` |
| API Gateway v2 | `8c3wp4cbag` |
| SNS | `jcode-guard-stop` and `jcode-guard-warn` |
| CloudWatch alarms | `jcode-bedrock-tokens-warn`, `jcode-bedrock-tokens-stop`; the ineffective billing alarms were replaced by the working Budget/SNS breaker path |
| Budget | `jcode-dev-monthly-cost` |
| Bedrock model route | `us.anthropic.claude-opus-4-6-v1` |
### Live-state verification
The account was subsequently inventoried through the `jcode-bedrock` profile. Runtime role names, Lambda roles, the EC2 instance and security group, the Elastic IP, Budget subscribers, CloudWatch alarms, log retention, API Gateway stage, S3/DynamoDB resources, and access-key metadata were verified directly. The wake Lambda now uses SSM pairing, all public EC2 ingress is closed, the root EBS volume is encrypted, CloudTrail and Access Analyzer are enabled, and the old deployment key is inactive.
## Target identity design
### Human access
Preferred end state:
- Use IAM Identity Center or another federated identity for the human operator.
- Require MFA for both operator and provisioner role assumption.
- Do not create long-lived keys for human access.
- Keep `jade-deploy` only during migration, then delete it after the observation period.
Transitional end state if an IAM user must remain:
- `jade-deploy` has no console password and no service permissions.
- Its only permission is `sts:AssumeRole` for the two deployment roles.
- It cannot modify itself, policies, access keys, MFA devices, or role trust policies.
Assume-only policy for `jade-deploy`:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AssumePhoneServerRolesOnly",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": [
"arn:aws:iam::302154194530:role/jcode-phone/JcodePhoneOperator",
"arn:aws:iam::302154194530:role/jcode-phone/JcodePhoneProvisioner"
]
}
]
}
```
Require MFA in both role trust policies for a human IAM principal:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::302154194530:user/jade-deploy"
},
"Action": "sts:AssumeRole",
"Condition": {
"Bool": { "aws:MultiFactorAuthPresent": "true" },
"NumericLessThan": { "aws:MultiFactorAuthAge": "3600" }
}
}
]
}
```
Set `JcodePhoneProvisioner` maximum session duration to one hour. Do not allow either role to change its own trust or permissions.
For unattended CI, use a separate OIDC-federated role restricted to the exact repository, branch/environment, and workflow. Do not weaken the human role's MFA trust for CI.
## Runtime policies
Use separate execution roles for the instance and each Lambda. Do not reuse the deploy role at runtime.
### EC2 instance: inference only
Replace `AmazonBedrockFullAccess` with the following customer-managed policy. `List*` actions require `Resource: "*"`. The inference resources deliberately cover only the configured Claude Opus 4.6 cross-region profile and its backing foundation model. Cross-region inference can route outside `us-east-1`, so a single-region foundation-model ARN may fail.
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DiscoverBedrockModels",
"Effect": "Allow",
"Action": [
"bedrock:ListFoundationModels",
"bedrock:ListInferenceProfiles"
],
"Resource": "*"
},
{
"Sid": "InvokeConfiguredClaudeProfile",
"Effect": "Allow",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
],
"Resource": [
"arn:aws:bedrock:us-east-1:302154194530:inference-profile/us.anthropic.claude-opus-4-6-v1",
"arn:aws:bedrock:*::foundation-model/anthropic.claude-opus-4-6-v1:0"
]
}
]
}
```
Verify the exact inference-profile ARN and backing model IDs with `bedrock list-inference-profiles` before application. If the deployed profile is AWS-owned or the backing model has a different version suffix, substitute the returned ARNs rather than widening the model-name pattern. The wildcard region is intentional because a US cross-region inference profile can route to multiple US regions. Remove `bedrock:InvokeModel` after testing if all production paths exclusively use `ConverseStream`; retaining it is a small compatibility allowance, not administrative access. The provider's optional STS identity validation does not require an added service permission for normal operation.
### Wake Lambda
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadTargetInstanceState",
"Effect": "Allow",
"Action": "ec2:DescribeInstances",
"Resource": "*"
},
{
"Sid": "StartTargetInstanceOnly",
"Effect": "Allow",
"Action": "ec2:StartInstances",
"Resource": "arn:aws:ec2:us-east-1:302154194530:instance/i-08214cf66cd3f80c7"
},
{
"Sid": "WriteOwnLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:302154194530:log-group:/aws/lambda/jcode-phone-wake:*"
}
]
}
```
Create the log group during provisioning with retention configured, rather than granting runtime `logs:CreateLogGroup` on `*`.
If the SSM-based wake implementation is deployed, add:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadManagedInstanceStatus",
"Effect": "Allow",
"Action": "ssm:DescribeInstanceInformation",
"Resource": "*"
},
{
"Sid": "RunPairCommandOnTargetOnly",
"Effect": "Allow",
"Action": "ssm:SendCommand",
"Resource": [
"arn:aws:ec2:us-east-1:302154194530:instance/i-08214cf66cd3f80c7",
"arn:aws:ssm:us-east-1::document/AWS-RunShellScript"
]
},
{
"Sid": "ReadPairCommandResult",
"Effect": "Allow",
"Action": "ssm:GetCommandInvocation",
"Resource": "*"
}
]
}
```
That variant also requires an SSM-managed-instance policy on the EC2 role. Keep it separate from the Bedrock policy, validate the exact Systems Manager agent calls in CloudTrail, and narrow it from `AmazonSSMManagedInstanceCore` where operationally practical. Treat `ssm:SendCommand` as privileged remote code execution and keep it scoped to the one instance and AWS-managed document.
### Breaker Lambda
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadTargetInstanceState",
"Effect": "Allow",
"Action": "ec2:DescribeInstances",
"Resource": "*"
},
{
"Sid": "StopTargetInstanceOnly",
"Effect": "Allow",
"Action": "ec2:StopInstances",
"Resource": "arn:aws:ec2:us-east-1:302154194530:instance/i-08214cf66cd3f80c7"
},
{
"Sid": "PublishGuardNotification",
"Effect": "Allow",
"Action": "sns:Publish",
"Resource": "arn:aws:sns:us-east-1:302154194530:jcode-guard-warn"
},
{
"Sid": "WriteOwnLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:302154194530:log-group:/aws/lambda/jcode-guard-breaker:*"
}
]
}
```
## Daily operator policy
This role updates and diagnoses existing resources but cannot create identities, create arbitrary compute, terminate the instance, release the EIP, delete guardrails, or alter role trust.
Replace the `<...>` values after read-only inventory. The API Gateway resource form is intentionally the API Gateway management ARN, which does not include the account ID.
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DescribePhoneServerInfrastructure",
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:DescribeInstanceStatus",
"ec2:DescribeAddresses",
"ec2:DescribeSecurityGroups",
"ec2:DescribeVolumes",
"cloudwatch:DescribeAlarms",
"cloudwatch:GetMetricData",
"cloudwatch:GetMetricStatistics",
"cloudwatch:ListMetrics"
],
"Resource": "*"
},
{
"Sid": "ReadExistingFunctions",
"Effect": "Allow",
"Action": [
"lambda:GetFunction",
"lambda:GetFunctionConfiguration",
"lambda:GetPolicy"
],
"Resource": [
"arn:aws:lambda:us-east-1:302154194530:function:jcode-phone-wake",
"arn:aws:lambda:us-east-1:302154194530:function:jcode-phone-wake:*",
"arn:aws:lambda:us-east-1:302154194530:function:jcode-guard-breaker",
"arn:aws:lambda:us-east-1:302154194530:function:jcode-guard-breaker:*"
]
},
{
"Sid": "OperateExistingInstance",
"Effect": "Allow",
"Action": [
"ec2:StartInstances",
"ec2:StopInstances",
"ec2:RebootInstances",
"ec2:ModifyInstanceAttribute"
],
"Resource": "arn:aws:ec2:us-east-1:302154194530:instance/i-08214cf66cd3f80c7"
},
{
"Sid": "DeployExistingFunctions",
"Effect": "Allow",
"Action": [
"lambda:UpdateFunctionCode",
"lambda:UpdateFunctionConfiguration",
"lambda:PublishVersion",
"lambda:CreateAlias",
"lambda:UpdateAlias",
"lambda:DeleteAlias",
"lambda:InvokeFunction"
],
"Resource": [
"arn:aws:lambda:us-east-1:302154194530:function:jcode-phone-wake",
"arn:aws:lambda:us-east-1:302154194530:function:jcode-phone-wake:*",
"arn:aws:lambda:us-east-1:302154194530:function:jcode-guard-breaker",
"arn:aws:lambda:us-east-1:302154194530:function:jcode-guard-breaker:*"
]
},
{
"Sid": "ReadAndPatchExistingHttpApiRoot",
"Effect": "Allow",
"Action": [
"apigateway:GET",
"apigateway:PATCH"
],
"Resource": "arn:aws:apigateway:us-east-1::/apis/8c3wp4cbag"
},
{
"Sid": "MaintainExistingHttpApiChildren",
"Effect": "Allow",
"Action": [
"apigateway:GET",
"apigateway:POST",
"apigateway:PUT",
"apigateway:PATCH",
"apigateway:DELETE"
],
"Resource": "arn:aws:apigateway:us-east-1::/apis/8c3wp4cbag/*"
},
{
"Sid": "PassInstanceRoleToEc2Only",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::302154194530:role/jcode-phone/runtime/JcodePhoneInstance",
"Condition": {
"StringEquals": {
"iam:PassedToService": "ec2.amazonaws.com"
}
}
},
{
"Sid": "PassLambdaRolesToLambdaOnly",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": [
"arn:aws:iam::302154194530:role/jcode-phone/runtime/JcodePhoneWakeLambda",
"arn:aws:iam::302154194530:role/jcode-phone/runtime/JcodePhoneBreakerLambda"
],
"Condition": {
"StringEquals": {
"iam:PassedToService": "lambda.amazonaws.com"
}
}
},
{
"Sid": "ReadPhoneLogs",
"Effect": "Allow",
"Action": [
"logs:DescribeLogStreams",
"logs:GetLogEvents",
"logs:FilterLogEvents"
],
"Resource": [
"arn:aws:logs:us-east-1:302154194530:log-group:/aws/lambda/jcode-phone-wake:*",
"arn:aws:logs:us-east-1:302154194530:log-group:/aws/lambda/jcode-guard-breaker:*"
]
},
{
"Sid": "MaintainGuardTopics",
"Effect": "Allow",
"Action": [
"sns:GetTopicAttributes",
"sns:ListSubscriptionsByTopic",
"sns:Publish"
],
"Resource": [
"arn:aws:sns:us-east-1:302154194530:jcode-guard-stop",
"arn:aws:sns:us-east-1:302154194530:jcode-guard-warn"
]
},
{
"Sid": "ReadNamedBudget",
"Effect": "Allow",
"Action": "budgets:ViewBudget",
"Resource": "arn:aws:budgets::302154194530:budget/jcode-dev-monthly-cost"
}
]
}
```
Recommended tightening:
- Remove `ec2:ModifyInstanceAttribute` if routine maintenance never changes shutdown behavior, instance type, source/destination check, or attached profile.
- Keep `lambda:AddPermission`, `lambda:RemovePermission`, `sns:Subscribe`, alarm mutation, and budget mutation in the MFA-gated provisioner path. Those actions can expose invocations, exfiltrate notifications, or disable cost controls.
- API Gateway `DELETE` is allowed only below `/apis/8c3wp4cbag/*`; the daily role cannot delete the API root.
- Do not grant `cloudwatch:DeleteAlarms`, `budgets:DeleteBudget`, `sns:DeleteTopic`, `ec2:TerminateInstances`, `ec2:ReleaseAddress`, or IAM write actions to the daily operator.
## Rebuild/provisioner role
A rebuild inherently needs create/delete privileges on several services. Keep those permissions out of the daily role. The safest maintainable implementation is to put the stack in CloudFormation, CDK, or Terraform and let the human invoke only a named stack deployment role.
Recommended model:
- Human `JcodePhoneProvisioner`: CloudFormation stack operations on `jcode-phone-server*`, read-only diagnostics, and `iam:PassRole` only for `JcodePhoneCloudFormationExecution` with `iam:PassedToService = cloudformation.amazonaws.com`.
- `JcodePhoneCloudFormationExecution`: service permissions below, usable only by CloudFormation.
- Every created resource is tagged `Project=jcode-phone-server` and `ManagedBy=cloudformation`.
- Every created runtime role is under path `/jcode-phone/runtime/` and must carry the `JcodePhoneRuntimeBoundary` permissions boundary.
Human provisioner policy:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ManageNamedPhoneStack",
"Effect": "Allow",
"Action": [
"cloudformation:CreateStack",
"cloudformation:UpdateStack",
"cloudformation:DeleteStack",
"cloudformation:DescribeStacks",
"cloudformation:DescribeStackEvents",
"cloudformation:DescribeStackResources",
"cloudformation:GetTemplate",
"cloudformation:GetTemplateSummary",
"cloudformation:ListStackResources",
"cloudformation:CreateChangeSet",
"cloudformation:DescribeChangeSet",
"cloudformation:ExecuteChangeSet",
"cloudformation:DeleteChangeSet",
"cloudformation:ValidateTemplate"
],
"Resource": [
"arn:aws:cloudformation:us-east-1:302154194530:stack/jcode-phone-server*/*",
"arn:aws:cloudformation:us-east-1:302154194530:changeSet/jcode-phone-server*/*"
]
},
{
"Sid": "ValidateNewTemplate",
"Effect": "Allow",
"Action": "cloudformation:ValidateTemplate",
"Resource": "*"
},
{
"Sid": "PassPhoneCloudFormationExecutionRole",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::302154194530:role/jcode-phone/JcodePhoneCloudFormationExecution",
"Condition": {
"StringEquals": {
"iam:PassedToService": "cloudformation.amazonaws.com"
}
}
}
]
}
```
The CloudFormation execution role should permit only this service/action envelope:
| Service | Required rebuild operations | Scope/guardrail |
|---|---|---|
| EC2 | Run/terminate the one server; create/tag volume and ENI; create/manage one SG; allocate/associate/release one EIP; modify shutdown behavior; describe AMIs/subnets/VPCs | `us-east-1`; request/resource tag `Project=jcode-phone-server`; approved instance types only; IMDSv2 required; approved VPC/subnet |
| IAM | Create/update/delete the three runtime roles and one instance profile; put/delete inline policies; pass roles | Path `/jcode-phone/runtime/` only; require boundary ARN on `CreateRole`; never allow changing deploy/provisioner/emergency roles |
| Lambda | Create/update/delete the two named functions; versions/aliases; permissions | Function ARN prefix `jcode-phone-*` and `jcode-guard-breaker*`; project tag |
| API Gateway v2 | Create/update/delete one HTTP API, integration, route, and stage | Project tag and stack ownership |
| SNS | Create/manage/delete `jcode-guard-stop` and `jcode-guard-warn`; subscriptions | Exact topic-name ARNs |
| CloudWatch | Create/update/delete the four named alarms | Exact alarm-name ARNs |
| Logs | Create/configure/delete the two Lambda log groups | Exact `/aws/lambda/...` ARNs; retention required |
| Budgets | Create/update/delete `jcode-dev-monthly-cost` | Exact budget ARN |
The execution role, not the daily operator, also owns `lambda:AddPermission`/`RemovePermission`, `sns:Subscribe`/`Unsubscribe`, CloudWatch alarm mutation, and budget mutation. This preserves integration and guardrail maintenance without making those high-impact actions part of everyday credentials.
Do not give the CloudFormation execution role general `iam:*`, `organizations:*`, `account:*`, `sts:AssumeRole`, `kms:*`, unrestricted `s3:*`, unrestricted `secretsmanager:*`, or permission to modify the provisioner, operator, emergency role, its own role, or its own boundary.
A permissions boundary is not a grant. Use it as a maximum for runtime roles and combine it with their narrow inline policies. The boundary should allow only:
- The instance's Bedrock discovery and configured-model inference permissions.
- The wake Lambda's start/describe and own-log permissions.
- The breaker Lambda's stop/describe, warning publish, and own-log permissions.
Because these permissions differ, the boundary can be their union; each role's inline policy must remain the narrower subset. Explicitly deny IAM, STS role assumption, Organizations, account administration, and access-key operations in the boundary as defense in depth.
## Read-only inventory required before migration
Run these with the existing administrator session after reauthentication. They make no changes:
```bash
aws sts get-caller-identity
aws iam list-attached-user-policies --user-name jade-deploy
aws iam list-user-policies --user-name jade-deploy
aws iam list-access-keys --user-name jade-deploy
aws iam get-account-authorization-details > phone-server-iam-before.json
aws ec2 describe-instances --instance-ids i-08214cf66cd3f80c7 --region us-east-1
aws ec2 describe-addresses --public-ips 54.196.207.97 --region us-east-1
aws lambda get-function --function-name jcode-phone-wake --region us-east-1
aws lambda get-function --function-name jcode-guard-breaker --region us-east-1
aws apigatewayv2 get-api --api-id 8c3wp4cbag --region us-east-1
aws sns list-topics --region us-east-1
aws cloudwatch describe-alarms --alarm-name-prefix jcode- --region us-east-1
aws budgets describe-budget --account-id 302154194530 --budget-name jcode-dev-monthly-cost
aws bedrock list-inference-profiles --type-equals SYSTEM_DEFINED --region us-east-1
```
Also inventory CloudTrail usage for at least 30 days. Add an action only when it corresponds to a known deploy/maintenance operation. Access Analyzer policy generation from CloudTrail is useful as a second opinion, but should not replace review because rarely used recovery operations may be absent.
## Lockout-safe migration and credential rotation
1. **Prepare recovery first.** Verify the account root email, root MFA, and recovery contacts. Create or verify a separate MFA-protected emergency administrator path. Prefer IAM Identity Center. It must be independent of `jade-deploy`, have no access key, and be tested before touching `AdministratorAccess`.
2. **Capture state.** Export IAM authorization details, user policy attachments, role trust policies, access-key metadata, resource ARNs, and tags. Record the exact `AdministratorAccess` attachment being replaced.
3. **Create runtime policies and roles.** Create the narrow instance, wake, and breaker roles. Do not switch workloads yet. Validate their documents with IAM Access Analyzer or `aws accessanalyzer validate-policy`.
4. **Create operator and provisioner roles.** Add MFA-gated trust. Ensure neither role can modify itself, the emergency path, permission boundaries, or arbitrary identities.
5. **Grant assume-only access while admin remains attached.** Attach the small `sts:AssumeRole` policy to `jade-deploy`, but leave `AdministratorAccess` temporarily attached.
6. **Test role entry.** From a distinct profile/session, assume each role and confirm `aws sts get-caller-identity` reports the role ARN. Confirm operator reads for EC2, Lambda, API Gateway, logs, SNS, alarms, and budget.
7. **Simulate every required API call.** Use `iam:SimulatePrincipalPolicy` or the policy simulator for the action/resource matrix in this document. Explicitly test denied controls such as creating users, attaching `AdministratorAccess`, terminating arbitrary instances, and deleting guardrails.
8. **Canary mutation paths without touching production.** Through the provisioner, deploy and remove a tiny tagged canary stack using the same resource classes where practical. For the operator, update a disposable Lambda alias or canary function rather than invoking `jcode-guard-breaker`, which stops production. Do not use the breaker invocation as an IAM test.
9. **Switch runtime roles one at a time.** First update Lambda execution roles and verify logs plus harmless wake status checks. Then replace the EC2 instance profile and run a real Bedrock streaming request. Keep the previous roles intact but unattached until verification completes.
10. **Rotate the credential transport.** Preferred: switch the workstation to IAM Identity Center and role profiles. Transitional IAM-user option: create a second `jade-deploy` access key, configure it under a new profile, and verify role assumption. Never overwrite the only working profile first. An IAM user can have at most two keys.
11. **Detach `AdministratorAccess` only after independent recovery succeeds.** Confirm the emergency administrator path in a separate browser/profile immediately before detachment. Then detach `AdministratorAccess` from `jade-deploy`. Do not delete the user, keys, old runtime roles, or old policies in the same change window.
12. **Run post-detachment checks.** Re-assume operator and provisioner, rerun all read checks and safe deployment checks, verify the phone-server health endpoint, verify wake behavior during an agreed maintenance window, verify pairing, and verify one Bedrock streaming completion.
13. **Disable the old key, do not delete it yet.** After the new access path has worked for at least 24 to 72 hours, mark the old key inactive. Monitor CloudTrail for attempts using its access-key ID and for `AccessDenied` on expected workflows.
14. **Delete after observation.** After another 7 to 14 days without needed rollback, delete the inactive key, remove obsolete policies/roles, and, if federation is stable, delete `jade-deploy` entirely.
15. **Review quarterly.** Use access-key last-used data, CloudTrail, Access Analyzer, credential reports, and alarm/budget tests. Remove unused actions. Test the emergency path without using it for routine work.
### Rollback rule
If a required operation fails after administrator removal, stop and use the independent emergency administrator path to correct the narrow role. Do not reattach `AdministratorAccess` to the everyday user as the default fix. Never rely on an existing STS session as the only rollback mechanism because policy changes and session expiry can invalidate that assumption.
## Additional security findings adjacent to IAM
These are not required for the IAM replacement, but they materially affect the deployment:
- The wake secret is embedded in Lambda source and placed in a query string. Query tokens can appear in browser history, logs, analytics, screenshots, and referrers. Store it in Secrets Manager or SSM Parameter Store, compare a header or signed short-lived request, and grant only the wake Lambda read access to that one secret.
- Port `7644` is publicly exposed and the Lambda calls it over plain HTTP. Prefer a private VPC path, SSM-mediated pairing, or tailnet-only access. If keeping it public, restrict the security group source and add TLS.
- The pair service runs as root and invokes `sudo -u ec2-user`; harden the systemd unit with a dedicated user, filesystem protections, `NoNewPrivileges`, and a narrowly scoped sudo rule.
- The instance role's current `AmazonBedrockFullAccess` is broader than necessary even if `jade-deploy` is fixed.
- Add CloudTrail alerts for `AttachUserPolicy`, `PutUserPolicy`, `CreateAccessKey`, `UpdateAssumeRolePolicy`, `PassRole`, and changes to the emergency/deployment roles.
## Acceptance criteria
The migration is complete when:
- `jade-deploy` has no `AdministratorAccess` and no direct AWS service permissions beyond role assumption.
- Daily deployment and maintenance succeed through `JcodePhoneOperator`.
- A full tagged rebuild can be performed through the MFA-gated provisioner/CloudFormation path.
- Runtime roles contain only the API calls documented above.
- An independent emergency administrator path is tested.
- The old access key is disabled, observed, then deleted.
- CloudTrail shows no unexpected `AccessDenied` for required operations and no use of the old key during the observation period.
+88
View File
@@ -0,0 +1,88 @@
# jcode phone server (managed cloud host)
A self-managing EC2 host that runs `jcode serve` with the WebSocket gateway so
phones (the iOS app, or SSH clients like Termius) can drive jcode sessions
without any laptop in the loop. Billing safety is layered and each layer has
been live-tested.
Live deployment (July 2026): AWS account `302154194530`, us-east-1,
instance `i-08214cf66cd3f80c7` (m7i-flex.large), Elastic IP `54.196.207.97`.
## Architecture
```
phone (jcode iOS app / Termius, connected through Tailscale)
│ WebSocket :7643 (pair token auth, tailnet-only)
EC2 jcode server ──instance role──▶ AWS Bedrock (Opus 4.6 default)
│ tap wake link (API Gateway → wake lambda, bearer token from URL fragment)
│ "Pair this phone" button (lambda → SSM Run Command → `jcode pair`)
AWS Budget $10 ──▶ SNS jcode-guard-stop ──▶ breaker lambda ──▶ stop instance
└──▶ email
```
## Files
| File | Deployed at | Purpose |
|---|---|---|
| `units/jcode-serve.service` | `~ec2-user/.config/systemd/user/` (user unit, linger on) | jcode daemon + gateway, restart always |
| `units/jcode-pair.service` | `/etc/systemd/system/` | Legacy local pairing HTTP service, now disabled after migration to SSM |
| `units/idle-autostop.{service,timer}` | `/etc/systemd/system/` | 5-min check, poweroff after 30 min idle |
| `idle-autostop.sh` | `/usr/local/bin/` | idle = no gateway/SSH clients AND jcode has no outbound :443 (not streaming) |
| `jcode-pair-service.py` | `/usr/local/bin/` | Legacy tailnet-only fallback; normal pairing now uses SSM |
| `wake-lambda.py` | Lambda `jcode-phone-wake` (behind API Gateway `8c3wp4cbag`) | wake page: starts instance, polls SSM health, generates pair codes through SSM |
| `breaker-lambda.py` | Lambda `jcode-guard-breaker` | stops the instance; subscribed to SNS `jcode-guard-stop` |
| `IAM-LEAST-PRIVILEGE.md` | repository documentation | scoped runtime/deploy policies and lockout-safe `jade-deploy` rotation plan |
## Server config (instance)
- `~/.jcode/config.toml`: `[provider]` default bedrock/Opus 4.6, `[gateway] enabled = true, port 7643, bind 0.0.0.0`
(note: `~/.jcode/config.toml`, NOT `~/.config/jcode/`)
- `~/.bashrc` env: `JCODE_BEDROCK_ENABLE=1`, `AWS_REGION=us-east-1`,
`JCODE_BEDROCK_MODEL=us.anthropic.claude-opus-4-6-v1`, `JCODE_GATEWAY_HOST=100.109.78.41`
- Helpers: `~/bin/jc` (jcode with bedrock), `~/bin/phone` (attach-or-create tmux jcode)
- `loginctl enable-linger ec2-user` so the user service runs at boot
- Instance attr `instance-initiated-shutdown-behavior=stop` so `poweroff` = stopped (not billed)
## Cost guardrails (all live-tested)
| Layer | Trigger | Action |
|---|---|---|
| idle-autostop | 30 min no clients + not streaming | instance powers itself off |
| `jcode-bedrock-tokens-warn` | >3M input tokens / 15 min | email |
| `jcode-bedrock-tokens-stop` | >10M input tokens / 15 min, 2 periods | breaker stops instance + email |
| AWS budget `jcode-dev-monthly-cost` | forecast >50% / actual >80% | email |
| AWS budget `jcode-dev-monthly-cost` | actual >100% of $10 | SNS breaker stops instance + email |
The legacy `AWS/Billing/EstimatedCharges` alarms were removed because the account was not publishing that metric. The Budget notification path is active and verified. Stopped instance cost remains approximately $6/mo for encrypted EBS plus the idle Elastic IP. The Elastic IP is retained because this existing instance needs public IPv4 for outbound Bedrock, SSM, and Tailscale connectivity when it boots; all public inbound security-group rules are closed.
## Phone flow
1. Bookmark the wake link (`https://<api-id>.execute-api.us-east-1.amazonaws.com/#t=<token>`, token stored at `~/.jcode/jcode-phone-wake-token` on the workstation). The URL fragment is not sent in HTTP requests; JavaScript exchanges it for an `Authorization: Bearer` header and keeps it in session storage.
2. Tap it: the Lambda starts the instance and polls EC2/SSM every 5 s until ready.
3. Tap "Pair this phone" → Lambda runs `jcode pair` through SSM → 6-digit code + `jcode://` deep link → opens the iOS app paired to `100.109.78.41:7643`.
4. SSH fallback: connect through Tailscale to `ec2-user@100.109.78.41`, then run `phone`.
## Security notes
- Gateway `/pair` requires a live 6-digit code (5-min TTL); WS requires the bearer token minted at pairing. Tokens are stored hashed server-side.
- Wake actions require a bearer token. The bookmark stores it in the URL fragment, which browsers do not send to API Gateway. Legacy `?t=` links redirect once to the fragment form.
- The EC2 security group has no public ingress. Gateway and SSH access are tailnet-only through `jcode-phone` (`100.109.78.41`).
- Pair generation uses SSM Run Command, so port 7644 is no longer public and the legacy pair service is disabled.
- The root EBS volume and future EBS volumes are encrypted by default.
- CloudTrail records multi-region management events to a private encrypted bucket with 90-day retention. Account-level IAM Access Analyzer and S3 Block Public Access are enabled.
- API Gateway access logs exclude query strings and authorization headers and expire after 30 days.
- IAM: the instance role has inference-only access to the configured Opus 4.6 profile plus SSM managed-instance access. Waker Lambda has start/describe EC2 plus narrowly scoped SSM command permissions. Breaker Lambda has stop/describe EC2 plus SNS publish.
- The deployment access key was rotated and the prior key is inactive. Daily maintenance can use the tested `jcode-operator` profile and `JcodeOperator` role, which cannot create IAM users or terminate instances. `jade-deploy` retains its administrator attachment only as a temporary recovery path until an independent root/MFA login is verified; see `IAM-LEAST-PRIVILEGE.md`.
## Rebuild from scratch (≈15 min)
1. Launch AL2023 x86_64 with an encrypted 30GB gp3 root volume, the Bedrock + SSM instance role, and a security group with no inbound rules. Associate an Elastic IP for outbound internet while running.
2. Install jcode, Tailscale, tmux, and git. Join the tailnet as `jcode-phone`; set the jcode gateway host to `100.109.78.41`.
3. Copy `units/jcode-serve.service` and the idle-autostop unit/script; enable linger and the required services. The legacy pair service is not required.
4. Deploy `wake-lambda.py` as `waker.py`, set `WAKE_TOKEN`, `INSTANCE_ID`, and `JCODE_GATEWAY_HOST=100.109.78.41`, and grant scoped EC2/SSM permissions. API Gateway HTTP API routes to the Lambda.
5. Create the SNS topics/subscriptions and connect the $10 Budget's 100% actual notification to `jcode-guard-stop`.
6. Enable CloudTrail, Access Analyzer, account-level S3 Block Public Access, EBS encryption by default, and bounded CloudWatch log retention.
7. Test: breaker stops the host; wake link starts it; SSM reports online; tailnet `/health` works; pair button returns a code targeting the tailnet address.
+16
View File
@@ -0,0 +1,16 @@
import boto3
INSTANCE_ID = "i-08214cf66cd3f80c7"
def handler(event, context):
ec2 = boto3.client("ec2", region_name="us-east-1")
state = ec2.describe_instances(InstanceIds=[INSTANCE_ID])["Reservations"][0]["Instances"][0]["State"]["Name"]
if state == "running":
ec2.stop_instances(InstanceIds=[INSTANCE_ID])
msg = f"Circuit breaker: stopped {INSTANCE_ID} (was {state})"
else:
msg = f"Circuit breaker: {INSTANCE_ID} already {state}"
sns = boto3.client("sns", region_name="us-east-1")
sns.publish(TopicArn="arn:aws:sns:us-east-1:302154194530:jcode-guard-warn",
Subject="jcode circuit breaker fired", Message=msg)
return {"message": msg}
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# Power off after 30 min with no clients AND no active agent work.
STATE_FILE=/var/tmp/idle-since
CONNS=$(ss -Htn state established "( sport = :7643 or sport = :22 )" | wc -l)
# jcode server doing outbound work (e.g. streaming from Bedrock)?
JPID=$(pgrep -f "jcode.*serve" | head -1)
BUSY=0
if [ -n "$JPID" ]; then
OUT443=$(ss -Htnp state established "( dport = :443 )" 2>/dev/null | grep -c "pid=$JPID") || true
[ "$OUT443" -gt 0 ] && BUSY=1
fi
if [ "$CONNS" -gt 0 ] || [ "$BUSY" -gt 0 ]; then
rm -f "$STATE_FILE"
exit 0
fi
NOW=$(date +%s)
if [ ! -f "$STATE_FILE" ]; then
echo "$NOW" > "$STATE_FILE"
exit 0
fi
IDLE_SECS=$((NOW - $(cat "$STATE_FILE")))
if [ "$IDLE_SECS" -ge 1800 ]; then
logger "idle-autostop: idle ${IDLE_SECS}s, powering off"
systemctl poweroff
fi
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Token-protected HTTP service that generates jcode pairing codes.
GET /pair-code?t=<token> -> {"code": "123456", "host": "100.109.78.41", "port": 7643, "uri": "jcode://pair?..."}
"""
import http.server, json, re, subprocess, os
from urllib.parse import urlparse, parse_qs
TOKEN = open('/etc/jcode-pair-token').read().strip()
HOST = '100.109.78.41'
PORT = 7643
class H(http.server.BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Cache-Control', 'no-store')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
u = urlparse(self.path)
q = parse_qs(u.query)
if q.get('t', [''])[0] != TOKEN:
return self._send(403, {'error': 'forbidden'})
if u.path != '/pair-code':
return self._send(404, {'error': 'not found'})
env = dict(os.environ)
env['PATH'] = '/home/ec2-user/.local/bin:' + env.get('PATH', '')
env['JCODE_GATEWAY_HOST'] = HOST
try:
out = subprocess.run(
['sudo', '-u', 'ec2-user', '-i', 'jcode', 'pair'],
capture_output=True, text=True, timeout=30, env=env,
)
text = re.sub(r'\x1b\[[0-9;]*m', '', out.stdout + out.stderr)
m = re.search(r'Pairing code:\s+(\d{3})\s(\d{3})', text)
if not m:
return self._send(500, {'error': 'no code in output'})
code = m.group(1) + m.group(2)
uri = f'jcode://pair?host={HOST}&port={PORT}&code={code}'
return self._send(200, {'code': code, 'host': HOST, 'port': PORT, 'uri': uri, 'expires_in': 300})
except Exception as e:
return self._send(500, {'error': str(e)})
http.server.HTTPServer(('0.0.0.0', 7644), H).serve_forever()
+98
View File
@@ -0,0 +1,98 @@
import importlib.util
import json
import os
from pathlib import Path
import sys
import types
import unittest
from unittest.mock import patch
os.environ["WAKE_TOKEN"] = "test-token"
os.environ["JCODE_GATEWAY_HOST"] = "100.64.0.10"
sys.modules.setdefault("boto3", types.ModuleType("boto3"))
sys.modules["boto3"].client = lambda *_args, **_kwargs: None
SPEC = importlib.util.spec_from_file_location(
"wake_lambda", Path(__file__).with_name("wake-lambda.py")
)
wake = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(wake)
class FakeEc2:
def __init__(self, state="running"):
self.state = state
self.started = False
def describe_instances(self, **_kwargs):
return {"Reservations": [{"Instances": [{"State": {"Name": self.state}}]}]}
def start_instances(self, **_kwargs):
self.started = True
class InvocationDoesNotExist(Exception):
pass
class FakeSsm:
class exceptions:
InvocationDoesNotExist = InvocationDoesNotExist
def describe_instance_information(self, **_kwargs):
return {"InstanceInformationList": [{"PingStatus": "Online"}]}
def send_command(self, **_kwargs):
return {"Command": {"CommandId": "command-1"}}
def get_command_invocation(self, **_kwargs):
return {
"Status": "Success",
"StandardOutputContent": "",
"StandardErrorContent": "Pairing code: \x1b[1;37m123 456\x1b[0m",
}
class WakeLambdaTests(unittest.TestCase):
def event(self, method, *, token=None, body=None, query=None):
headers = {"Authorization": f"Bearer {token}"} if token else {}
return {
"requestContext": {"http": {"method": method}},
"headers": headers,
"queryStringParameters": query,
"body": json.dumps(body) if body is not None else None,
}
def test_landing_page_never_embeds_token(self):
result = wake.handler(self.event("GET"), None)
self.assertEqual(result["statusCode"], 200)
self.assertNotIn("test-token", result["body"])
self.assertIn("Content-Security-Policy", result["headers"])
def test_legacy_query_redirects_token_into_fragment(self):
result = wake.handler(self.event("GET", query={"t": "test-token"}), None)
self.assertEqual(result["statusCode"], 302)
self.assertEqual(result["headers"]["Location"], "/#t=test-token")
def test_post_requires_bearer_token(self):
result = wake.handler(self.event("POST", body={"action": "status"}), None)
self.assertEqual(result["statusCode"], 403)
def test_status_uses_ec2_and_ssm_health(self):
ec2, ssm = FakeEc2(), FakeSsm()
with patch.object(wake.boto3, "client", side_effect=[ec2, ssm]):
result = wake.handler(
self.event("POST", token="test-token", body={"action": "status"}), None
)
self.assertEqual(result["statusCode"], 200)
self.assertTrue(json.loads(result["body"])["healthy"])
def test_pairing_parses_ansi_output_and_returns_tailnet_host(self):
result = wake.fetch_pair_code(FakeSsm())
self.assertEqual(result["code"], "123456")
self.assertEqual(result["host"], "100.64.0.10")
self.assertEqual(result["uri"], "jcode://pair?host=100.64.0.10&port=7643&code=123456")
if __name__ == "__main__":
unittest.main()
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Post-upload TestFlight setup for JCodeMobile.
Run after a build reaches App Store Connect (PLA must be accepted):
/tmp/ascvenv/bin/python scripts/phone-server/testflight-setup.py
Idempotent. Does three things:
1. Finds the com.jcode.mobile app and its latest build.
2. Ensures an internal beta group exists with the account holder as tester.
3. Assigns the latest build to that group so it is installable in TestFlight.
"""
import json
import sys
import time
import urllib.request
import urllib.error
KEY_ID = "XJKP4235XC"
ISSUER = "f1147f07-48fe-4850-9171-f37d4b2dee41"
KEY_PATH = "/home/jeremy/Downloads/AuthKey_XJKP4235XC.p8"
BUNDLE_ID = "com.jcode.mobile"
TESTER_EMAIL = "jeremyhuang55555@gmail.com"
GROUP_NAME = "internal"
API = "https://api.appstoreconnect.apple.com/v1"
def token():
import jwt
key = open(KEY_PATH).read()
return jwt.encode(
{
"iss": ISSUER,
"iat": int(time.time()),
"exp": int(time.time()) + 1200,
"aud": "appstoreconnect-v1",
},
key,
algorithm="ES256",
headers={"kid": KEY_ID},
)
def req(method, path, body=None):
url = path if path.startswith("http") else API + path
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(url, data=data, method=method)
r.add_header("Authorization", f"Bearer {token()}")
if data:
r.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(r, timeout=30) as resp:
raw = resp.read().decode()
return resp.status, json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read().decode() or "{}")
def main():
st, apps = req("GET", f"/apps?filter[bundleId]={BUNDLE_ID}")
if st != 200 or not apps.get("data"):
print(f"app lookup failed ({st}): {json.dumps(apps)[:300]}")
return 1
app_id = apps["data"][0]["id"]
print(f"app: {app_id}")
st, builds = req(
"GET",
f"/builds?filter[app]={app_id}&sort=-uploadedDate&limit=1",
)
if st != 200 or not builds.get("data"):
print(f"no builds yet ({st}): {json.dumps(builds)[:300]}")
return 1
build = builds["data"][0]
build_id = build["id"]
attrs = build["attributes"]
print(f"latest build: {attrs.get('version')} ({attrs.get('processingState')})")
# Beta group (create if missing)
st, groups = req(
"GET", f"/betaGroups?filter[app]={app_id}&filter[name]={GROUP_NAME}"
)
if groups.get("data"):
group_id = groups["data"][0]["id"]
else:
st, g = req(
"POST",
"/betaGroups",
{
"data": {
"type": "betaGroups",
"attributes": {"name": GROUP_NAME, "isInternalGroup": True},
"relationships": {
"app": {"data": {"type": "apps", "id": app_id}}
},
}
},
)
if st not in (200, 201):
print(f"group create failed ({st}): {json.dumps(g)[:300]}")
return 1
group_id = g["data"]["id"]
print(f"group: {group_id}")
# Tester (create if missing, then add to group)
st, testers = req("GET", f"/betaTesters?filter[email]={TESTER_EMAIL}")
if testers.get("data"):
tester_id = testers["data"][0]["id"]
else:
st, t = req(
"POST",
"/betaTesters",
{
"data": {
"type": "betaTesters",
"attributes": {"email": TESTER_EMAIL, "firstName": "Jeremy"},
"relationships": {
"betaGroups": {
"data": [{"type": "betaGroups", "id": group_id}]
}
},
}
},
)
if st not in (200, 201):
print(f"tester create failed ({st}): {json.dumps(t)[:300]}")
return 1
tester_id = t["data"]["id"]
print(f"tester: {tester_id}")
# Make sure tester is in the group (no-op if already)
req(
"POST",
f"/betaGroups/{group_id}/relationships/betaTesters",
{"data": [{"type": "betaTesters", "id": tester_id}]},
)
# Assign build to group
st, r = req(
"POST",
f"/betaGroups/{group_id}/relationships/builds",
{"data": [{"type": "builds", "id": build_id}]},
)
if st not in (200, 201, 204):
print(f"build assign failed ({st}): {json.dumps(r)[:300]}")
return 1
print("build assigned to group. TestFlight install should be available.")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,5 @@
[Unit]
Description=Stop instance when idle
[Service]
Type=oneshot
ExecStart=/usr/local/bin/idle-autostop.sh
@@ -0,0 +1,7 @@
[Unit]
Description=Idle auto-stop check every 5 min
[Timer]
OnBootSec=10min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
@@ -0,0 +1,11 @@
[Unit]
Description=jcode pairing code service
After=network.target
[Service]
ExecStart=/usr/bin/python3 /usr/local/bin/jcode-pair-service.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,16 @@
[Unit]
Description=jcode server with WebSocket gateway
After=network.target
[Service]
Environment=PATH=/home/ec2-user/.local/bin:/usr/local/bin:/usr/bin:/bin
Environment=JCODE_BEDROCK_ENABLE=1
Environment=AWS_REGION=us-east-1
Environment=JCODE_BEDROCK_MODEL=us.anthropic.claude-opus-4-6-v1
ExecStartPre=/bin/rm -f /run/user/1000/jcode-daemon.lock /run/user/1000/jcode.sock /run/user/1000/jcode.sock.hash /run/user/1000/jcode-debug.sock
ExecStart=/home/ec2-user/.local/bin/jcode --provider bedrock serve
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
+199
View File
@@ -0,0 +1,199 @@
import boto3
import hmac
import json
import os
import re
import secrets
import shlex
import time
from urllib.parse import quote
INSTANCE_ID = os.environ.get("INSTANCE_ID", "i-08214cf66cd3f80c7")
TOKEN = os.environ.get("WAKE_TOKEN", "REPLACE_WITH_WAKE_TOKEN")
HOST = os.environ.get("JCODE_GATEWAY_HOST", "100.109.78.41")
PORT = int(os.environ.get("JCODE_GATEWAY_PORT", "7643"))
REGION = os.environ.get("AWS_REGION", "us-east-1")
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
PAIRING_CODE_RE = re.compile(r"Pairing code:\s+(\d{3})\s+(\d{3})")
def response(status, body="", content_type="application/json", headers=None):
result_headers = {
"Cache-Control": "no-store",
"Content-Type": content_type,
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
}
if headers:
result_headers.update(headers)
return {"statusCode": status, "headers": result_headers, "body": body}
def json_response(status, obj):
return response(status, json.dumps(obj))
def authorized(event):
headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
supplied = headers.get("authorization", "")
expected = f"Bearer {TOKEN}"
return hmac.compare_digest(supplied, expected)
def instance_state(ec2):
instance = ec2.describe_instances(InstanceIds=[INSTANCE_ID])["Reservations"][0]["Instances"][0]
return instance["State"]["Name"]
def ssm_online(ssm):
result = ssm.describe_instance_information(
Filters=[{"Key": "InstanceIds", "Values": [INSTANCE_ID]}],
MaxResults=5,
)
return any(item.get("PingStatus") == "Online" for item in result.get("InstanceInformationList", []))
def fetch_pair_code(ssm):
host = shlex.quote(HOST)
command = (
"sudo -iu ec2-user env "
f"JCODE_GATEWAY_HOST={host} "
"/home/ec2-user/.local/bin/jcode pair"
)
command_id = ssm.send_command(
InstanceIds=[INSTANCE_ID],
DocumentName="AWS-RunShellScript",
Parameters={"commands": [command]},
TimeoutSeconds=35,
)["Command"]["CommandId"]
deadline = time.monotonic() + 35
invocation = None
while time.monotonic() < deadline:
try:
invocation = ssm.get_command_invocation(CommandId=command_id, InstanceId=INSTANCE_ID)
except ssm.exceptions.InvocationDoesNotExist:
time.sleep(1)
continue
if invocation["Status"] in {"Success", "Failed", "Cancelled", "TimedOut"}:
break
time.sleep(1)
if not invocation or invocation.get("Status") != "Success":
status = invocation.get("Status", "TimedOut") if invocation else "TimedOut"
return {"error": f"pair command {status.lower()}"}
text = ANSI_RE.sub(
"",
invocation.get("StandardOutputContent", "") + invocation.get("StandardErrorContent", ""),
)
match = PAIRING_CODE_RE.search(text)
if not match:
return {"error": "no pairing code in command output"}
code = match.group(1) + match.group(2)
return {
"code": code,
"host": HOST,
"port": PORT,
"uri": f"jcode://pair?host={HOST}&port={PORT}&code={code}",
"expires_in": 300,
}
def landing_page():
nonce = secrets.token_urlsafe(18)
html = """<!doctype html><html><head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>jcode server</title>
<style nonce="__NONCE__">
body{font-family:-apple-system,system-ui;background:#101314;color:#eee;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
.card{text-align:center;padding:32px;max-width:360px}h1{color:#4DD9A6;font-size:1.6em;margin-bottom:8px}
#s{font-size:1.05em;line-height:1.5}.dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px;background:#e6b450}
.ok .dot{background:#4DD9A6}.ok #s b{color:#4DD9A6}.spin{opacity:.75;font-size:.9em;margin-top:14px}
#pairbtn{display:none;margin-top:22px;background:#4DD9A6;color:#0c0f10;border:0;border-radius:12px;padding:14px 22px;font-size:1.05em;font-weight:600}
#pairout{margin-top:16px;font-size:1em;line-height:1.6}#pairout .code{font-size:1.9em;letter-spacing:.18em;color:#4DD9A6;font-weight:700}#pairout a{color:#4DD9A6}
</style></head><body><div class="card" id="c">
<h1>jcode server</h1><p id="s"><span class="dot"></span>Authenticating…</p>
<p class="spin" id="hint">checking every 5s…</p><button id="pairbtn">Pair this phone</button><div id="pairout"></div>
<script nonce="__NONCE__">
const fragment = new URLSearchParams(location.hash.slice(1));
if (fragment.get('t')) sessionStorage.setItem('jcode-wake-token', fragment.get('t'));
history.replaceState(null, '', location.pathname);
const token = sessionStorage.getItem('jcode-wake-token');
const api = async action => {
if (!token) throw new Error('missing token; open the saved wake link');
const r = await fetch(location.pathname, {method:'POST', cache:'no-store',
headers:{'Authorization':'Bearer '+token,'Content-Type':'application/json'},
body:JSON.stringify({action})});
if (!r.ok) throw new Error(r.status === 403 ? 'invalid token' : 'request failed: '+r.status);
return r.json();
};
async function poll(){
try{
const j=await api('status'),s=document.getElementById('s'),c=document.getElementById('c');
if(j.healthy){c.classList.add('ok');s.innerHTML='<span class="dot"></span><b>Ready.</b> Open the jcode app now.';document.getElementById('hint').textContent='server is up';document.getElementById('pairbtn').style.display='inline-block';return;}
s.innerHTML='<span class="dot"></span>Instance: '+j.state+' · services warming up…';
}catch(e){document.getElementById('s').textContent=e.message;document.getElementById('hint').textContent='';return;}
setTimeout(poll,5000);
}
async function pair(){
const o=document.getElementById('pairout');o.textContent='generating code…';
try{const j=await api('pair');if(j.code){o.innerHTML='<div class="code">'+j.code.slice(0,3)+' '+j.code.slice(3)+'</div><div>host '+j.host+':'+j.port+' · expires in 5 min</div><div style="margin-top:10px"><a href="'+j.uri+'">Open in jcode app</a></div>';}else{o.textContent='error: '+(j.error||'unknown');}}catch(e){o.textContent='error: '+e.message;}
}
document.getElementById('pairbtn').addEventListener('click',pair);
(async()=>{try{await api('wake');poll();}catch(e){document.getElementById('s').textContent=e.message;document.getElementById('hint').textContent='';}})();
</script></div></body></html>""".replace("__NONCE__", nonce)
csp = (
"default-src 'none'; "
f"style-src 'nonce-{nonce}'; script-src 'nonce-{nonce}'; "
"connect-src 'self'; base-uri 'none'; frame-ancestors 'none'"
)
return response(200, html, "text/html; charset=utf-8", {"Content-Security-Policy": csp})
def handler(event, context):
method = (
event.get("requestContext", {}).get("http", {}).get("method")
or event.get("httpMethod")
or "GET"
).upper()
if method == "GET":
legacy_token = (event.get("queryStringParameters") or {}).get("t")
if legacy_token and hmac.compare_digest(legacy_token, TOKEN):
return response(302, headers={"Location": f"/#t={quote(TOKEN, safe='')}"})
return landing_page()
if method != "POST":
return json_response(405, {"error": "method not allowed"})
if not authorized(event):
return json_response(403, {"error": "forbidden"})
try:
payload = json.loads(event.get("body") or "{}")
except json.JSONDecodeError:
return json_response(400, {"error": "invalid JSON"})
action = payload.get("action")
ec2 = boto3.client("ec2", region_name=REGION)
ssm = boto3.client("ssm", region_name=REGION)
state = instance_state(ec2)
if action == "wake":
started = state == "stopped"
if started:
ec2.start_instances(InstanceIds=[INSTANCE_ID])
return json_response(200, {"state": state, "started": started})
if action == "status":
healthy = state == "running" and ssm_online(ssm)
return json_response(200, {"state": state, "healthy": healthy})
if action == "pair":
if state != "running":
return json_response(409, {"error": f"instance {state}, wake it first"})
if not ssm_online(ssm):
return json_response(503, {"error": "instance management agent is not ready"})
result = fetch_pair_code(ssm)
return json_response(200 if "code" in result else 500, result)
return json_response(400, {"error": "unknown action"})