mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
feat: webserver proxy (#116)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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
+1
-1
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
+3
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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" \
|
||||
|
||||
@@ -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..."
|
||||
|
||||
Reference in New Issue
Block a user