feat: webserver proxy (#116)

This commit is contained in:
Julian Sommer
2026-04-08 11:46:43 +10:00
committed by GitHub
parent 820b605c0b
commit 45ea467fcc
37 changed files with 522 additions and 334 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key
ReadWritePaths=/var/lib/spinifex /var/log/spinifex /etc/spinifex/bootstrap.json /tmp
ReadWritePaths=/var/lib/spinifex /var/log/spinifex /etc/spinifex /tmp
# Hardening
ProtectHome=yes
+6
View File
@@ -336,6 +336,12 @@ func SetServiceOwnership() {
}
}
// /etc/spinifex needs group-write so awsgw (spinifex-gw) can delete
// bootstrap.json after consuming it on first boot.
if err := os.Chmod("/etc/spinifex", 0770); err != nil { //nolint:gosec // directory needs group-write for bootstrap.json deletion
slog.Warn("SetServiceOwnership: chmod /etc/spinifex failed", "err", err)
}
// Shared config files — root:spinifex, ca.key stays root:root 0600
for path, mode := range map[string]os.FileMode{
"/etc/spinifex/spinifex.toml": 0640,
@@ -51,6 +51,10 @@ func ValidateRunInstancesInput(input *ec2.RunInstancesInput) (err error) {
return errors.New(awserrors.ErrorMissingParameter)
}
if input.KeyName == nil || *input.KeyName == "" {
return errors.New(awserrors.ErrorMissingParameter)
}
if !strings.HasPrefix(*input.ImageId, "ami-") {
return errors.New(awserrors.ErrorInvalidAMIIDMalformed)
}
@@ -572,6 +572,7 @@ func TestRunInstances_SingleInstanceDistributes(t *testing.T) {
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
InstanceType: aws.String("t3.micro"),
KeyName: aws.String("test-key"),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
}
@@ -1073,6 +1074,7 @@ func TestRunInstances_ClusterPlacementGroupRouting(t *testing.T) {
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
InstanceType: aws.String("t3.micro"),
KeyName: aws.String("test-key"),
MinCount: aws.Int64(2),
MaxCount: aws.Int64(2),
Placement: &ec2.Placement{
@@ -1123,6 +1125,7 @@ func TestRunInstances_MultiInstanceUsesDistribution(t *testing.T) {
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
InstanceType: aws.String("t3.micro"),
KeyName: aws.String("test-key"),
MinCount: aws.Int64(2),
MaxCount: aws.Int64(2),
}
+7 -2
View File
@@ -107,8 +107,12 @@ func launchService(config *config.ClusterConfig) error {
return fmt.Errorf("initialize IAM service: %w", err)
}
// First boot: consume bootstrap.json → seed IAM users into NATS KV → delete file
// First boot: consume bootstrap.json → seed IAM users into NATS KV → delete file.
// Resolve symlinks so the path matches systemd ReadWritePaths for deletion.
bootstrapPath := filepath.Join(nodeConfig.BaseDir, "config", "bootstrap.json")
if resolved, err := filepath.EvalSymlinks(bootstrapPath); err == nil {
bootstrapPath = resolved
}
data, err := handlers_iam.LoadBootstrapData(bootstrapPath)
switch {
case err == nil:
@@ -118,8 +122,9 @@ func launchService(config *config.ClusterConfig) error {
}
if err := os.Remove(bootstrapPath); err != nil {
slog.Warn("Failed to delete bootstrap file", "path", bootstrapPath, "err", err)
} else {
slog.Info("Bootstrap complete, bootstrap.json deleted")
}
slog.Info("Bootstrap complete, bootstrap.json deleted")
case os.IsNotExist(err):
// No bootstrap file — normal after first boot
default:
@@ -1 +1 @@
import{It as e,Lt as t,Vt as n,pt as r}from"./awsClient.js";var i=class extends n.classBuilder().ep(e).m(function(e,n,r,i){return[t(r,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DescribeInstances`,{}).n(`EC2Client`,`DescribeInstancesCommand`).sc(r).build(){};export{i as t};
import{It as e,Rt as t,pt as n,zt as r}from"./awsClient.js";var i=class extends t.classBuilder().ep(e).m(function(e,t,n,i){return[r(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DescribeInstances`,{}).n(`EC2Client`,`DescribeInstancesCommand`).sc(n).build(){};export{i as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
import{A as e,D as t,E as n,O as r,T as i,k as a,w as o}from"./SignatureV4.js";import{F as s,I as c,P as l}from"./ec2.js";var u=/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/,d=e().min(1,`Key name is required`).max(255,`Key name must be 255 characters or less`).regex(/^[\w\s._\-:/()#,@[\]+=&;{}!$*]+$/,`Key name contains invalid characters`),f=a({imageId:e(`Please select an Image`),instanceType:e(`Please select an instance type`),keyName:e(`Please select a key pair`),subnetId:e().optional(),placementGroupName:e().optional(),count:t(`Instance count must be a whole number`).min(1,`Instance count must be at least 1`)}),p=a({keyName:d}),m=a({keyName:d,publicKeyMaterial:e().min(1,`Public key is required`).refine(e=>e.trim().length>0,`Public key cannot be empty`)}),h=a({size:r().int(`Size must be a whole number`).min(1,`Size must be at least 1 GiB`).max(16384,`Size must be at most 16384 GiB`),availabilityZone:e().min(1,`Availability zone is required`)}),g=a({size:r().int(`Size must be a whole number`).min(1,`Size must be at least 1 GiB`)}),_=a({volumeId:e().min(1,`Volume is required`),description:e().optional()});a({sourceSnapshotId:e().min(1,`Source snapshot is required`),sourceRegion:e().min(1,`Source region is required`),description:e().optional()}),a({volumeId:e().min(1,`Volume is required`),instanceId:e().min(1,`Instance is required`),device:e().optional()}),a({volumeId:e().min(1,`Volume is required`),instanceId:e().optional(),force:n().optional()}),a({instanceType:e().min(1,`Instance type is required`)}),a({name:e().min(1,`Name is required`),description:e().optional()});var v=a({vpcId:e().min(1,`VPC is required`),cidrBlock:e().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.1.0/24)`),availabilityZone:e().optional()});a({cidrBlock:e().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.0.0/16)`),name:e().optional()});var y=a({key:e().min(1,`Key is required`),value:e()}),b=a({mode:o([`vpc-only`,`vpc-and-more`]),namePrefix:e().optional(),autoGenerateNames:n(),cidrBlock:e().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.0.0/16)`).refine(e=>c(e),`CIDR has invalid octets or prefix length (must be /16 to /28)`),tenancy:o([`default`,`dedicated`]),publicSubnetCount:r().int().min(0).max(1),privateSubnetCount:r().int().min(0).max(2),publicSubnetCidrs:i(e()),privateSubnetCidrs:i(e()),tags:i(y)}).superRefine((e,t)=>{if(e.mode!==`vpc-and-more`)return;let n=[];for(let[r,i]of e.publicSubnetCidrs.entries())c(i,16,28)?(l(e.cidrBlock,i)||t.addIssue({code:`custom`,message:`Subnet CIDR must be within the VPC CIDR range`,path:[`publicSubnetCidrs`,r]}),n.push({cidr:i,field:`publicSubnetCidrs`,index:r})):t.addIssue({code:`custom`,message:`Invalid subnet CIDR format or prefix length`,path:[`publicSubnetCidrs`,r]});for(let[r,i]of e.privateSubnetCidrs.entries())c(i,16,28)?(l(e.cidrBlock,i)||t.addIssue({code:`custom`,message:`Subnet CIDR must be within the VPC CIDR range`,path:[`privateSubnetCidrs`,r]}),n.push({cidr:i,field:`privateSubnetCidrs`,index:r})):t.addIssue({code:`custom`,message:`Invalid subnet CIDR format or prefix length`,path:[`privateSubnetCidrs`,r]});for(let e=0;e<n.length;e++)for(let r=e+1;r<n.length;r++){let i=n[e],a=n[r];i&&a&&s(i.cidr,a.cidr)&&t.addIssue({code:`custom`,message:`Subnet CIDRs must not overlap`,path:[a.field,a.index]})}}),x=a({groupName:e().min(1,`Group name is required`).max(255,`Group name must be 255 characters or less`),strategy:e().min(1,`Strategy is required`)}),S=a({groupName:e().min(1,`Group name is required`).max(255,`Group name must be 255 characters or less`),description:e().min(1,`Description is required`).max(255,`Description must be 255 characters or less`),vpcId:e().min(1,`VPC is required`)}),C=a({ipProtocol:e().min(1,`Protocol is required`),fromPort:r().int().min(-1).max(65535),toPort:r().int().min(-1).max(65535),cidrIp:e().min(1,`CIDR is required`).regex(u,`Must be a valid CIDR block (e.g. 0.0.0.0/0)`)});export{_ as a,b as c,C as d,S as i,m as l,p as n,v as o,x as r,h as s,f as t,g as u};
import{A as e,D as t,E as n,O as r,T as i,k as a,w as o}from"./SignatureV4.js";import{F as s,I as c,P as l}from"./ec2.js";var u=/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/,d=e().min(1,`Key name is required`).max(255,`Key name must be 255 characters or less`).regex(/^[\w\s._\-:/()#,@[\]+=&;{}!$*]+$/,`Key name contains invalid characters`),f=a({imageId:e(`Please select an Image`),instanceType:e(`Please select an instance type`),keyName:e(`Please select a key pair`).min(1,`Key pair is required`),subnetId:e().optional(),placementGroupName:e().optional(),count:t(`Instance count must be a whole number`).min(1,`Instance count must be at least 1`)}),p=a({keyName:d}),m=a({keyName:d,publicKeyMaterial:e().min(1,`Public key is required`).refine(e=>e.trim().length>0,`Public key cannot be empty`)}),h=a({size:r().int(`Size must be a whole number`).min(1,`Size must be at least 1 GiB`).max(16384,`Size must be at most 16384 GiB`),availabilityZone:e().min(1,`Availability zone is required`)}),g=a({size:r().int(`Size must be a whole number`).min(1,`Size must be at least 1 GiB`)}),_=a({volumeId:e().min(1,`Volume is required`),description:e().optional()});a({sourceSnapshotId:e().min(1,`Source snapshot is required`),sourceRegion:e().min(1,`Source region is required`),description:e().optional()}),a({volumeId:e().min(1,`Volume is required`),instanceId:e().min(1,`Instance is required`),device:e().optional()}),a({volumeId:e().min(1,`Volume is required`),instanceId:e().optional(),force:n().optional()}),a({instanceType:e().min(1,`Instance type is required`)}),a({name:e().min(1,`Name is required`),description:e().optional()});var v=a({vpcId:e().min(1,`VPC is required`),cidrBlock:e().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.1.0/24)`),availabilityZone:e().optional()});a({cidrBlock:e().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.0.0/16)`),name:e().optional()});var y=a({key:e().min(1,`Key is required`),value:e()}),b=a({mode:o([`vpc-only`,`vpc-and-more`]),namePrefix:e().optional(),autoGenerateNames:n(),cidrBlock:e().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.0.0/16)`).refine(e=>c(e),`CIDR has invalid octets or prefix length (must be /16 to /28)`),tenancy:o([`default`,`dedicated`]),publicSubnetCount:r().int().min(0).max(1),privateSubnetCount:r().int().min(0).max(2),publicSubnetCidrs:i(e()),privateSubnetCidrs:i(e()),tags:i(y)}).superRefine((e,t)=>{if(e.mode!==`vpc-and-more`)return;let n=[];for(let[r,i]of e.publicSubnetCidrs.entries())c(i,16,28)?(l(e.cidrBlock,i)||t.addIssue({code:`custom`,message:`Subnet CIDR must be within the VPC CIDR range`,path:[`publicSubnetCidrs`,r]}),n.push({cidr:i,field:`publicSubnetCidrs`,index:r})):t.addIssue({code:`custom`,message:`Invalid subnet CIDR format or prefix length`,path:[`publicSubnetCidrs`,r]});for(let[r,i]of e.privateSubnetCidrs.entries())c(i,16,28)?(l(e.cidrBlock,i)||t.addIssue({code:`custom`,message:`Subnet CIDR must be within the VPC CIDR range`,path:[`privateSubnetCidrs`,r]}),n.push({cidr:i,field:`privateSubnetCidrs`,index:r})):t.addIssue({code:`custom`,message:`Invalid subnet CIDR format or prefix length`,path:[`privateSubnetCidrs`,r]});for(let e=0;e<n.length;e++)for(let r=e+1;r<n.length;r++){let i=n[e],a=n[r];i&&a&&s(i.cidr,a.cidr)&&t.addIssue({code:`custom`,message:`Subnet CIDRs must not overlap`,path:[a.field,a.index]})}}),x=a({groupName:e().min(1,`Group name is required`).max(255,`Group name must be 255 characters or less`),strategy:e().min(1,`Strategy is required`)}),S=a({groupName:e().min(1,`Group name is required`).max(255,`Group name must be 255 characters or less`),description:e().min(1,`Description is required`).max(255,`Description must be 255 characters or less`),vpcId:e().min(1,`VPC is required`)}),C=a({ipProtocol:e().min(1,`Protocol is required`),fromPort:r().int().min(-1).max(65535),toPort:r().int().min(-1).max(65535),cidrIp:e().min(1,`CIDR is required`).regex(u,`Must be a valid CIDR block (e.g. 0.0.0.0/0)`)});export{_ as a,b as c,C as d,S as i,m as l,p as n,v as o,x as r,h as s,f as t,g as u};
+1 -1
View File
@@ -1 +1 @@
import{t as e}from"./compiler-runtime.js";import{n as t}from"./QueryClientProvider.js";import{n}from"./error-banner.js";import{A as r,C as i,D as a,E as o,Lt as s,O as c,R as l,T as u,Vt as d,k as f,r as p,w as m,z as h}from"./awsClient.js";var g=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`AttachUserPolicy`,{}).n(`IAMClient`,`AttachUserPolicyCommand`).sc(i).build(){},_=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreateAccessKey`,{}).n(`IAMClient`,`CreateAccessKeyCommand`).sc(m).build(){},v=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreatePolicy`,{}).n(`IAMClient`,`CreatePolicyCommand`).sc(u).build(){},y=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreateUser`,{}).n(`IAMClient`,`CreateUserCommand`).sc(o).build(){},b=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeleteAccessKey`,{}).n(`IAMClient`,`DeleteAccessKeyCommand`).sc(a).build(){},x=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeletePolicy`,{}).n(`IAMClient`,`DeletePolicyCommand`).sc(c).build(){},S=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeleteUser`,{}).n(`IAMClient`,`DeleteUserCommand`).sc(f).build(){},C=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DetachUserPolicy`,{}).n(`IAMClient`,`DetachUserPolicyCommand`).sc(r).build(){},w=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`UpdateAccessKey`,{}).n(`IAMClient`,`UpdateAccessKeyCommand`).sc(l).build(){},T=e();function E(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:D,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`users`]})}},e[0]=r,e[1]=i),n(i)}function D(e){let t=new y({UserName:e.userName,Path:e.path||void 0});return p().send(t)}function O(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:k,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`users`]})}},e[0]=r,e[1]=i),n(i)}function k(e){let t=new S({UserName:e});return p().send(t)}function A(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:j,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},e[0]=r,e[1]=i),n(i)}function j(e){let t=new _({UserName:e});return p().send(t)}function M(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:N,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},e[0]=r,e[1]=i),n(i)}function N(e){let{userName:t,accessKeyId:n}=e,r=new b({UserName:t,AccessKeyId:n});return p().send(r)}function P(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:F,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},e[0]=r,e[1]=i),n(i)}function F(e){let{userName:t,accessKeyId:n,status:r}=e,i=new w({UserName:t,AccessKeyId:n,Status:r});return p().send(i)}function I(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:L,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`policies`]})}},e[0]=r,e[1]=i),n(i)}function L(e){let t=new v({PolicyName:e.policyName,Description:e.description||void 0,PolicyDocument:e.policyDocument});return p().send(t)}function R(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:z,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`policies`]})}},e[0]=r,e[1]=i),n(i)}function z(e){let t=new x({PolicyArn:e});return p().send(t)}function B(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:V,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`attached-user-policies`]})}},e[0]=r,e[1]=i),n(i)}function V(e){let{userName:t,policyArn:n}=e,r=new g({UserName:t,PolicyArn:n});return p().send(r)}function H(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:U,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`attached-user-policies`]})}},e[0]=r,e[1]=i),n(i)}function U(e){let{userName:t,policyArn:n}=e,r=new C({UserName:t,PolicyArn:n});return p().send(r)}export{M as a,H as c,E as i,P as l,A as n,R as o,I as r,O as s,B as t};
import{t as e}from"./compiler-runtime.js";import{n as t}from"./QueryClientProvider.js";import{n}from"./error-banner.js";import{A as r,C as i,D as a,E as o,O as s,R as c,Rt as l,T as u,k as d,r as f,w as p,z as m,zt as h}from"./awsClient.js";var g=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`AttachUserPolicy`,{}).n(`IAMClient`,`AttachUserPolicyCommand`).sc(i).build(){},_=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreateAccessKey`,{}).n(`IAMClient`,`CreateAccessKeyCommand`).sc(p).build(){},v=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreatePolicy`,{}).n(`IAMClient`,`CreatePolicyCommand`).sc(u).build(){},y=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreateUser`,{}).n(`IAMClient`,`CreateUserCommand`).sc(o).build(){},b=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeleteAccessKey`,{}).n(`IAMClient`,`DeleteAccessKeyCommand`).sc(a).build(){},x=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeletePolicy`,{}).n(`IAMClient`,`DeletePolicyCommand`).sc(s).build(){},S=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeleteUser`,{}).n(`IAMClient`,`DeleteUserCommand`).sc(d).build(){},C=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DetachUserPolicy`,{}).n(`IAMClient`,`DetachUserPolicyCommand`).sc(r).build(){},w=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`UpdateAccessKey`,{}).n(`IAMClient`,`UpdateAccessKeyCommand`).sc(c).build(){},T=e();function E(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:D,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`users`]})}},e[0]=r,e[1]=i),n(i)}function D(e){let t=new y({UserName:e.userName,Path:e.path||void 0});return f().send(t)}function O(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:k,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`users`]})}},e[0]=r,e[1]=i),n(i)}function k(e){let t=new S({UserName:e});return f().send(t)}function A(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:j,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},e[0]=r,e[1]=i),n(i)}function j(e){let t=new _({UserName:e});return f().send(t)}function M(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:N,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},e[0]=r,e[1]=i),n(i)}function N(e){let{userName:t,accessKeyId:n}=e,r=new b({UserName:t,AccessKeyId:n});return f().send(r)}function P(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:F,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},e[0]=r,e[1]=i),n(i)}function F(e){let{userName:t,accessKeyId:n,status:r}=e,i=new w({UserName:t,AccessKeyId:n,Status:r});return f().send(i)}function I(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:L,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`policies`]})}},e[0]=r,e[1]=i),n(i)}function L(e){let t=new v({PolicyName:e.policyName,Description:e.description||void 0,PolicyDocument:e.policyDocument});return f().send(t)}function R(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:z,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`policies`]})}},e[0]=r,e[1]=i),n(i)}function z(e){let t=new x({PolicyArn:e});return f().send(t)}function B(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:V,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`attached-user-policies`]})}},e[0]=r,e[1]=i),n(i)}function V(e){let{userName:t,policyArn:n}=e,r=new g({UserName:t,PolicyArn:n});return f().send(r)}function H(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:U,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`attached-user-policies`]})}},e[0]=r,e[1]=i),n(i)}function U(e){let{userName:t,policyArn:n}=e,r=new C({UserName:t,PolicyArn:n});return f().send(r)}export{M as a,H as c,E as i,P as l,A as n,R as o,I as r,O as s,B as t};
+1 -1
View File
@@ -1 +1 @@
import{t as e}from"./queryOptions.js";import{F as t,I as n,L as r,Lt as i,M as a,N as o,P as s,Vt as c,j as l,r as u,z as d}from"./awsClient.js";var f=class extends c.classBuilder().ep(d).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`GetPolicy`,{}).n(`IAMClient`,`GetPolicyCommand`).sc(l).build(){},p=class extends c.classBuilder().ep(d).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`GetPolicyVersion`,{}).n(`IAMClient`,`GetPolicyVersionCommand`).sc(a).build(){},m=class extends c.classBuilder().ep(d).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`GetUser`,{}).n(`IAMClient`,`GetUserCommand`).sc(o).build(){},h=class extends c.classBuilder().ep(d).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`ListAccessKeys`,{}).n(`IAMClient`,`ListAccessKeysCommand`).sc(s).build(){},g=class extends c.classBuilder().ep(d).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`ListAttachedUserPolicies`,{}).n(`IAMClient`,`ListAttachedUserPoliciesCommand`).sc(t).build(){},_=class extends c.classBuilder().ep(d).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`ListPolicies`,{}).n(`IAMClient`,`ListPoliciesCommand`).sc(n).build(){},v=class extends c.classBuilder().ep(d).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`ListUsers`,{}).n(`IAMClient`,`ListUsersCommand`).sc(r).build(){},y=e({queryKey:[`iam`,`users`],queryFn:()=>{let e=new v({});return u().send(e)},staleTime:3e5}),b=t=>e({queryKey:[`iam`,`users`,t],queryFn:()=>{let e=new m({UserName:t});return u().send(e)},staleTime:3e5}),x=t=>e({queryKey:[`iam`,`access-keys`,t],queryFn:()=>{let e=new h({UserName:t});return u().send(e)},staleTime:3e5}),S=e({queryKey:[`iam`,`policies`],queryFn:()=>{let e=new _({Scope:`Local`});return u().send(e)},staleTime:3e5}),C=t=>e({queryKey:[`iam`,`policies`,t],queryFn:()=>{let e=new f({PolicyArn:t});return u().send(e)},staleTime:3e5}),w=(t,n)=>e({queryKey:[`iam`,`policy-versions`,t,n],queryFn:()=>{let e=new p({PolicyArn:t,VersionId:n});return u().send(e)},staleTime:3e5}),T=t=>e({queryKey:[`iam`,`attached-user-policies`,t],queryFn:()=>{let e=new g({UserName:t});return u().send(e)},staleTime:3e5});export{w as a,C as i,T as n,b as o,S as r,y as s,x as t};
import{t as e}from"./queryOptions.js";import{F as t,I as n,L as r,M as i,N as a,P as o,Rt as s,j as c,r as l,z as u,zt as d}from"./awsClient.js";var f=class extends s.classBuilder().ep(u).m(function(e,t,n,r){return[d(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`GetPolicy`,{}).n(`IAMClient`,`GetPolicyCommand`).sc(c).build(){},p=class extends s.classBuilder().ep(u).m(function(e,t,n,r){return[d(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`GetPolicyVersion`,{}).n(`IAMClient`,`GetPolicyVersionCommand`).sc(i).build(){},m=class extends s.classBuilder().ep(u).m(function(e,t,n,r){return[d(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`GetUser`,{}).n(`IAMClient`,`GetUserCommand`).sc(a).build(){},h=class extends s.classBuilder().ep(u).m(function(e,t,n,r){return[d(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`ListAccessKeys`,{}).n(`IAMClient`,`ListAccessKeysCommand`).sc(o).build(){},g=class extends s.classBuilder().ep(u).m(function(e,t,n,r){return[d(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`ListAttachedUserPolicies`,{}).n(`IAMClient`,`ListAttachedUserPoliciesCommand`).sc(t).build(){},_=class extends s.classBuilder().ep(u).m(function(e,t,n,r){return[d(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`ListPolicies`,{}).n(`IAMClient`,`ListPoliciesCommand`).sc(n).build(){},v=class extends s.classBuilder().ep(u).m(function(e,t,n,r){return[d(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`ListUsers`,{}).n(`IAMClient`,`ListUsersCommand`).sc(r).build(){},y=e({queryKey:[`iam`,`users`],queryFn:()=>{let e=new v({});return l().send(e)},staleTime:3e5}),b=t=>e({queryKey:[`iam`,`users`,t],queryFn:()=>{let e=new m({UserName:t});return l().send(e)},staleTime:3e5}),x=t=>e({queryKey:[`iam`,`access-keys`,t],queryFn:()=>{let e=new h({UserName:t});return l().send(e)},staleTime:3e5}),S=e({queryKey:[`iam`,`policies`],queryFn:()=>{let e=new _({Scope:`Local`});return l().send(e)},staleTime:3e5}),C=t=>e({queryKey:[`iam`,`policies`,t],queryFn:()=>{let e=new f({PolicyArn:t});return l().send(e)},staleTime:3e5}),w=(t,n)=>e({queryKey:[`iam`,`policy-versions`,t,n],queryFn:()=>{let e=new p({PolicyArn:t,VersionId:n});return l().send(e)},staleTime:3e5}),T=t=>e({queryKey:[`iam`,`attached-user-policies`,t],queryFn:()=>{let e=new g({UserName:t});return l().send(e)},staleTime:3e5});export{w as a,C as i,T as n,b as o,S as r,y as s,x as t};
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{a as e,n as t,r as n}from"./compiler-runtime.js";import{r,t as i}from"./useRouter.js";import{L as a,M as o,S as s,h as c,m as l,t as u,u as d}from"./useStore.js";import{n as f,r as p,t as m}from"./matchContext.js";import{n as h}from"./redirect.js";import{t as g}from"./link.js";import{t as _}from"./useNavigate.js";var v=class{get to(){return this._to}get id(){return this._id}get path(){return this._path}get fullPath(){return this._fullPath}constructor(e){if(this.init=e=>{this.originalIndex=e.originalIndex;let t=this.options,n=!t?.path&&!t?.id;this.parentRoute=this.options.getParentRoute?.(),n?this._path=p:this.parentRoute||s();let r=n?p:t?.path;r&&r!==`/`&&(r=l(r));let i=t?.id||r,a=n?p:d([this.parentRoute.id===`__root__`?``:this.parentRoute.id,i]);r===`__root__`&&(r=`/`),a!==`__root__`&&(a=d([`/`,a]));let o=a===`__root__`?`/`:d([this.parentRoute.fullPath,r]);this._path=r,this._id=a,this._fullPath=o,this._to=c(o)},this.addChildren=e=>this._addFileChildren(e),this._addFileChildren=e=>(Array.isArray(e)&&(this.children=e),typeof e==`object`&&e&&(this.children=Object.values(e)),this),this._addFileTypes=()=>this,this.updateLoader=e=>(Object.assign(this.options,e),this),this.update=e=>(Object.assign(this.options,e),this),this.lazy=e=>(this.lazyFn=e,this),this.redirect=e=>h({from:this.fullPath,...e}),this.options=e||{},this.isRoot=!e?.getParentRoute,e?.id&&e?.path)throw Error(`Route cannot have both an 'id' and a 'path' option.`)}},y=class extends v{constructor(e){super(e)}},b=e(n(),1),x={state:void 0,get:()=>void 0,subscribe:()=>()=>{}};function S(e){let t=i(),n=b.useContext(e.from?m:f),r=e.from??n,o=r?e.from?t.stores.getMatchStoreByRouteId(r):t.stores.activeMatchStoresById.get(r):void 0,c=b.useRef(void 0);return u(o??x,n=>{if((e.shouldThrow??!0)&&!n&&s(),n===void 0)return;let r=e.select?e.select(n):n;if(e.structuralSharing??t.options.defaultStructuralSharing){let e=a(c.current,r);return c.current=e,e}return r})}function C(e){return S({from:e.from,strict:e.strict,structuralSharing:e.structuralSharing,select:t=>e.select?e.select(t.loaderData):t.loaderData})}function w(e){let{select:t,...n}=e;return S({...n,select:e=>t?t(e.loaderDeps):e.loaderDeps})}function T(e){return S({from:e.from,shouldThrow:e.shouldThrow,structuralSharing:e.structuralSharing,strict:e.strict,select:t=>{let n=e.strict===!1?t.params:t._strictParams;return e.select?e.select(n):n}})}function E(e){return S({from:e.from,strict:e.strict,shouldThrow:e.shouldThrow,structuralSharing:e.structuralSharing,select:t=>e.select?e.select(t.search):t.search})}function D(e){return S({...e,select:t=>e.select?e.select(t.context):t.context})}var O=t(),k=class extends v{constructor(e){super(e),this.useMatch=e=>S({select:e?.select,from:this.id,structuralSharing:e?.structuralSharing}),this.useRouteContext=e=>D({...e,from:this.id}),this.useSearch=e=>E({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useParams=e=>T({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useLoaderDeps=e=>w({...e,from:this.id}),this.useLoaderData=e=>C({...e,from:this.id}),this.useNavigate=()=>_({from:this.fullPath}),this.Link=b.forwardRef((e,t)=>(0,O.jsx)(g,{ref:t,from:this.fullPath,...e}))}};function A(e){return new k(e)}function j(){return e=>N(e)}var M=class extends y{constructor(e){super(e),this.useMatch=e=>S({select:e?.select,from:this.id,structuralSharing:e?.structuralSharing}),this.useRouteContext=e=>D({...e,from:this.id}),this.useSearch=e=>E({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useParams=e=>T({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useLoaderDeps=e=>w({...e,from:this.id}),this.useLoaderData=e=>C({...e,from:this.id}),this.useNavigate=()=>_({from:this.fullPath}),this.Link=b.forwardRef((e,t)=>(0,O.jsx)(g,{ref:t,from:this.fullPath,...e}))}};function N(e){return new M(e)}function P(e){return typeof e==`object`?new F(e,{silent:!0}).createRoute(e):new F(e,{silent:!0}).createRoute}var F=class{constructor(e,t){this.path=e,this.createRoute=e=>{let t=A(e);return t.isRoot=!1,t},this.silent=t?.silent}};function I(e,t){let n,i,a,s,c=()=>(n||=e().then(e=>{n=void 0,i=e[t??`default`]}).catch(e=>{if(a=e,o(a)&&a instanceof Error&&typeof window<`u`&&typeof sessionStorage<`u`){let e=`tanstack_router_reload:${a.message}`;sessionStorage.getItem(e)||(sessionStorage.setItem(e,`1`),s=!0)}}),n),l=function(e){if(s)throw window.location.reload(),new Promise(()=>{});if(a)throw a;if(!i)if(r)r(c());else throw c();return b.createElement(i,e)};return l.preload=c,l}export{P as n,j as r,I as t};
import{a as e,n as t,r as n}from"./compiler-runtime.js";import{B as r,P as i,_ as a,f as o,g as s,i as c,t as l,w as u,z as d}from"./useStore.js";import{n as f,r as p,t as m}from"./matchContext.js";import{n as h}from"./redirect.js";import{t as g}from"./link.js";import{t as _}from"./useNavigate.js";var v=class{get to(){return this._to}get id(){return this._id}get path(){return this._path}get fullPath(){return this._fullPath}constructor(e){if(this.init=e=>{this.originalIndex=e.originalIndex;let t=this.options,n=!t?.path&&!t?.id;this.parentRoute=this.options.getParentRoute?.(),n?this._path=p:this.parentRoute||u();let r=n?p:t?.path;r&&r!==`/`&&(r=s(r));let i=t?.id||r,c=n?p:o([this.parentRoute.id===`__root__`?``:this.parentRoute.id,i]);r===`__root__`&&(r=`/`),c!==`__root__`&&(c=o([`/`,c]));let l=c===`__root__`?`/`:o([this.parentRoute.fullPath,r]);this._path=r,this._id=c,this._fullPath=l,this._to=a(l)},this.addChildren=e=>this._addFileChildren(e),this._addFileChildren=e=>(Array.isArray(e)&&(this.children=e),typeof e==`object`&&e&&(this.children=Object.values(e)),this),this._addFileTypes=()=>this,this.updateLoader=e=>(Object.assign(this.options,e),this),this.update=e=>(Object.assign(this.options,e),this),this.lazy=e=>(this.lazyFn=e,this),this.redirect=e=>h({from:this.fullPath,...e}),this.options=e||{},this.isRoot=!e?.getParentRoute,e?.id&&e?.path)throw Error(`Route cannot have both an 'id' and a 'path' option.`)}},y=class extends v{constructor(e){super(e)}},b=e(n(),1),x={state:void 0,get:()=>void 0,subscribe:()=>()=>{}};function S(e){let t=c(),n=b.useContext(e.from?m:f),r=e.from??n,i=r?e.from?t.stores.getMatchStoreByRouteId(r):t.stores.activeMatchStoresById.get(r):void 0,a=b.useRef(void 0);return l(i??x,n=>{if((e.shouldThrow??!0)&&!n&&u(),n===void 0)return;let r=e.select?e.select(n):n;if(e.structuralSharing??t.options.defaultStructuralSharing){let e=d(a.current,r);return a.current=e,e}return r})}function C(e){return S({from:e.from,strict:e.strict,structuralSharing:e.structuralSharing,select:t=>e.select?e.select(t.loaderData):t.loaderData})}function w(e){let{select:t,...n}=e;return S({...n,select:e=>t?t(e.loaderDeps):e.loaderDeps})}function T(e){return S({from:e.from,shouldThrow:e.shouldThrow,structuralSharing:e.structuralSharing,strict:e.strict,select:t=>{let n=e.strict===!1?t.params:t._strictParams;return e.select?e.select(n):n}})}function E(e){return S({from:e.from,strict:e.strict,shouldThrow:e.shouldThrow,structuralSharing:e.structuralSharing,select:t=>e.select?e.select(t.search):t.search})}function D(e){return S({...e,select:t=>e.select?e.select(t.context):t.context})}var O=t(),k=class extends v{constructor(e){super(e),this.useMatch=e=>S({select:e?.select,from:this.id,structuralSharing:e?.structuralSharing}),this.useRouteContext=e=>D({...e,from:this.id}),this.useSearch=e=>E({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useParams=e=>T({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useLoaderDeps=e=>w({...e,from:this.id}),this.useLoaderData=e=>C({...e,from:this.id}),this.useNavigate=()=>_({from:this.fullPath}),this.Link=b.forwardRef((e,t)=>(0,O.jsx)(g,{ref:t,from:this.fullPath,...e}))}};function A(e){return new k(e)}function j(){return e=>N(e)}var M=class extends y{constructor(e){super(e),this.useMatch=e=>S({select:e?.select,from:this.id,structuralSharing:e?.structuralSharing}),this.useRouteContext=e=>D({...e,from:this.id}),this.useSearch=e=>E({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useParams=e=>T({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useLoaderDeps=e=>w({...e,from:this.id}),this.useLoaderData=e=>C({...e,from:this.id}),this.useNavigate=()=>_({from:this.fullPath}),this.Link=b.forwardRef((e,t)=>(0,O.jsx)(g,{ref:t,from:this.fullPath,...e}))}};function N(e){return new M(e)}function P(e){return typeof e==`object`?new F(e,{silent:!0}).createRoute(e):new F(e,{silent:!0}).createRoute}var F=class{constructor(e,t){this.path=e,this.createRoute=e=>{let t=A(e);return t.isRoot=!1,t},this.silent=t?.silent}};function I(e,t){let n,a,o,s,c=()=>(n||=e().then(e=>{n=void 0,a=e[t??`default`]}).catch(e=>{if(o=e,i(o)&&o instanceof Error&&typeof window<`u`&&typeof sessionStorage<`u`){let e=`tanstack_router_reload:${o.message}`;sessionStorage.getItem(e)||(sessionStorage.setItem(e,`1`),s=!0)}}),n),l=function(e){if(s)throw window.location.reload(),new Promise(()=>{});if(o)throw o;if(!a)if(r)r(c());else throw c();return b.createElement(a,e)};return l.preload=c,l}export{P as n,j as r,I as t};
+1 -1
View File
@@ -1 +1 @@
import{a as e,n as t,r as n}from"./compiler-runtime.js";import{a as r,i as ee,t as i}from"./useRouter.js";import{A as a,D as te,a as ne,c as o,d as s,j as c,t as re}from"./useStore.js";import{Xt as l}from"./awsClient.js";var ie=`Error preloading route! ☝️`,u=e(n(),1);t();var d=e(l(),1);function f(e,t){let n=i(),l=ee(t),{activeProps:f,inactiveProps:g,activeOptions:_,to:v,preload:pe,preloadDelay:me,hashScrollIntoView:he,replace:ge,startTransition:_e,resetScroll:y,viewTransition:b,children:x,target:S,disabled:C,style:w,className:T,onClick:E,onBlur:D,onFocus:O,onMouseEnter:k,onMouseLeave:A,onTouchStart:j,ignoreBlocker:M,params:ve,search:ye,hash:be,state:xe,mask:Se,reloadDocument:Ce,unsafeRelative:we,from:Te,_fromLocation:Ee,...N}=e,P=ne(),F=u.useMemo(()=>e,[n,e.from,e._fromLocation,e.hash,e.to,e.search,e.params,e.state,e.mask,e.unsafeRelative]),I=re(n.stores.location,e=>e,(e,t)=>e.href===t.href),L=u.useMemo(()=>{let e={_fromLocation:I,...F};return n.buildLocation(e)},[n,I,F]),R=L.maskedLocation?L.maskedLocation.publicHref:L.publicHref,z=L.maskedLocation?L.maskedLocation.external:L.external,B=u.useMemo(()=>ue(R,z,n.history,C),[C,z,R,n.history]),V=u.useMemo(()=>{if(B?.external)return c(B.href,n.protocolAllowlist)?void 0:B.href;if(!de(v)&&!(typeof v!=`string`||v.indexOf(`:`)===-1))try{return new URL(v),c(v,n.protocolAllowlist)?void 0:v}catch{}},[v,B,n.protocolAllowlist]),H=u.useMemo(()=>{if(V)return!1;if(_?.exact){if(!o(I.pathname,L.pathname,n.basepath))return!1}else{let e=s(I.pathname,n.basepath),t=s(L.pathname,n.basepath);if(!(e.startsWith(t)&&(e.length===t.length||e[t.length]===`/`)))return!1}return(_?.includeSearch??!0)&&!te(I.search,L.search,{partial:!_?.exact,ignoreUndefined:!_?.explicitUndefined})?!1:_?.includeHash?P&&I.hash===L.hash:!0},[_?.exact,_?.explicitUndefined,_?.includeHash,_?.includeSearch,I,V,P,L.hash,L.pathname,L.search,n.basepath]),U=H?a(f,{})??ae:p,W=H?p:a(g,{})??p,G=[T,U.className,W.className].filter(Boolean).join(` `),K=(w||U.style||W.style)&&{...w,...U.style,...W.style},[De,q]=u.useState(!1),J=u.useRef(!1),Y=e.reloadDocument||V?!1:pe??n.options.defaultPreload,X=me??n.options.defaultPreloadDelay??0,Z=u.useCallback(()=>{n.preloadRoute({...F,_builtLocation:L}).catch(e=>{console.warn(e),console.warn(ie)})},[n,F,L]);r(l,u.useCallback(e=>{e?.isIntersecting&&Z()},[Z]),le,{disabled:!!C||Y!==`viewport`}),u.useEffect(()=>{J.current||!C&&Y===`render`&&(Z(),J.current=!0)},[C,Z,Y]);let Oe=e=>{let t=e.currentTarget.getAttribute(`target`),r=S===void 0?t:S;if(!C&&!fe(e)&&!e.defaultPrevented&&(!r||r===`_self`)&&e.button===0){e.preventDefault(),(0,d.flushSync)(()=>{q(!0)});let t=n.subscribe(`onResolved`,()=>{t(),q(!1)});n.navigate({...F,replace:ge,resetScroll:y,hashScrollIntoView:he,startTransition:_e,viewTransition:b,ignoreBlocker:M})}};if(V)return{...N,ref:l,href:V,...x&&{children:x},...S&&{target:S},...C&&{disabled:C},...w&&{style:w},...T&&{className:T},...E&&{onClick:E},...D&&{onBlur:D},...O&&{onFocus:O},...k&&{onMouseEnter:k},...A&&{onMouseLeave:A},...j&&{onTouchStart:j}};let Q=e=>{if(C||Y!==`intent`)return;if(!X){Z();return}let t=e.currentTarget;if(m.has(t))return;let n=setTimeout(()=>{m.delete(t),Z()},X);m.set(t,n)},ke=e=>{C||Y!==`intent`||Z()},$=e=>{if(C||!Y||!X)return;let t=e.currentTarget,n=m.get(t);n&&(clearTimeout(n),m.delete(t))};return{...N,...U,...W,href:B?.href,ref:l,onClick:h([E,Oe]),onBlur:h([D,$]),onFocus:h([O,Q]),onMouseEnter:h([k,Q]),onMouseLeave:h([A,$]),onTouchStart:h([j,ke]),disabled:!!C,target:S,...K&&{style:K},...G&&{className:G},...C&&oe,...H&&se,...P&&De&&ce}}var p={},ae={className:`active`},oe={role:`link`,"aria-disabled":!0},se={"data-status":`active`,"aria-current":`page`},ce={"data-transitioning":`transitioning`},m=new WeakMap,le={rootMargin:`100px`},h=e=>t=>{for(let n of e)if(n){if(t.defaultPrevented)return;n(t)}};function ue(e,t,n,r){if(!r)return t?{href:e,external:!0}:{href:n.createHref(e)||`/`,external:!1}}function de(e){if(typeof e!=`string`)return!1;let t=e.charCodeAt(0);return t===47?e.charCodeAt(1)!==47:t===46}var g=u.forwardRef((e,t)=>{let{_asChild:n,...r}=e,{type:ee,...i}=f(r,t),a=typeof r.children==`function`?r.children({isActive:i[`data-status`]===`active`}):r.children;if(!n){let{disabled:e,...t}=i;return u.createElement(`a`,t,a)}return u.createElement(n,i,a)});function fe(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}export{g as t};
import{a as e,n as t,r as n}from"./compiler-runtime.js";import{H as r,M as i,N as a,V as o,i as ee,k as te,p as s,s as ne,t as re,u as ie}from"./useStore.js";import{Xt as c}from"./awsClient.js";var ae=`Error preloading route! ☝️`,l=e(n(),1);t();var oe=e(c(),1);function u(e,t){let n=ee(),c=o(t),{activeProps:u,inactiveProps:h,activeOptions:g,to:_,preload:me,preloadDelay:he,hashScrollIntoView:ge,replace:_e,startTransition:v,resetScroll:y,viewTransition:b,children:x,target:S,disabled:C,style:w,className:T,onClick:E,onBlur:D,onFocus:O,onMouseEnter:k,onMouseLeave:A,onTouchStart:j,ignoreBlocker:M,params:ve,search:ye,hash:be,state:xe,mask:Se,reloadDocument:Ce,unsafeRelative:we,from:Te,_fromLocation:Ee,...N}=e,P=ne(),F=l.useMemo(()=>e,[n,e.from,e._fromLocation,e.hash,e.to,e.search,e.params,e.state,e.mask,e.unsafeRelative]),I=re(n.stores.location,e=>e,(e,t)=>e.href===t.href),L=l.useMemo(()=>{let e={_fromLocation:I,...F};return n.buildLocation(e)},[n,I,F]),R=L.maskedLocation?L.maskedLocation.publicHref:L.publicHref,z=L.maskedLocation?L.maskedLocation.external:L.external,B=l.useMemo(()=>de(R,z,n.history,C),[C,z,R,n.history]),V=l.useMemo(()=>{if(B?.external)return a(B.href,n.protocolAllowlist)?void 0:B.href;if(!fe(_)&&!(typeof _!=`string`||_.indexOf(`:`)===-1))try{return new URL(_),a(_,n.protocolAllowlist)?void 0:_}catch{}},[_,B,n.protocolAllowlist]),H=l.useMemo(()=>{if(V)return!1;if(g?.exact){if(!ie(I.pathname,L.pathname,n.basepath))return!1}else{let e=s(I.pathname,n.basepath),t=s(L.pathname,n.basepath);if(!(e.startsWith(t)&&(e.length===t.length||e[t.length]===`/`)))return!1}return(g?.includeSearch??!0)&&!te(I.search,L.search,{partial:!g?.exact,ignoreUndefined:!g?.explicitUndefined})?!1:g?.includeHash?P&&I.hash===L.hash:!0},[g?.exact,g?.explicitUndefined,g?.includeHash,g?.includeSearch,I,V,P,L.hash,L.pathname,L.search,n.basepath]),U=H?i(u,{})??se:d,W=H?d:i(h,{})??d,G=[T,U.className,W.className].filter(Boolean).join(` `),K=(w||U.style||W.style)&&{...w,...U.style,...W.style},[De,q]=l.useState(!1),J=l.useRef(!1),Y=e.reloadDocument||V?!1:me??n.options.defaultPreload,X=he??n.options.defaultPreloadDelay??0,Z=l.useCallback(()=>{n.preloadRoute({...F,_builtLocation:L}).catch(e=>{console.warn(e),console.warn(ae)})},[n,F,L]);r(c,l.useCallback(e=>{e?.isIntersecting&&Z()},[Z]),p,{disabled:!!C||Y!==`viewport`}),l.useEffect(()=>{J.current||!C&&Y===`render`&&(Z(),J.current=!0)},[C,Z,Y]);let Oe=e=>{let t=e.currentTarget.getAttribute(`target`),r=S===void 0?t:S;if(!C&&!pe(e)&&!e.defaultPrevented&&(!r||r===`_self`)&&e.button===0){e.preventDefault(),(0,oe.flushSync)(()=>{q(!0)});let t=n.subscribe(`onResolved`,()=>{t(),q(!1)});n.navigate({...F,replace:_e,resetScroll:y,hashScrollIntoView:ge,startTransition:v,viewTransition:b,ignoreBlocker:M})}};if(V)return{...N,ref:c,href:V,...x&&{children:x},...S&&{target:S},...C&&{disabled:C},...w&&{style:w},...T&&{className:T},...E&&{onClick:E},...D&&{onBlur:D},...O&&{onFocus:O},...k&&{onMouseEnter:k},...A&&{onMouseLeave:A},...j&&{onTouchStart:j}};let Q=e=>{if(C||Y!==`intent`)return;if(!X){Z();return}let t=e.currentTarget;if(f.has(t))return;let n=setTimeout(()=>{f.delete(t),Z()},X);f.set(t,n)},ke=e=>{C||Y!==`intent`||Z()},$=e=>{if(C||!Y||!X)return;let t=e.currentTarget,n=f.get(t);n&&(clearTimeout(n),f.delete(t))};return{...N,...U,...W,href:B?.href,ref:c,onClick:m([E,Oe]),onBlur:m([D,$]),onFocus:m([O,Q]),onMouseEnter:m([k,Q]),onMouseLeave:m([A,$]),onTouchStart:m([j,ke]),disabled:!!C,target:S,...K&&{style:K},...G&&{className:G},...C&&ce,...H&&le,...P&&De&&ue}}var d={},se={className:`active`},ce={role:`link`,"aria-disabled":!0},le={"data-status":`active`,"aria-current":`page`},ue={"data-transitioning":`transitioning`},f=new WeakMap,p={rootMargin:`100px`},m=e=>t=>{for(let n of e)if(n){if(t.defaultPrevented)return;n(t)}};function de(e,t,n,r){if(!r)return t?{href:e,external:!0}:{href:n.createHref(e)||`/`,external:!1}}function fe(e){if(typeof e!=`string`)return!1;let t=e.charCodeAt(0);return t===47?e.charCodeAt(1)!==47:t===46}var h=l.forwardRef((e,t)=>{let{_asChild:n,...r}=e,{type:i,...a}=u(r,t),o=typeof r.children==`function`?r.children({isActive:a[`data-status`]===`active`}):r.children;if(!n){let{disabled:e,...t}=a;return l.createElement(`a`,t,o)}return l.createElement(n,a,o)});function pe(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}export{h as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
import{t as e}from"./queryOptions.js";import{Lt as t,Vt as n,_ as r,d as i,f as a,g as o,i as s}from"./awsClient.js";var c=class extends n.classBuilder().ep(o).m(function(e,n,i,a){return[t(i,e.getEndpointParameterInstructions()),r(i)]}).s(`AmazonS3`,`ListBuckets`,{}).n(`S3Client`,`ListBucketsCommand`).sc(i).build(){},l=class extends n.classBuilder().ep({...o,Bucket:{type:`contextParams`,name:`Bucket`},Prefix:{type:`contextParams`,name:`Prefix`}}).m(function(e,n,i,a){return[t(i,e.getEndpointParameterInstructions()),r(i)]}).s(`AmazonS3`,`ListObjectsV2`,{}).n(`S3Client`,`ListObjectsV2Command`).sc(a).build(){},u=e({queryKey:[`s3`,`buckets`],queryFn:()=>{let e=new c({});return s().send(e)}}),d=(t,n)=>e({queryKey:[`s3`,`buckets`,t,`objects`,n??``],queryFn:()=>{let e=new l({Bucket:t,Prefix:n,Delimiter:`/`});return s().send(e)}});export{u as n,d as t};
import{t as e}from"./queryOptions.js";import{Rt as t,_ as n,d as r,f as i,g as a,i as o,zt as s}from"./awsClient.js";var c=class extends t.classBuilder().ep(a).m(function(e,t,r,i){return[s(r,e.getEndpointParameterInstructions()),n(r)]}).s(`AmazonS3`,`ListBuckets`,{}).n(`S3Client`,`ListBucketsCommand`).sc(r).build(){},l=class extends t.classBuilder().ep({...a,Bucket:{type:`contextParams`,name:`Bucket`},Prefix:{type:`contextParams`,name:`Prefix`}}).m(function(e,t,r,i){return[s(r,e.getEndpointParameterInstructions()),n(r)]}).s(`AmazonS3`,`ListObjectsV2`,{}).n(`S3Client`,`ListObjectsV2Command`).sc(i).build(){},u=e({queryKey:[`s3`,`buckets`],queryFn:()=>{let e=new c({});return o().send(e)}}),d=(t,n)=>e({queryKey:[`s3`,`buckets`,t,`objects`,n??``],queryFn:()=>{let e=new l({Bucket:t,Prefix:n,Delimiter:`/`});return o().send(e)}});export{u as n,d as t};
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{a as e,r as t}from"./compiler-runtime.js";import{t as n}from"./useRouter.js";var r=e(t(),1);function i(e){let t=n();return r.useCallback(n=>t.navigate({...n,from:n.from??e?.from}),[e?.from,t])}export{i as t};
import{a as e,r as t}from"./compiler-runtime.js";import{i as n}from"./useStore.js";var r=e(t(),1);function i(e){let t=n();return r.useCallback(n=>t.navigate({...n,from:n.from??e?.from}),[e?.from,t])}export{i as t};
@@ -1 +0,0 @@
import{a as e,r as t}from"./compiler-runtime.js";var n=e(t(),1),r=n.use,i=typeof window<`u`?n.useLayoutEffect:n.useEffect;function a(e){let t=n.useRef({value:e,prev:null}),r=t.current.value;return e!==r&&(t.current={value:e,prev:r}),t.current.prev}function o(e,t,r={},i={}){n.useEffect(()=>{if(!e.current||i.disabled||typeof IntersectionObserver!=`function`)return;let n=new IntersectionObserver(([e])=>{t(e)},r);return n.observe(e.current),()=>{n.disconnect()}},[t,r,i.disabled,e])}function s(e){let t=n.useRef(null);return n.useImperativeHandle(e,()=>t.current,[]),t}var c=n.createContext(null);function l(e){return n.useContext(c)}export{o as a,s as i,c as n,i as o,r,a as s,l as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -26,7 +26,6 @@
<link rel="modulepreload" crossorigin href="/assets/useOpenInteractionType.js">
<link rel="modulepreload" crossorigin href="/assets/separator.js">
<link rel="modulepreload" crossorigin href="/assets/QueryClientProvider.js">
<link rel="modulepreload" crossorigin href="/assets/useRouter.js">
<link rel="modulepreload" crossorigin href="/assets/link.js">
<link rel="modulepreload" crossorigin href="/assets/matchContext.js">
<link rel="modulepreload" crossorigin href="/assets/useNavigate.js">
@@ -1,12 +1,17 @@
import { EC2Client } from "@aws-sdk/client-ec2"
import { IAMClient } from "@aws-sdk/client-iam"
import { S3Client } from "@aws-sdk/client-s3"
import { HttpRequest } from "@smithy/protocol-http"
import { getCredentials } from "./auth"
const AWS_REGION = "ap-southeast-2"
const awsEndpoint = `${window.location.protocol}//${window.location.hostname}:9999`
const s3Endpoint = `${window.location.protocol}//${window.location.hostname}:8443`
// SDK signs against the real backend host so the SigV4 signature includes
// the correct Host header value. Middleware rewrites the outgoing URL
// to route through the same-origin reverse proxy after signing is complete.
const AWSGW_SIGN_ENDPOINT = `${window.location.protocol}//localhost:9999`
const S3_SIGN_ENDPOINT = `${window.location.protocol}//localhost:8443`
// Cached singleton clients
let ec2Client: EC2Client | null = null
@@ -20,13 +25,24 @@ export function getEc2Client(): EC2Client {
throw new Error("AWS credentials not configured")
}
ec2Client = new EC2Client({
endpoint: awsEndpoint,
endpoint: AWSGW_SIGN_ENDPOINT,
region: AWS_REGION,
credentials: {
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey,
},
})
ec2Client.middlewareStack.add(
(next) => (args) => {
if (HttpRequest.isInstance(args.request)) {
args.request.hostname = window.location.hostname
args.request.port = Number(window.location.port) || 443
args.request.path = `/proxy/awsgw${args.request.path}`
}
return next(args)
},
{ step: "finalizeRequest", name: "proxyRewrite", override: true },
)
}
return ec2Client
}
@@ -38,13 +54,24 @@ export function getIamClient(): IAMClient {
throw new Error("AWS credentials not configured")
}
iamClient = new IAMClient({
endpoint: awsEndpoint,
endpoint: AWSGW_SIGN_ENDPOINT,
region: AWS_REGION,
credentials: {
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey,
},
})
iamClient.middlewareStack.add(
(next) => (args) => {
if (HttpRequest.isInstance(args.request)) {
args.request.hostname = window.location.hostname
args.request.port = Number(window.location.port) || 443
args.request.path = `/proxy/awsgw${args.request.path}`
}
return next(args)
},
{ step: "finalizeRequest", name: "proxyRewrite", override: true },
)
}
return iamClient
}
@@ -56,7 +83,7 @@ export function getS3Client(): S3Client {
throw new Error("AWS credentials not configured")
}
s3Client = new S3Client({
endpoint: s3Endpoint,
endpoint: S3_SIGN_ENDPOINT,
region: AWS_REGION,
credentials: {
accessKeyId: credentials.accessKeyId,
@@ -67,21 +94,30 @@ export function getS3Client(): S3Client {
// Remove trailing slashes from request paths to fix compatibility with
// path-style S3 endpoints where a trailing slash causes the request to
// be interpreted as GetObject instead of ListObjects
s3Client.middlewareStack.use({
applyToStack: (stack) => {
stack.add(
(next) => (args) => {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- smithy middleware args.request is typed as unknown
const request = (args as { request?: { path?: string } }).request
if (request?.path?.endsWith("/") && request.path !== "/") {
request.path = request.path.slice(0, -1)
}
return next(args)
},
{ step: "build", name: "removeTrailingSlash" },
)
s3Client.middlewareStack.add(
(next) => (args) => {
if (
HttpRequest.isInstance(args.request) &&
args.request.path.endsWith("/") &&
args.request.path !== "/"
) {
args.request.path = args.request.path.slice(0, -1)
}
return next(args)
},
})
{ step: "build", name: "removeTrailingSlash" },
)
s3Client.middlewareStack.add(
(next) => (args) => {
if (HttpRequest.isInstance(args.request)) {
args.request.hostname = window.location.hostname
args.request.port = Number(window.location.port) || 443
args.request.path = `/proxy/s3${args.request.path}`
}
return next(args)
},
{ step: "finalizeRequest", name: "proxyRewrite", override: true },
)
}
return s3Client
}
@@ -18,19 +18,19 @@ export async function signedFetch<T>({
credentials,
service = "spinifex",
}: SignedFetchOptions): Promise<T> {
const hostname = window.location.hostname
const port = GATEWAY_PORT
const protocol = window.location.protocol.replace(":", "")
const body = `Action=${action}`
// Sign the request against the real backend (localhost:9999) so the
// gateway's SigV4 verification sees the host value it expects.
const request = new HttpRequest({
method: "POST",
protocol,
hostname,
port,
hostname: "localhost",
port: GATEWAY_PORT,
path: "/",
headers: {
host: `${hostname}:${port}`,
host: `localhost:${GATEWAY_PORT}`,
"content-type": "application/x-www-form-urlencoded",
},
body,
@@ -55,15 +55,20 @@ export async function signedFetch<T>({
}
}
const url = `${protocol}://${hostname}:${port}/`
const response = await fetch(url, {
// Send the request through the same-origin reverse proxy instead of
// directly to the gateway, eliminating cross-origin requests.
const proxyUrl = `${window.location.protocol}//${window.location.host}/proxy/awsgw/`
const response = await fetch(proxyUrl, {
method: "POST",
headers,
body,
})
if (!response.ok) {
throw new Error(`${action} failed: ${response.status}`)
const detail = await response.text().catch(() => "")
throw new Error(
`${action} failed: ${response.status}${detail ? ` - ${detail}` : ""}`,
)
}
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- response.json() returns Promise<any>
@@ -11,7 +11,7 @@ vi.mock("@/lib/awsClient", () => ({
vi.mock("@aws-sdk/lib-storage", () => ({
Upload: class MockUpload {
private key: string
private readonly key: string
constructor({ params }: { params: { Key: string } }) {
this.key = params.Key
}
@@ -12,7 +12,6 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
interface PrivateKeyModalProps {
@@ -59,10 +58,10 @@ export function PrivateKeyModal({
</AlertDialogHeader>
<div className="space-y-3">
<Textarea
className="font-mono text-xs"
<textarea
className="w-full resize-none overflow-y-auto rounded-md border border-input bg-input/20 px-2 py-2 font-mono text-xs"
readOnly
rows={12}
rows={4}
value={keyMaterial}
/>
@@ -1,6 +1,6 @@
import { DescribeInstancesCommand } from "@aws-sdk/client-ec2"
import { zodResolver } from "@hookform/resolvers/zod"
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"
import { createFileRoute, redirect } from "@tanstack/react-router"
import { useState } from "react"
import { useForm } from "react-hook-form"
@@ -37,7 +37,6 @@ export const Route = createFileRoute("/login")({
})
function LoginPage() {
const navigate = useNavigate()
const [authError, setAuthError] = useState<string | null>(null)
const {
register,
@@ -54,7 +53,7 @@ function LoginPage() {
setCredentials(data)
try {
await getEc2Client().send(new DescribeInstancesCommand({}))
navigate({ to: "/" })
window.location.href = "/"
} catch {
clearCredentials()
clearClients()
@@ -20,7 +20,7 @@ const keyNameField = z
export const createInstanceSchema = z.object({
imageId: z.string("Please select an Image"),
instanceType: z.string("Please select an instance type"),
keyName: z.string("Please select a key pair"),
keyName: z.string("Please select a key pair").min(1, "Key pair is required"),
subnetId: z.string().optional(),
placementGroupName: z.string().optional(),
count: z
+74
View File
@@ -0,0 +1,74 @@
package spinifexui
import (
"crypto/tls"
"crypto/x509"
"fmt"
"log/slog"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
"time"
)
// newProxyTransport creates an *http.Transport that trusts the given CA
// certificate so the reverse proxy can connect to backend services using
// self-signed TLS certificates.
func newProxyTransport(caCertPath string) (*http.Transport, error) {
caCert, err := os.ReadFile(caCertPath)
if err != nil {
return nil, fmt.Errorf("read CA cert %s: %w", caCertPath, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("failed to parse CA cert from %s", caCertPath)
}
return &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: pool,
},
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
}, nil
}
// newReverseProxy creates a reverse proxy that forwards requests to the given
// backend host:port after stripping the pathPrefix from the request path.
// The proxy sets req.Host to the backend address so SigV4 signature verification
// succeeds (the gateway uses r.Host for the canonical host header).
func newReverseProxy(backendHost, pathPrefix string, transport *http.Transport) http.Handler {
target := &url.URL{
Scheme: "https",
Host: backendHost,
}
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(target)
pr.Out.Host = target.Host
// Strip the proxy path prefix.
pr.Out.URL.Path = strings.TrimPrefix(pr.In.URL.Path, pathPrefix)
if pr.Out.URL.Path == "" {
pr.Out.URL.Path = "/"
}
},
Transport: transport,
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
slog.Error("Proxy error", "backend", backendHost, "path", r.URL.Path, "error", err)
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusBadGateway)
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>`+
`<Error><Code>BadGateway</Code>`+
`<Message>upstream connection to %s failed</Message></Error>`, backendHost)
},
}
return proxy
}
+48 -224
View File
@@ -1,14 +1,10 @@
package spinifexui
import (
"bufio"
"bytes"
"compress/gzip"
"context"
"crypto/tls"
"embed"
"fmt"
"io"
"io/fs"
"log/slog"
"net"
@@ -21,6 +17,7 @@ import (
"syscall"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/mulgadc/spinifex/spinifex/utils"
)
@@ -149,24 +146,17 @@ func (svc *Service) launchService() error {
// Derive CA cert path from server cert directory.
caCertPath := filepath.Join(filepath.Dir(svc.Config.TLSCert), "ca.pem")
// Build TLS transport for reverse proxies using the same CA the UI trusts.
proxyTransport, err := newProxyTransport(caCertPath)
if err != nil {
return fmt.Errorf("proxy transport: %w", err)
}
// Serve static files from embedded filesystem
fileServer := http.FileServer(http.FS(contentFS))
// SPA handler: try to serve the file, fallback to index.html
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Serve CA certificate download at /api/ca.pem.
if r.URL.Path == "/api/ca.pem" {
if _, err := os.Stat(caCertPath); os.IsNotExist(err) {
slog.Warn("CA certificate requested but not found", "path", caCertPath)
http.Error(w, "CA certificate not yet generated. Run 'spx admin init' to create it.", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", `attachment; filename="spinifex-ca.pem"`)
http.ServeFile(w, r, caCertPath)
return
}
spaHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Clean the path
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
@@ -197,8 +187,38 @@ func (svc *Service) launchService() error {
}
})
// Wrap handler with security headers and gzip compression
finalHandler := securityHeadersMiddleware(gzipMiddleware(handler))
mux := http.NewServeMux()
// Reverse proxy routes — must be registered before the SPA catch-all.
mux.Handle("/proxy/awsgw/", newReverseProxy("localhost:9999", "/proxy/awsgw", proxyTransport))
mux.Handle("/proxy/s3/", newReverseProxy("localhost:8443", "/proxy/s3", proxyTransport))
// CA certificate download.
mux.HandleFunc("/api/ca.pem", func(w http.ResponseWriter, r *http.Request) {
if _, err := os.Stat(caCertPath); err != nil {
if os.IsNotExist(err) {
slog.Warn("CA certificate requested but not found", "path", caCertPath)
http.Error(w, "CA certificate not yet generated. Run 'spx admin init' to create it.", http.StatusNotFound)
} else {
slog.Error("CA certificate stat failed", "path", caCertPath, "error", err)
http.Error(w, "Unable to read CA certificate", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", `attachment; filename="spinifex-ca.pem"`)
http.ServeFile(w, r, caCertPath)
})
// SPA catch-all.
mux.Handle("/", spaHandler)
// Chi's Compress middleware handles http.Flusher passthrough for streaming
// proxy responses and skips already-compressed content types.
compressor := middleware.NewCompressor(5, "text/html", "text/css",
"application/javascript", "text/javascript", "application/json",
"image/svg+xml", "text/plain")
finalHandler := securityHeadersMiddleware(compressor.Handler(mux))
addr := fmt.Sprintf("%s:%d", svc.Config.Host, svc.Config.Port)
@@ -206,7 +226,7 @@ func (svc *Service) launchService() error {
Addr: addr,
Handler: finalHandler,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 60 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
}
@@ -256,8 +276,14 @@ func (svc *Service) launchService() error {
return server.Serve(splitLn)
}
// Content-Security-Policy header. All API requests are proxied through the
// same origin so connect-src only needs 'self'.
const csp = "default-src 'self'; script-src 'self'; style-src 'self'; " +
"img-src 'self'; font-src 'self' data:; connect-src 'self'; " +
"object-src 'none'; base-uri 'self'; form-action 'self'; " +
"frame-ancestors 'none'; upgrade-insecure-requests;"
func securityHeadersMiddleware(next http.Handler) http.Handler {
csp := buildCSP()
slog.Info("Content-Security-Policy configured", "csp", csp)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", csp)
@@ -268,205 +294,3 @@ func securityHeadersMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
// getLocalIPs returns all non-loopback IPv4 addresses on the machine.
func getLocalIPs() []string {
var ips []string
ifaces, err := net.Interfaces()
if err != nil {
slog.Warn("Failed to get network interfaces for CSP", "error", err)
return ips
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLoopback() || ip.To4() == nil {
continue
}
ips = append(ips, ip.String())
}
}
return ips
}
// buildCSP constructs the Content-Security-Policy header, dynamically adding
// each local IP with the AWS Gateway (:9999) and daemon (:8443) ports to connect-src.
func buildCSP() string {
var b strings.Builder
b.WriteString("'self' https://localhost:9999 https://localhost:8443")
for _, ip := range getLocalIPs() {
fmt.Fprintf(&b, " https://%s:9999 https://%s:8443", ip, ip)
}
return fmt.Sprintf(
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self' data:; connect-src %s; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests;",
b.String(),
)
}
// gzipContentTypes lists the MIME types eligible for gzip compression.
var gzipContentTypes = map[string]bool{
"text/html": true,
"text/css": true,
"application/javascript": true,
"text/javascript": true,
"application/json": true,
"image/svg+xml": true,
"text/plain": true,
}
// gzipResponseWriter wraps http.ResponseWriter to compress eligible responses.
type gzipResponseWriter struct {
http.ResponseWriter
gw *gzip.Writer
wroteHeader bool
compress bool
}
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
if w.compress {
return w.gw.Write(b)
}
return w.ResponseWriter.Write(b)
}
func (w *gzipResponseWriter) WriteHeader(code int) {
if w.wroteHeader {
return
}
w.wroteHeader = true
ct := w.ResponseWriter.Header().Get("Content-Type")
// Strip parameters (e.g. "text/html; charset=utf-8" → "text/html")
if idx := strings.IndexByte(ct, ';'); idx != -1 {
ct = strings.TrimSpace(ct[:idx])
}
if gzipContentTypes[ct] {
w.compress = true
w.ResponseWriter.Header().Set("Content-Encoding", "gzip")
w.ResponseWriter.Header().Del("Content-Length")
}
w.ResponseWriter.WriteHeader(code)
}
func gzipMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
gz, _ := gzip.NewWriterLevel(w, gzip.DefaultCompression)
defer gz.Close()
grw := &gzipResponseWriter{ResponseWriter: w, gw: gz}
next.ServeHTTP(grw, r)
})
}
const tlsRecordTypeHandshake = 0x16
// tlsSplitListener accepts connections and reads the first byte. If it looks
// like a TLS ClientHello (0x16), the connection is wrapped with TLS. Otherwise,
// a plain-HTTP redirect to HTTPS is sent and the connection is closed.
type tlsSplitListener struct {
net.Listener
port int
tlsCfg *tls.Config
}
func (ln *tlsSplitListener) Accept() (net.Conn, error) {
for {
conn, err := ln.Listener.Accept()
if err != nil {
return nil, err
}
// Set a deadline for the initial protocol detection byte so a
// slow/malicious client cannot block the accept loop.
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
var buf [1]byte
if _, err := io.ReadFull(conn, buf[:]); err != nil {
_ = conn.Close()
continue
}
// Clear the deadline before handing off the connection.
_ = conn.SetReadDeadline(time.Time{})
if buf[0] == tlsRecordTypeHandshake {
// Prepend the peeked byte and upgrade to TLS.
merged := &prefixConn{
Conn: conn,
r: io.MultiReader(bytes.NewReader(buf[:]), conn),
}
return tls.Server(merged, ln.tlsCfg), nil
}
// Plain HTTP — send redirect and close.
go ln.redirectHTTP(conn, buf[0])
}
}
// redirectHTTP reads an HTTP request from conn (with the first byte already
// consumed into firstByte), writes a 301 redirect to HTTPS, and closes conn.
func (ln *tlsSplitListener) redirectHTTP(conn net.Conn, firstByte byte) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
br := bufio.NewReader(io.MultiReader(bytes.NewReader([]byte{firstByte}), conn))
req, err := http.ReadRequest(br)
if err != nil {
return
}
defer req.Body.Close()
host := req.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
// Sanitize host to prevent header injection via CRLF.
if strings.ContainsAny(host, "\r\n") {
return
}
target := fmt.Sprintf("https://%s:%d%s", host, ln.port, req.URL.RequestURI())
resp := &http.Response{
StatusCode: http.StatusMovedPermanently,
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{"Location": {target}, "Connection": {"close"}},
Body: http.NoBody,
}
_ = resp.Write(conn)
}
// prefixConn wraps a net.Conn with a reader that replays prefixed bytes.
type prefixConn struct {
net.Conn
r io.Reader
}
func (c *prefixConn) Read(b []byte) (int, error) {
return c.r.Read(b)
}
+158 -29
View File
@@ -2,12 +2,17 @@ package spinifexui
import (
"compress/gzip"
"crypto/tls"
"encoding/pem"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5/middleware"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -168,14 +173,15 @@ func TestSecurityHeadersMiddleware_PassesThrough(t *testing.T) {
assert.NotEmpty(t, resp.Header.Get("Content-Security-Policy"))
}
func TestGzipMiddleware_CompressesEligibleContent(t *testing.T) {
func TestChiCompress_CompressesEligibleContent(t *testing.T) {
body := "Hello, this is some text content that should be compressed if long enough."
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(body))
})
handler := gzipMiddleware(inner)
compressor := middleware.NewCompressor(5, "text/html")
handler := compressor.Handler(inner)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
@@ -185,8 +191,6 @@ func TestGzipMiddleware_CompressesEligibleContent(t *testing.T) {
resp := rec.Result()
defer resp.Body.Close()
// The gzip handler may or may not compress depending on content size,
// but the handler should still return a valid response.
assert.Equal(t, http.StatusOK, resp.StatusCode)
if resp.Header.Get("Content-Encoding") == "gzip" {
@@ -199,16 +203,16 @@ func TestGzipMiddleware_CompressesEligibleContent(t *testing.T) {
}
}
func TestGzipMiddleware_NoCompressionWithoutHeader(t *testing.T) {
func TestChiCompress_NoCompressionWithoutHeader(t *testing.T) {
body := "uncompressed response body"
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(body))
})
handler := gzipMiddleware(inner)
compressor := middleware.NewCompressor(5, "text/html")
handler := compressor.Handler(inner)
req := httptest.NewRequest(http.MethodGet, "/", nil)
// No Accept-Encoding header
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -223,13 +227,14 @@ func TestGzipMiddleware_NoCompressionWithoutHeader(t *testing.T) {
assert.Equal(t, body, string(respBody))
}
func TestGzipMiddleware_IgnoresNonTextContent(t *testing.T) {
func TestChiCompress_IgnoresNonTextContent(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write([]byte("fake image data"))
})
handler := gzipMiddleware(inner)
compressor := middleware.NewCompressor(5, "text/html", "application/json")
handler := compressor.Handler(inner)
req := httptest.NewRequest(http.MethodGet, "/image.png", nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
@@ -240,36 +245,160 @@ func TestGzipMiddleware_IgnoresNonTextContent(t *testing.T) {
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
// image/png not in the allowed content types — should not be compressed
assert.NotEqual(t, "gzip", resp.Header.Get("Content-Encoding"))
}
func TestGetLocalIPs_ReturnsNonLoopback(t *testing.T) {
ips := getLocalIPs()
for _, ip := range ips {
assert.NotEqual(t, "127.0.0.1", ip, "should not include loopback")
}
}
func TestBuildCSP_ContainsSelf(t *testing.T) {
csp := buildCSP()
func TestCSP_ContainsSelf(t *testing.T) {
assert.Contains(t, csp, "connect-src 'self'")
assert.Contains(t, csp, "default-src 'self'")
}
func TestBuildCSP_ContainsLocalIPs(t *testing.T) {
ips := getLocalIPs()
csp := buildCSP()
for _, ip := range ips {
assert.Contains(t, csp, "https://"+ip+":9999")
assert.Contains(t, csp, "https://"+ip+":8443")
func TestCSP_NoExternalPorts(t *testing.T) {
assert.NotContains(t, csp, ":9999", "proxy removes need for direct gateway access")
assert.NotContains(t, csp, ":8443", "proxy removes need for direct predastore access")
}
func TestNewReverseProxy_StripsPrefixAndSetsHost(t *testing.T) {
tests := []struct {
name string
reqPath string
prefix string
wantPath string
wantHost string
wantQuery string
}{
{
name: "strips awsgw prefix",
reqPath: "/proxy/awsgw/",
prefix: "/proxy/awsgw",
wantPath: "/",
wantHost: "localhost:9999",
},
{
name: "strips prefix with subpath",
reqPath: "/proxy/awsgw/some/path",
prefix: "/proxy/awsgw",
wantPath: "/some/path",
wantHost: "localhost:9999",
},
{
name: "strips s3 prefix with bucket key",
reqPath: "/proxy/s3/bucket/key",
prefix: "/proxy/s3",
wantPath: "/bucket/key",
wantHost: "localhost:8443",
},
{
name: "empty path after strip becomes root",
reqPath: "/proxy/awsgw",
prefix: "/proxy/awsgw",
wantPath: "/",
wantHost: "localhost:9999",
},
{
name: "preserves query string",
reqPath: "/proxy/s3/bucket?list-type=2&prefix=foo",
prefix: "/proxy/s3",
wantPath: "/bucket",
wantHost: "localhost:8443",
wantQuery: "list-type=2&prefix=foo",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Start a mock backend to capture the forwarded request.
var gotPath, gotHost, gotQuery string
backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotHost = r.Host
gotQuery = r.URL.RawQuery
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()
// Use the test server's TLS transport.
transport := backend.Client().Transport.(*http.Transport)
// Point the proxy at the test server instead of the real backend.
backendAddr := backend.Listener.Addr().String()
proxy := newReverseProxy(backendAddr, tt.prefix, transport)
req := httptest.NewRequest(http.MethodPost, tt.reqPath, nil)
rec := httptest.NewRecorder()
proxy.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, tt.wantPath, gotPath, "backend should see stripped path")
assert.Equal(t, backendAddr, gotHost, "backend should see proxy-set Host")
if tt.wantQuery != "" {
assert.Equal(t, tt.wantQuery, gotQuery, "query string should be preserved")
}
})
}
}
func TestBuildCSP_ContainsLocalhost(t *testing.T) {
csp := buildCSP()
assert.Contains(t, csp, "https://localhost:9999")
assert.Contains(t, csp, "https://localhost:8443")
func TestNewReverseProxy_ErrorHandler(t *testing.T) {
// Use a transport that will fail to connect (no backend listening).
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
proxy := newReverseProxy("localhost:19999", "/proxy/awsgw", transport)
req := httptest.NewRequest(http.MethodPost, "/proxy/awsgw/", nil)
rec := httptest.NewRecorder()
proxy.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadGateway, rec.Code)
assert.Equal(t, "application/xml", rec.Header().Get("Content-Type"))
body := rec.Body.String()
assert.Contains(t, body, "<Code>BadGateway</Code>")
assert.Contains(t, body, "localhost:19999")
}
func TestNewProxyTransport_ValidCert(t *testing.T) {
// Use the test TLS server's CA cert.
backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()
// Extract the CA cert from the test server's TLS config and write to a temp file.
certPEM := backend.Certificate()
require.NotNil(t, certPEM)
dir := t.TempDir()
caPath := filepath.Join(dir, "ca.pem")
// httptest TLS server uses a self-signed cert; encode it as PEM.
pemData := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certPEM.Raw,
})
require.NoError(t, os.WriteFile(caPath, pemData, 0o644))
transport, err := newProxyTransport(caPath)
require.NoError(t, err)
assert.NotNil(t, transport)
assert.NotNil(t, transport.TLSClientConfig)
assert.NotNil(t, transport.TLSClientConfig.RootCAs)
}
func TestNewProxyTransport_MissingFile(t *testing.T) {
_, err := newProxyTransport("/nonexistent/ca.pem")
assert.Error(t, err)
assert.Contains(t, err.Error(), "read CA cert")
}
func TestNewProxyTransport_InvalidPEM(t *testing.T) {
dir := t.TempDir()
caPath := filepath.Join(dir, "bad.pem")
require.NoError(t, os.WriteFile(caPath, []byte("not a cert"), 0o644))
_, err := newProxyTransport(caPath)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse CA cert")
}
func TestShutdown_WithServer(t *testing.T) {
+104
View File
@@ -0,0 +1,104 @@
package spinifexui
import (
"bufio"
"bytes"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
const tlsRecordTypeHandshake = 0x16
// tlsSplitListener accepts connections and reads the first byte. If it looks
// like a TLS ClientHello (0x16), the connection is wrapped with TLS. Otherwise,
// a plain-HTTP redirect to HTTPS is sent and the connection is closed.
type tlsSplitListener struct {
net.Listener
port int
tlsCfg *tls.Config
}
func (ln *tlsSplitListener) Accept() (net.Conn, error) {
for {
conn, err := ln.Listener.Accept()
if err != nil {
return nil, err
}
// Set a deadline for the initial protocol detection byte so a
// slow/malicious client cannot block the accept loop.
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
var buf [1]byte
if _, err := io.ReadFull(conn, buf[:]); err != nil {
_ = conn.Close()
continue
}
// Clear the deadline before handing off the connection.
_ = conn.SetReadDeadline(time.Time{})
if buf[0] == tlsRecordTypeHandshake {
// Prepend the peeked byte and upgrade to TLS.
merged := &prefixConn{
Conn: conn,
r: io.MultiReader(bytes.NewReader(buf[:]), conn),
}
return tls.Server(merged, ln.tlsCfg), nil
}
// Plain HTTP — send redirect and close.
go ln.redirectHTTP(conn, buf[0])
}
}
// redirectHTTP reads an HTTP request from conn (with the first byte already
// consumed into firstByte), writes a 301 redirect to HTTPS, and closes conn.
func (ln *tlsSplitListener) redirectHTTP(conn net.Conn, firstByte byte) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
br := bufio.NewReader(io.MultiReader(bytes.NewReader([]byte{firstByte}), conn))
req, err := http.ReadRequest(br)
if err != nil {
return
}
defer req.Body.Close()
host := req.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
// Sanitize host to prevent header injection via CRLF.
if strings.ContainsAny(host, "\r\n") {
return
}
target := fmt.Sprintf("https://%s:%d%s", host, ln.port, req.URL.RequestURI())
resp := &http.Response{
StatusCode: http.StatusMovedPermanently,
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{"Location": {target}, "Connection": {"close"}},
Body: http.NoBody,
}
_ = resp.Write(conn)
}
// prefixConn wraps a net.Conn with a reader that replays prefixed bytes.
type prefixConn struct {
net.Conn
r io.Reader
}
func (c *prefixConn) Read(b []byte) (int, error) {
return c.r.Read(b)
}
+13 -11
View File
@@ -77,7 +77,8 @@ start_node_services() {
# Start all services - each node's config binds to its specific IP
# UI is not needed for E2E tests and fails in pseudo multi-node (wrong cert path)
UI=false ./scripts/start-dev.sh "$data_dir"
# Services run as root (admin init/join require sudo)
sudo UI=false ./scripts/start-dev.sh "$data_dir"
echo "Node$node_num services started"
}
@@ -91,7 +92,8 @@ stop_node_services() {
echo "Stopping services for node$node_num..."
# Stop using PID files in the node's log directory
./scripts/stop-dev.sh "$data_dir"
# Services run as root (admin init/join require sudo), so stop must also be root
sudo ./scripts/stop-dev.sh "$data_dir"
echo "Node$node_num services stopped"
}
@@ -668,9 +670,9 @@ force_cleanup_all_nodes() {
if [ -f "$pidfile" ]; then
local pid
pid=$(cat "$pidfile" 2>/dev/null || true)
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
if [ -n "$pid" ] && sudo kill -0 "$pid" 2>/dev/null; then
echo " Node$i: killing $svc (PID $pid)..."
kill -TERM "$pid" 2>/dev/null || true
sudo kill -TERM "$pid" 2>/dev/null || true
fi
fi
done
@@ -691,17 +693,17 @@ force_cleanup_all_nodes() {
if [ -f "$pidfile" ]; then
local pid
pid=$(cat "$pidfile" 2>/dev/null || true)
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
if [ -n "$pid" ] && sudo kill -0 "$pid" 2>/dev/null; then
echo " Node$i: force-killing $svc (PID $pid)..."
kill -9 "$pid" 2>/dev/null || true
sudo kill -9 "$pid" 2>/dev/null || true
fi
fi
done
fi
done
# Kill any remaining QEMU processes
pkill -9 -x qemu-system-x86_64 2>/dev/null || true
# Kill any remaining QEMU processes (root-owned since services run as root)
sudo pkill -9 -x qemu-system-x86_64 2>/dev/null || true
sleep 1
@@ -716,7 +718,7 @@ force_cleanup_all_nodes() {
if [ -n "$lock_files" ]; then
echo " Node$i: removing stale badger LOCK files..."
echo "$lock_files" | while read -r f; do
rm -f "$f"
sudo rm -f "$f"
echo " removed $f"
done
fi
@@ -762,7 +764,7 @@ init_leader_node() {
# Start init in background — formation server will wait for joins
# shellcheck disable=SC2086
./bin/spx admin init \
sudo ./bin/spx admin init \
--node node1 \
--bind "${NODE1_IP}" \
--cluster-bind "${NODE1_IP}" \
@@ -803,7 +805,7 @@ join_follower_node() {
rm -rf "$data_dir/"
# Route to node1 (seed node) - other nodes discovered via NATS gossip
./bin/spx admin join \
sudo ./bin/spx admin join \
--node "node$node_num" \
--bind "$node_ip" \
--cluster-bind "$node_ip" \
+5 -5
View File
@@ -25,15 +25,15 @@ cleanup() {
dump_all_node_logs
fi
# Kill any lingering formation background processes
[ -n "$LEADER_INIT_PID" ] && kill "$LEADER_INIT_PID" 2>/dev/null || true
[ -n "$JOIN2_PID" ] && kill "$JOIN2_PID" 2>/dev/null || true
[ -n "$JOIN3_PID" ] && kill "$JOIN3_PID" 2>/dev/null || true
# Kill any lingering formation background processes (root-owned via sudo)
[ -n "$LEADER_INIT_PID" ] && sudo kill "$LEADER_INIT_PID" 2>/dev/null || true
[ -n "$JOIN2_PID" ] && sudo kill "$JOIN2_PID" 2>/dev/null || true
[ -n "$JOIN3_PID" ] && sudo kill "$JOIN3_PID" 2>/dev/null || true
# Try coordinated shutdown first (only if NATS is likely still up)
if [ "$CLUSTER_SERVICES_STARTED" = "true" ]; then
echo "Attempting coordinated cluster shutdown..."
if timeout 60 ./bin/spx admin cluster shutdown --force --timeout 30s --config "$HOME/node1/config/spinifex.toml" 2>/dev/null; then
if timeout 60 sudo ./bin/spx admin cluster shutdown --force --timeout 30s --config "$HOME/node1/config/spinifex.toml" 2>/dev/null; then
echo "Coordinated shutdown succeeded"
else
echo "Coordinated shutdown failed, falling back to per-node stop..."