chore: merge main into feat/iso-installer, resolve Makefile .PHONY conflict

This commit is contained in:
tomnewton-mulga
2026-04-10 09:40:16 +10:00
74 changed files with 1330 additions and 756 deletions
+12 -7
View File
@@ -180,9 +180,13 @@ jobs:
sparse-checkout: scripts/tofu-cluster
path: mulga
- name: Clone and build
- name: Build Install Artifacts
working-directory: mulga/scripts/tofu-cluster
run: ./build-artifacts.sh "${GIT_BRANCH}" "${RUNNER_TEMP}/spinifex-artifacts.tar"
run: |
./build-install-artifacts.sh "${GIT_BRANCH}" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-tests.tar"
- name: Provision VMs
working-directory: mulga/scripts/tofu-cluster
@@ -194,13 +198,14 @@ jobs:
./scrub-env.sh env2 >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
- name: Bootstrap Cluster
- name: Bootstrap via Binary Install
working-directory: mulga/scripts/tofu-cluster
run: |
SPX_PREBUILT=true \
SPX_ARTIFACT_TAR="${RUNNER_TEMP}/spinifex-artifacts.tar" \
SPX_EXTERNAL_POOL="192.168.1.240-192.168.1.250" \
./bootstrap.sh env2 >> "$TOFU_LOG" 2>&1
./bootstrap-install.sh env2 \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
- name: Run E2E Tests
working-directory: mulga/scripts/tofu-cluster
@@ -218,7 +223,7 @@ jobs:
done
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
"cd ~/Development/mulga/spinifex && ./tests/e2e/run-multinode-e2e.sh ${NODE_IPS} && ./tests/e2e/run-cert-e2e.sh --multinode ${NODE_IPS}"
"cd ~/e2e-tests && ./run-multinode-e2e.sh ${NODE_IPS} && ./run-cert-e2e.sh --multinode ${NODE_IPS}"
- name: Teardown
if: always()
+3
View File
@@ -22,6 +22,7 @@ linters:
- errname # checks that sentinel errors are prefixed with Err, error types suffixed with Error
- exptostd # detects golang.org/x/exp/ functions replaceable by stdlib
- fatcontext # detects nested contexts in loops
- forcetypeassert # flags unchecked type assertions that can panic
- gocheckcompilerdirectives # validates go compiler directive comments (//go:)
- goprintffuncname # checks that printf-like functions are named with f at the end
- gosec # inspects source code for security problems
@@ -29,11 +30,13 @@ linters:
- ineffassign # detects assignments to existing variables that are not used
- makezero # finds slice declarations with non-zero initial length followed by append
- mirror # catches wrong mirror patterns in bytes/strings usage
- musttag # checks structs passed to marshal/unmarshal have proper tags
- modernize # modernizes code to use newer Go idioms (go fix analyzers)
- nakedret # finds naked returns in functions
- nilerr # catches code that returns nil even after checking err != nil
- nilnesserr # catches returning a different nil error variable after err != nil check
- nolintlint # reports ill-formed or insufficient nolint directives
- nosprintfhostport # flags fmt.Sprintf host:port — use net.JoinHostPort for IPv6 safety
- predeclared # catches shadowing of Go builtins (len, cap, error, etc.)
- reassign # checks that package variables are not reassigned
- recvcheck # checks for receiver type consistency (pointer vs value)
+15 -3
View File
@@ -120,6 +120,17 @@ run:
$(MAKE) go_build
$(MAKE) go_run
# Fast iteration: build + install binary + restart all services
deploy: build
sudo install -m 755 bin/spx /usr/local/bin/spx
sudo systemctl daemon-reload
sudo systemctl restart spinifex.target
# Re-run setup.sh after changing systemd units, helper scripts, or logrotate config.
# Not needed for code-only changes — use deploy for those.
reinstall:
scripts/dev-install.sh
clean:
rm -f ./bin/$(GO_PROJECT_NAME)
rm -rf spinifex/services/spinifexui/frontend/dist
@@ -136,9 +147,9 @@ install-system:
ovn-central ovn-host openvswitch-switch dhcpcd-base
install-go:
@echo -e "\n....Installing Go 1.26.1 for $(ARCH) ($(GO_ARCH))...."
@echo -e "\n....Installing Go 1.26.2 for $(ARCH) ($(GO_ARCH))...."
@if [ ! -d "/usr/local/go" ]; then \
curl -L https://go.dev/dl/go1.26.1.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
curl -L https://go.dev/dl/go1.26.2.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
else \
echo "Go already installed in /usr/local/go"; \
fi
@@ -211,7 +222,8 @@ distro-arm64:
distro-clean:
rm -rf dist/
.PHONY: build build-ui build-installer build-alb-agent build-system-image build-alb-image go_build go_run preflight test test-cover test-race diff-coverage bench run clean \
.PHONY: build build-ui build-installer build-alb-agent build-system-image build-alb-image go_build go_run preflight test test-cover test-race diff-coverage bench run \
deploy reinstall clean \
install-system install-go install-aws quickinstall \
lint fix govulncheck \
distro distro-amd64 distro-arm64 distro-clean
+33 -2
View File
@@ -6,7 +6,7 @@ Wants=spinifex-nats.service
[Service]
Type=simple
User=spinifex
User=spinifex-gw
Group=spinifex
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
ExecStart=/usr/local/bin/spx service awsgw start
@@ -14,9 +14,40 @@ Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/awsgw/
Environment=SPINIFEX_AWSGW_TLS_CERT=/etc/spinifex/server.pem
Environment=SPINIFEX_AWSGW_TLS_KEY=/etc/spinifex/server.key
# Filesystem access
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/awsgw /var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+33 -1
View File
@@ -6,7 +6,7 @@ Wants=spinifex-nats.service
[Service]
Type=simple
User=spinifex
User=spinifex-daemon
Group=spinifex
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
ExecStart=/usr/local/bin/spx service spinifex start
@@ -17,5 +17,37 @@ Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/spinifex/
Environment=SPINIFEX_WAL_DIR=/var/lib/spinifex/spinifex/
# Filesystem access — NoNewPrivileges omitted (daemon uses sudo for ip, ovs-vsctl, dhcpcd)
ProtectSystem=full
PrivateDevices=no
DevicePolicy=closed
DeviceAllow=/dev/kvm rw
DeviceAllow=/dev/net/tun rw
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/server.pem /etc/spinifex/server.key
ReadWritePaths=/var/lib/spinifex/spinifex /var/log/spinifex /run/spinifex /run/spinifex/nbd
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+32 -1
View File
@@ -5,7 +5,7 @@ Before=spinifex-predastore.service spinifex-daemon.service spinifex-awsgw.servic
[Service]
Type=simple
User=spinifex
User=spinifex-nats
Group=spinifex
ExecStart=/usr/local/bin/spx service nats start
Restart=on-failure
@@ -13,5 +13,36 @@ RestartSec=5
OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/nats/nats.conf
# Filesystem access
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/nats
ReadWritePaths=/var/lib/spinifex/nats /var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+32 -1
View File
@@ -6,7 +6,7 @@ Wants=spinifex-nats.service
[Service]
Type=simple
User=spinifex
User=spinifex-storage
Group=spinifex
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
ExecStart=/var/lib/spinifex/predastore-start.sh
@@ -18,5 +18,36 @@ Environment=SPINIFEX_PREDASTORE_BASE_PATH=/var/lib/spinifex/predastore/
Environment=SPINIFEX_PREDASTORE_TLS_CERT=/etc/spinifex/server.pem
Environment=SPINIFEX_PREDASTORE_TLS_KEY=/etc/spinifex/server.key
# Filesystem access
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/predastore /etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key
ReadWritePaths=/var/lib/spinifex/predastore /var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+32 -1
View File
@@ -5,7 +5,7 @@ After=spinifex-awsgw.service
[Service]
Type=simple
User=spinifex
User=spinifex-ui
Group=spinifex
ExecStart=/usr/local/bin/spx service spinifex-ui start
Restart=on-failure
@@ -14,5 +14,36 @@ OOMScoreAdjust=-500
Environment=SPINIFEX_UI_TLS_CERT=/etc/spinifex/server.pem
Environment=SPINIFEX_UI_TLS_KEY=/etc/spinifex/server.key
# Filesystem access
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/server.pem /etc/spinifex/server.key
ReadWritePaths=/var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+32 -1
View File
@@ -6,7 +6,7 @@ Wants=spinifex-nats.service
[Service]
Type=simple
User=spinifex
User=spinifex-viperblock
Group=spinifex
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
ExecStart=/usr/local/bin/spx service viperblock start
@@ -17,5 +17,36 @@ EnvironmentFile=/etc/spinifex/systemd.env
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/viperblock/
# Filesystem access
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/systemd.env
ReadWritePaths=/var/lib/spinifex/viperblock /var/log/spinifex /run/spinifex/nbd
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+31 -1
View File
@@ -6,7 +6,7 @@ Wants=spinifex-nats.service
[Service]
Type=simple
User=spinifex
User=spinifex-vpcd
Group=spinifex
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
ExecStart=/usr/local/bin/spx service vpcd start
@@ -14,6 +14,36 @@ Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/vpcd/
# Filesystem access — NoNewPrivileges omitted (vpcd uses sudo for ovs-vsctl, ovn-nbctl, systemctl)
ProtectSystem=full
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/spinifex.toml
ReadWritePaths=/var/lib/spinifex/vpcd /var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+39 -43
View File
@@ -561,9 +561,9 @@ func runimagesListCmd(cmd *cobra.Command, args []string) {
// TODO: Move all logic to a module, use minimal application logic in viper commands
func runAdminInit(cmd *cobra.Command, args []string) {
if os.Getuid() != 0 {
fmt.Fprintln(os.Stderr, "⚠️ Not running as root — CA certificate will not be installed to system trust store.")
fmt.Fprintln(os.Stderr, " Run with sudo, or manually: sudo cp ~/spinifex/config/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt && sudo update-ca-certificates")
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "⚠️ Warning: 'spx admin init' is not running as root.")
fmt.Fprintln(os.Stderr, " Service user setup and CA certificate installation will be skipped.")
fmt.Fprintln(os.Stderr, " For production deployments, run with sudo.")
}
force, _ := cmd.Flags().GetBool("force")
@@ -800,14 +800,15 @@ func runAdminInit(cmd *cobra.Command, args []string) {
fmt.Fprintf(os.Stderr, "Error generating IAM master key: %v\n", err)
os.Exit(1)
}
bootstrapResult, err := writeBootstrapFiles(configDir, masterKey, accessKey, secretKey, accountID)
bootstrapDir := filepath.Join(spxRoot, "awsgw")
bootstrapResult, err := writeBootstrapFiles(configDir, bootstrapDir, masterKey, accessKey, secretKey, accountID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error writing bootstrap files: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated IAM master key")
fmt.Printf(" Master key: %s\n", filepath.Join(configDir, "master.key"))
fmt.Printf(" Bootstrap: %s\n", filepath.Join(configDir, "bootstrap.json"))
fmt.Printf(" Bootstrap: %s\n", filepath.Join(bootstrapDir, "bootstrap.json"))
fmt.Printf("\n🔑 Generated admin credentials (save these — they won't be shown again):\n")
fmt.Printf(" Access Key: %s\n", bootstrapResult.AdminAccessKey)
@@ -948,6 +949,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
Region: region,
NatsToken: natsToken,
DataDir: spxRoot,
LogDir: LogDirFor(spxRoot),
ConfigDir: configDir,
Node: node,
@@ -1029,14 +1031,10 @@ func runAdminInit(cmd *cobra.Command, args []string) {
admin.CreateServiceDirectories(spxRoot)
// In production layout (running as root), fix ownership so the service user
// can read config and write data. SUDO_USER identifies the operator account.
// In production layout (running as root), set per-service ownership on
// directories and shared config files (root:spinifex with correct modes).
if os.Getuid() == 0 {
sudoUser := os.Getenv("SUDO_USER")
if sudoUser != "" {
admin.ChownRecursive(configDir, sudoUser)
admin.ChownRecursive(spxRoot, sudoUser)
}
admin.SetServiceOwnership()
}
// Print success message
@@ -1066,13 +1064,14 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
fmt.Fprintf(os.Stderr, "❌ Error generating IAM master key: %v\n", err)
os.Exit(1)
}
bootstrapResult, err := writeBootstrapFiles(configDir, masterKey, accessKey, secretKey, accountID)
bootstrapDir := filepath.Join(spxRoot, "awsgw")
bootstrapResult, err := writeBootstrapFiles(configDir, bootstrapDir, masterKey, accessKey, secretKey, accountID)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error writing bootstrap files: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated IAM master key")
fmt.Printf(" Bootstrap: %s\n", filepath.Join(configDir, "bootstrap.json"))
fmt.Printf(" Bootstrap: %s\n", filepath.Join(bootstrapDir, "bootstrap.json"))
// Read CA cert/key for distribution to joining nodes
caCertData, err := os.ReadFile(filepath.Join(configDir, "ca.pem"))
@@ -1186,6 +1185,7 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
Region: region,
NatsToken: natsToken,
DataDir: spxRoot,
LogDir: LogDirFor(spxRoot),
ConfigDir: configDir,
Node: node,
@@ -1261,14 +1261,10 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
admin.CreateServiceDirectories(spxRoot)
// In production layout (running as root), fix ownership so the service user
// can read config and write data. SUDO_USER identifies the operator account.
// In production layout (running as root), set per-service ownership on
// directories and shared config files (root:spinifex with correct modes).
if os.Getuid() == 0 {
sudoUser := os.Getenv("SUDO_USER")
if sudoUser != "" {
admin.ChownRecursive(configDir, sudoUser)
admin.ChownRecursive(spxRoot, sudoUser)
}
admin.SetServiceOwnership()
}
// Keep formation server running briefly so joining nodes can fetch complete status
@@ -1294,8 +1290,9 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
func runAdminJoin(cmd *cobra.Command, args []string) {
if os.Getuid() != 0 {
fmt.Fprintln(os.Stderr, "⚠️ Not running as root — CA certificate will not be installed to system trust store.")
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "⚠️ Warning: 'spx admin join' is not running as root.")
fmt.Fprintln(os.Stderr, " Service user setup and CA certificate installation will be skipped.")
fmt.Fprintln(os.Stderr, " For production deployments, run with sudo.")
}
node, _ := cmd.Flags().GetString("node")
@@ -1515,12 +1512,13 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
fmt.Fprintf(os.Stderr, "❌ Error decoding master key: %v\n", err)
os.Exit(1)
}
if err := writeBootstrapFilesWithAdmin(configDir, masterKeyBytes, creds.AccessKey, creds.SecretKey, creds.AccountID, creds.AdminAccessKey, creds.AdminSecretKey); err != nil {
bootstrapDir := filepath.Join(dataDir, "awsgw")
if err := writeBootstrapFilesWithAdmin(configDir, bootstrapDir, masterKeyBytes, creds.AccessKey, creds.SecretKey, creds.AccountID, creds.AdminAccessKey, creds.AdminSecretKey); err != nil {
fmt.Fprintf(os.Stderr, "❌ Error writing bootstrap files: %v\n", err)
os.Exit(1)
}
fmt.Println("✅ IAM master key received from leader")
fmt.Printf("✅ Bootstrap file written: %s\n", filepath.Join(configDir, "bootstrap.json"))
fmt.Printf("✅ Bootstrap file written: %s\n", filepath.Join(bootstrapDir, "bootstrap.json"))
// Generate server cert signed by CA with this node's bind IP
if err := admin.GenerateServerCertOnly(configDir, bindIP); err != nil {
@@ -1584,6 +1582,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
Region: creds.Region,
NatsToken: creds.NatsToken,
DataDir: dataDir,
LogDir: LogDirFor(dataDir),
ConfigDir: configDir,
Node: node,
@@ -1663,14 +1662,10 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
admin.CreateServiceDirectories(dataDir)
// In production layout (running as root), fix ownership so the service user
// can read config and write data. SUDO_USER identifies the operator account.
// In production layout (running as root), set per-service ownership on
// directories and shared config files (root:spinifex with correct modes).
if os.Getuid() == 0 {
sudoUser := os.Getenv("SUDO_USER")
if sudoUser != "" {
admin.ChownRecursive(configDir, sudoUser)
admin.ChownRecursive(dataDir, sudoUser)
}
admin.SetServiceOwnership()
}
// Print cluster summary
@@ -1680,10 +1675,6 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
for name, n := range statusResp.Nodes {
fmt.Printf(" - %s (%s)\n", name, n.BindIP)
}
fmt.Println("\n📋 Next steps:")
fmt.Println(" 1. Start services:")
fmt.Println(" ./scripts/start-dev.sh")
fmt.Println()
}
// buildRemoteNodes converts formation NodeInfo into RemoteNode entries,
@@ -1816,7 +1807,7 @@ func runAccountCreate(cmd *cobra.Command, args []string) {
endpointHost = h
}
}
endpointURL := fmt.Sprintf("https://%s:9999", endpointHost)
endpointURL := "https://" + net.JoinHostPort(endpointHost, "9999")
credPath := filepath.Join(homeDir, ".aws", "credentials")
configPath := filepath.Join(homeDir, ".aws", "config")
@@ -1917,8 +1908,9 @@ type writeBootstrapResult struct {
}
// writeBootstrapFiles generates new admin credentials and writes the bootstrap
// files (master.key + bootstrap.json). Used by init flows (single and multi-node).
func writeBootstrapFiles(configDir string, masterKey []byte, accessKey, secretKey, accountID string) (*writeBootstrapResult, error) {
// files (master.key to configDir, bootstrap.json to bootstrapDir).
// Used by init flows (single and multi-node).
func writeBootstrapFiles(configDir, bootstrapDir string, masterKey []byte, accessKey, secretKey, accountID string) (*writeBootstrapResult, error) {
adminAccessKey, err := admin.GenerateAWSAccessKey()
if err != nil {
return nil, fmt.Errorf("generate admin access key: %w", err)
@@ -1927,7 +1919,7 @@ func writeBootstrapFiles(configDir string, masterKey []byte, accessKey, secretKe
if err != nil {
return nil, fmt.Errorf("generate admin secret key: %w", err)
}
if err := writeBootstrapFilesWithAdmin(configDir, masterKey, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey); err != nil {
if err := writeBootstrapFilesWithAdmin(configDir, bootstrapDir, masterKey, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey); err != nil {
return nil, err
}
return &writeBootstrapResult{
@@ -1937,8 +1929,12 @@ func writeBootstrapFiles(configDir string, masterKey []byte, accessKey, secretKe
}
// writeBootstrapFilesWithAdmin writes the bootstrap files using the provided
// admin credentials. Used by join flows where admin creds come from the leader.
func writeBootstrapFilesWithAdmin(configDir string, masterKey []byte, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey string) error {
// admin credentials. master.key goes to configDir, bootstrap.json goes to
// bootstrapDir (the awsgw data directory) so it stays outside /etc/spinifex.
func writeBootstrapFilesWithAdmin(configDir, bootstrapDir string, masterKey []byte, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey string) error {
if err := os.MkdirAll(bootstrapDir, 0700); err != nil {
return fmt.Errorf("create bootstrap directory %s: %w", bootstrapDir, err)
}
if err := handlers_iam.SaveMasterKey(filepath.Join(configDir, "master.key"), masterKey); err != nil {
return fmt.Errorf("saving master key: %w", err)
}
@@ -1966,7 +1962,7 @@ func writeBootstrapFilesWithAdmin(configDir string, masterKey []byte, accessKey,
},
}
return handlers_iam.SaveBootstrapData(filepath.Join(configDir, "bootstrap.json"), bd)
return handlers_iam.SaveBootstrapData(filepath.Join(bootstrapDir, "bootstrap.json"), bd)
}
// detectedWanBridge returns the OVS bridge name that OVN bridge-mappings should
+10
View File
@@ -26,6 +26,16 @@ func DefaultDataDir() string {
return filepath.Join(realUserHomeDir(), "spinifex")
}
// LogDirFor returns the log directory for a given data directory.
// Production: /var/log/spinifex (matches systemd ReadWritePaths)
// Development: <dataDir>/logs (supports custom per-node data dirs)
func LogDirFor(dataDir string) string {
if isProductionLayout() {
return "/var/log/spinifex"
}
return filepath.Join(dataDir, "logs")
}
// realUserHomeDir returns the home directory of the real (non-sudo) user.
// When running under sudo, SUDO_USER is set to the invoking user — resolve
// their home directory so config/data land in the right place.
+6
View File
@@ -733,6 +733,12 @@ var vpcdStartCmd = &cobra.Command{
}
}
baseDir := viper.GetString("base-dir")
if baseDir != "" {
fmt.Println("Overwriting vpcd base-dir to:", baseDir)
nodeConfig.BaseDir = baseDir
}
svc, err := service.New("vpcd", &vpcd.Config{
NatsHost: nodeConfig.NATS.Host,
NatsToken: nodeConfig.NATS.ACL.Token,
+1 -1
View File
@@ -40,7 +40,7 @@ jetstream {
debug: false
trace: false
logtime: true
log_file: "{{.DataDir}}/logs/nats.log"
log_file: "{{.LogDir}}/nats.log"
# Limits
max_connections: 1000
+1 -1
View File
@@ -48,7 +48,7 @@ sudo apt install nbdkit nbdkit-plugin-dev pkg-config qemu-system qemu-utils qemu
**Note:** OVN and Open vSwitch are required for VPC networking (virtual switches, routers, DHCP, Geneve overlay). The packages are installed above, but the setup and configuration step (`setup-ovn.sh`) is covered later — see the [Setup OVN](#setup-ovn) section below.
Ensure the Go toolkit is installed for version 1.26.1 or higher. Recommended to install the latest directly from [https://go.dev/dl/](https://go.dev/dl/).
Ensure the Go toolkit is installed for version 1.26.2 or higher. Recommended to install the latest directly from [https://go.dev/dl/](https://go.dev/dl/).
Confirm Go is correctly installed, and set in your $PATH.
+1 -1
View File
@@ -32,7 +32,7 @@ This guide builds Spinifex from source. For production deployments, the [binary
**Requirements:**
- Ubuntu 22.04+ / Debian 12+
- Go 1.26.1+
- Go 1.26.2+
- GCC, make, pkg-config
- QEMU/KVM, OVN/Open vSwitch, AWS CLI v2, dhcpcd-base
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/mulgadc/spinifex
go 1.26.1
go 1.26.2
require (
github.com/aws/aws-sdk-go v1.55.8
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
# dev-install.sh — Full local development setup via production installer.
# Builds from source, assembles a tarball, runs setup.sh for scaffolding
# (user creation, directories, systemd units), initializes the cluster,
# and starts services via systemd.
#
# Usage: ./scripts/dev-install.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
MULGA_ROOT="$(cd "$PROJECT_ROOT/.." && pwd)"
echo "=== Building ==="
cd "$PROJECT_ROOT" && make build
cd "$MULGA_ROOT/viperblock" && make go_build_nbd
echo "=== Assembling tarball for setup.sh ==="
STAGING=$(mktemp -d)
trap 'rm -rf "$STAGING"' EXIT
cp "$PROJECT_ROOT/bin/spx" "$STAGING/spx"
cp "$MULGA_ROOT/viperblock/lib/nbdkit-viperblock-plugin.so" "$STAGING/"
cp "$PROJECT_ROOT/scripts/setup-ovn.sh" "$STAGING/"
mkdir -p "$STAGING/systemd"
cp "$PROJECT_ROOT/build/systemd/"* "$STAGING/systemd/"
mkdir -p "$STAGING/scripts"
cp "$PROJECT_ROOT/build/scripts/"* "$STAGING/scripts/"
cp "$PROJECT_ROOT/build/logrotate/spinifex" "$STAGING/logrotate-spinifex"
tar czf /tmp/spinifex-local.tar.gz -C "$STAGING" .
echo "=== Cleaning stale state from previous installs ==="
# Stop any running services before modifying files
sudo systemctl stop spinifex.target 2>/dev/null || true
sudo systemctl reset-failed 2>/dev/null || true
# Remove stale files owned by the dev user in production paths.
# Previous dev-mode installs (admin init without sudo, start-dev.sh) leave
# files owned by tf-user that service users (spinifex-nats, etc.) can't read
# under systemd's ProtectSystem=strict sandboxing.
for dir in /var/lib/spinifex /var/log/spinifex /etc/spinifex; do
if [ -d "$dir" ]; then
# Remove PID files, stale logs, and the legacy ~/spinifex/config symlink
sudo find "$dir" -name '*.pid' -delete 2>/dev/null || true
sudo find "$dir" -name '*.log' -delete 2>/dev/null || true
sudo find "$dir" -name '*.log.*' -delete 2>/dev/null || true
fi
done
# Remove legacy data dir contents that conflict with production layout
if [ -d /var/lib/spinifex/config ]; then
sudo rm -rf /var/lib/spinifex/config
fi
echo "=== Running setup.sh (creates users, dirs, systemd units) ==="
sudo INSTALL_SPINIFEX_TARBALL=/tmp/spinifex-local.tar.gz bash "$PROJECT_ROOT/scripts/setup.sh"
rm -f /tmp/spinifex-local.tar.gz
echo "=== Setting up OVN ==="
sudo /usr/local/share/spinifex/setup-ovn.sh --management
echo "=== Initializing ==="
sudo spx admin init --force --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1
echo "=== Installing CA certificate ==="
sudo cp /etc/spinifex/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
sudo update-ca-certificates
echo "=== Starting services ==="
sudo systemctl start spinifex.target
echo "=== Done ==="
echo "Services: sudo systemctl status spinifex.target"
echo "Logs: journalctl -u 'spinifex-*' -f"
echo "Test: AWS_PROFILE=spinifex aws ec2 describe-instances"
echo "Iterate: make deploy (rebuild + restart all)"
# Check if user needs to re-login for spinifex group membership
if ! id -Gn 2>/dev/null | grep -qw spinifex; then
echo ""
echo "NOTE: You were added to the 'spinifex' group but your current"
echo "session doesn't reflect it yet. Log out and back in, or run:"
echo " newgrp spinifex"
fi
+10 -18
View File
@@ -165,24 +165,16 @@ else
fi
echo ""
echo "🎉 Development environment setup complete!"
echo "Development environment setup complete!"
echo ""
echo "📋 Next steps:"
echo "Recommended: Use scripts/dev-install.sh for a production-parity setup"
echo " (systemd, privilege separation, production paths)."
echo ""
echo " 1. Setup OVN networking:"
echo " ./scripts/setup-ovn.sh --management"
echo " scripts/dev-install.sh # One-time: build, install, init, start via systemd"
echo " make deploy # Daily: rebuild + copy binary + restart all"
echo " make reinstall # Changed a .service file? Re-run full setup"
echo ""
echo " 2. Initialize Spinifex (creates ~/spinifex/ directories, config, certs, and installs CA):"
echo " sudo ./bin/spx admin init --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1"
echo ""
echo " 3. Start services:"
echo " ./scripts/start-dev.sh"
echo ""
echo "🔧 Development endpoints:"
echo " - Spinifex Gateway: https://localhost:$SPINIFEX_GATEWAY_PORT"
echo " - Predastore S3: https://localhost:$PREDASTORE_PORT"
echo " - NATS: nats://localhost:$NATS_PORT"
echo ""
echo "🧪 Test with AWS CLI:"
echo " export AWS_PROFILE=spinifex"
echo " aws ec2 describe-instances"
echo "Legacy workflow (deprecated, retained for pseudo-multinode CI only):"
echo " ./scripts/setup-ovn.sh --management"
echo " sudo ./bin/spx admin init --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1"
echo " ./scripts/start-dev.sh"
+1 -1
View File
@@ -7,7 +7,7 @@ Builds a Proxmox VM template with all Spinifex dependencies pre-installed. Cloni
| Component | Details |
|---|---|
| System packages | QEMU, nbdkit, libvirt, gcc, jq, curl, etc. (`make install-system`) |
| Go | 1.26.1 (`make install-go`) |
| Go | 1.26.2 (`make install-go`) |
| AWS CLI | v2 (`make install-aws`) |
| Repositories | spinifex, viperblock, predastore (with `go.work` configured) |
| Module cache | `go mod download` completed for all repos |
+38 -42
View File
@@ -481,7 +481,19 @@ echo " ovn-encap-type: geneve"
echo ""
echo "Step 5: Starting ovn-controller..."
sudo systemctl start ovn-controller
# Set ovn-controller file log level to WARN so it doesn't spam the log with
# connection-retry INFO messages ("OVNSB commit failed") when the SB DB
# isn't running. Uses a systemd ExecStartPost so it persists across restarts.
OVN_CTRL_OVERRIDE="/etc/systemd/system/ovn-controller.service.d/log-level.conf"
sudo mkdir -p "$(dirname "$OVN_CTRL_OVERRIDE")"
sudo tee "$OVN_CTRL_OVERRIDE" >/dev/null <<'OVERRIDE'
[Service]
ExecStartPost=/bin/sh -c 'OVS_RUNDIR=/var/run/ovn exec /usr/bin/ovs-appctl -t ovn-controller vlog/set file:warn'
OVERRIDE
sudo systemctl daemon-reload
echo " ovn-controller log level: file:warn (via systemd drop-in)"
sudo systemctl restart ovn-controller
echo " ovn-controller: started"
# --- Step 6: Sysctl tuning ---
@@ -565,59 +577,43 @@ else
echo " systemd override: already exists"
fi
# Create sudoers rule for network commands that always need root
# (ip tuntap, ip link set — NET_ADMIN operations)
# Sudoers rules for spinifex-daemon and spinifex-vpcd are managed by setup.sh
# (install_sudoers). Skip writing here to avoid conflicts.
SUDOERS_FILE="/etc/sudoers.d/spinifex-network"
if [ ! -f "$SUDOERS_FILE" ]; then
CURRENT_USER=$(whoami)
sudo tee "$SUDOERS_FILE" >/dev/null <<EOF
# Spinifex VPC networking: allow non-root daemon to manage tap devices and OVS
$CURRENT_USER ALL=(root) NOPASSWD: /sbin/ip, /usr/sbin/ip
$CURRENT_USER ALL=(root) NOPASSWD: /usr/bin/ovs-vsctl, /usr/bin/ovs-appctl
$CURRENT_USER ALL=(root) NOPASSWD: /usr/bin/ovn-nbctl, /usr/bin/ovn-sbctl
EOF
sudo chmod 0440 "$SUDOERS_FILE"
echo " sudoers rule: created ($SUDOERS_FILE)"
if [ -f "$SUDOERS_FILE" ]; then
echo " sudoers rule: already exists ($SUDOERS_FILE, managed by setup.sh)"
else
echo " sudoers rule: already exists"
echo " sudoers rule: not found — run setup.sh first, or install manually"
fi
# --- Step 9: Configure OVN log rotation ---
# OVN has built-in rotation that renames foo.log → foo.log.log. A previous
# logrotate config used a *.log glob that caught those .log.log files too,
# creating .log.log.1 files that accumulated to 27GB. Fix: use explicit
# filenames so logrotate only touches the primary logs, not OVN's backups.
# The ovn-common package provides /etc/logrotate.d/ovn-common which handles
# rotation and vlog/reopen. We just add maxsize + rotate to cap disk usage.
echo ""
echo "Step 9: Configuring OVN log rotation..."
# Clean up stale .log.log files from the old double-rotation bug
if ls /var/log/ovn/*.log.log* 1>/dev/null 2>&1; then
sudo rm -f /var/log/ovn/*.log.log*
echo " cleaned up stale .log.log files"
OVN_LOGROTATE="/etc/logrotate.d/ovn-common"
if [ -f "$OVN_LOGROTATE" ]; then
if ! grep -q 'maxsize' "$OVN_LOGROTATE"; then
sudo sed -i '/^\/var\/log\/ovn\/\*\.log {/a\ rotate 5\n maxsize 100M' "$OVN_LOGROTATE"
echo " added maxsize 100M + rotate 5 to $OVN_LOGROTATE"
else
echo " $OVN_LOGROTATE already has maxsize configured"
fi
else
echo " WARNING: $OVN_LOGROTATE not found — install ovn-common package"
fi
LOGROTATE_FILE="/etc/logrotate.d/ovn-spinifex"
sudo tee "$LOGROTATE_FILE" >/dev/null <<'LOGROTATE'
/var/log/ovn/ovn-controller.log
/var/log/ovn/ovn-northd.log
/var/log/ovn/ovsdb-server-nb.log
/var/log/ovn/ovsdb-server-sb.log
{
daily
rotate 3
maxsize 100M
compress
missingok
notifempty
copytruncate
}
LOGROTATE
echo " logrotate config: $LOGROTATE_FILE (daily, 100M max, 3 rotations, explicit filenames)"
# Remove our old custom config if present (superseded by patching ovn-common)
if [ -f /etc/logrotate.d/ovn-spinifex ]; then
sudo rm -f /etc/logrotate.d/ovn-spinifex
echo " removed obsolete /etc/logrotate.d/ovn-spinifex"
fi
# --- Step 10: Enable auto-start on boot ---
# OVN services should start with the system in production. ovn-controller uses
# exponential backoff if the SB DB isn't ready yet, so CPU impact is negligible.
# In dev, stop-dev.sh explicitly stops OVN when not needed.
# OVN services should start with the system in production. ovn-controller
# retries when the SB DB isn't ready; file log level is set to WARN (Step 5)
# to prevent log spam during those retries.
echo ""
echo "Step 10: Enabling OVN auto-start on boot..."
sudo systemctl enable openvswitch-switch 2>/dev/null || true
+114 -31
View File
@@ -89,27 +89,66 @@ detect_arch() {
info "Detected architecture: $MACHINE ($ARCH)"
}
# --- Detect service user ---
detect_service_user() {
# Use the real user — either the current user, or SUDO_USER if running via sudo
SPINIFEX_USER="${SUDO_USER:-$(whoami)}"
SPINIFEX_GROUP="$(id -gn "$SPINIFEX_USER")"
# --- Create per-service system users ---
create_service_users() {
SPINIFEX_GROUP="spinifex"
if [ "$SPINIFEX_USER" = "root" ]; then
fatal "Cannot determine service user. Do not run as root directly — use: curl ... | bash (as a normal user)"
# Create shared group
if ! getent group "$SPINIFEX_GROUP" > /dev/null 2>&1; then
$SUDO groupadd --system "$SPINIFEX_GROUP"
fi
info "Services will run as: $SPINIFEX_USER:$SPINIFEX_GROUP"
# Create per-service users with correct home directories
declare -A SERVICE_HOMES=(
[nats]="/var/lib/spinifex/nats"
[gw]="/var/lib/spinifex/awsgw"
[daemon]="/var/lib/spinifex/spinifex"
[storage]="/var/lib/spinifex/predastore"
[viperblock]="/var/lib/spinifex/viperblock"
[vpcd]="/var/lib/spinifex"
[ui]="/var/lib/spinifex"
)
for svc in nats gw daemon storage viperblock vpcd ui; do
local user="spinifex-${svc}"
if ! id "$user" > /dev/null 2>&1; then
$SUDO useradd --system --no-create-home \
--home-dir "${SERVICE_HOMES[$svc]}" \
--gid "$SPINIFEX_GROUP" \
--shell /usr/sbin/nologin \
"$user"
fi
done
# Allow the service user to manage OVN/OVS and tap devices
$SUDO tee /etc/sudoers.d/spinifex-network > /dev/null << SUDOERS
# Spinifex VPC networking: allow service user to manage tap devices, OVS, and OVN
${SPINIFEX_USER} ALL=(root) NOPASSWD: /sbin/ip, /usr/sbin/ip
${SPINIFEX_USER} ALL=(root) NOPASSWD: /usr/bin/ovs-vsctl, /usr/bin/ovs-appctl
${SPINIFEX_USER} ALL=(root) NOPASSWD: /usr/bin/ovn-nbctl, /usr/bin/ovn-sbctl
# Add invoking user to spinifex group for admin CLI access
ADMIN_USER="${SUDO_USER:-$(whoami)}"
if [ "$ADMIN_USER" != "root" ]; then
$SUDO usermod -aG "$SPINIFEX_GROUP" "$ADMIN_USER"
fi
# KVM access for daemon
if getent group kvm > /dev/null 2>&1; then
$SUDO usermod -aG kvm spinifex-daemon
fi
info "Service users created (spinifex-{nats,gw,daemon,storage,viperblock,vpcd,ui})"
}
# --- Install scoped sudoers rules ---
install_sudoers() {
$SUDO tee /etc/sudoers.d/spinifex-network > /dev/null << 'SUDOERS'
# Spinifex daemon: tap devices, OVS bridge management, and DHCP for external IPs
spinifex-daemon ALL=(root) NOPASSWD: /sbin/ip, /usr/sbin/ip
spinifex-daemon ALL=(root) NOPASSWD: /usr/bin/ovs-vsctl, /usr/bin/ovs-appctl
spinifex-daemon ALL=(root) NOPASSWD: /usr/sbin/dhcpcd
# Spinifex VPC daemon: OVN and OVS read/write, OVN controller status check
spinifex-vpcd ALL=(root) NOPASSWD: /usr/bin/ovs-vsctl, /usr/bin/ovs-appctl
spinifex-vpcd ALL=(root) NOPASSWD: /usr/bin/ovn-nbctl, /usr/bin/ovn-sbctl
spinifex-vpcd ALL=(root) NOPASSWD: /usr/bin/systemctl is-active --quiet ovn-controller
SUDOERS
$SUDO chmod 0440 /etc/sudoers.d/spinifex-network
info "Sudoers rules installed for $SPINIFEX_USER"
$SUDO visudo -cf /etc/sudoers.d/spinifex-network || fatal "Invalid sudoers syntax in spinifex-network"
info "Scoped sudoers rules installed for spinifex-daemon and spinifex-vpcd"
}
# --- Install apt dependencies ---
@@ -241,12 +280,14 @@ install_files() {
create_directories() {
info "Creating directories..."
# Top-level directories (root-owned, group-readable by spinifex)
$SUDO mkdir -p /etc/spinifex
$SUDO chmod 0700 /etc/spinifex
$SUDO chown "$SPINIFEX_USER:$SPINIFEX_GROUP" /etc/spinifex
$SUDO chmod 0750 /etc/spinifex
$SUDO chown "root:$SPINIFEX_GROUP" /etc/spinifex
$SUDO mkdir -p /var/lib/spinifex
$SUDO chown "$SPINIFEX_USER:$SPINIFEX_GROUP" /var/lib/spinifex
$SUDO chmod 0750 /var/lib/spinifex
$SUDO chown "root:$SPINIFEX_GROUP" /var/lib/spinifex
# Symlink so services that expect BaseDir/config/ can find /etc/spinifex/
if [ ! -e /var/lib/spinifex/config ]; then
@@ -259,26 +300,70 @@ create_directories() {
fi
$SUDO mkdir -p /var/log/spinifex
$SUDO chown "$SPINIFEX_USER:$SPINIFEX_GROUP" /var/log/spinifex
$SUDO chmod 0775 /var/log/spinifex
$SUDO chown "root:$SPINIFEX_GROUP" /var/log/spinifex
$SUDO mkdir -p /run/spinifex
$SUDO chown "$SPINIFEX_USER:$SPINIFEX_GROUP" /run/spinifex
$SUDO chmod 0775 /run/spinifex
$SUDO chown "root:$SPINIFEX_GROUP" /run/spinifex
$SUDO mkdir -p /run/spinifex/nbd
$SUDO chmod 0770 /run/spinifex/nbd
$SUDO chown "root:$SPINIFEX_GROUP" /run/spinifex/nbd
# Per-service config directories
$SUDO mkdir -p /etc/spinifex/nats
$SUDO chown "spinifex-nats:$SPINIFEX_GROUP" /etc/spinifex/nats
$SUDO chmod 0750 /etc/spinifex/nats
$SUDO mkdir -p /etc/spinifex/predastore
$SUDO chown "spinifex-storage:$SPINIFEX_GROUP" /etc/spinifex/predastore
$SUDO chmod 0750 /etc/spinifex/predastore
# Per-service data directories
$SUDO mkdir -p /var/lib/spinifex/nats
$SUDO chown "spinifex-nats:$SPINIFEX_GROUP" /var/lib/spinifex/nats
$SUDO chmod 0700 /var/lib/spinifex/nats
$SUDO mkdir -p /var/lib/spinifex/spinifex
$SUDO chown "spinifex-daemon:$SPINIFEX_GROUP" /var/lib/spinifex/spinifex
$SUDO chmod 0700 /var/lib/spinifex/spinifex
$SUDO mkdir -p /var/lib/spinifex/predastore
$SUDO chown "spinifex-storage:$SPINIFEX_GROUP" /var/lib/spinifex/predastore
$SUDO chmod 0700 /var/lib/spinifex/predastore
$SUDO mkdir -p /var/lib/spinifex/viperblock
$SUDO chown "$SPINIFEX_USER:$SPINIFEX_GROUP" /var/lib/spinifex/viperblock
$SUDO chown "spinifex-viperblock:$SPINIFEX_GROUP" /var/lib/spinifex/viperblock
$SUDO chmod 0700 /var/lib/spinifex/viperblock
$SUDO mkdir -p /var/lib/spinifex/vpcd
$SUDO chown "spinifex-vpcd:$SPINIFEX_GROUP" /var/lib/spinifex/vpcd
$SUDO chmod 0700 /var/lib/spinifex/vpcd
$SUDO mkdir -p /var/lib/spinifex/awsgw
$SUDO chown "spinifex-gw:$SPINIFEX_GROUP" /var/lib/spinifex/awsgw
$SUDO chmod 0700 /var/lib/spinifex/awsgw
# Symlink so awsgw's {BaseDir}/config/ paths resolve to /etc/spinifex/
if [ ! -e /var/lib/spinifex/awsgw/config ]; then
$SUDO ln -s /etc/spinifex /var/lib/spinifex/awsgw/config
fi
# Top-level config files (root-owned, group-readable)
# Generate environment file with install-specific values (e.g. arch-dependent paths)
$SUDO tee /etc/spinifex/systemd.env > /dev/null << EOF
# Generated by setup.sh — install-specific environment variables
SPINIFEX_VIPERBLOCK_PLUGIN_PATH=${PLUGINDIR}/nbdkit-viperblock-plugin.so
EOF
$SUDO chown "$SPINIFEX_USER:$SPINIFEX_GROUP" /etc/spinifex/systemd.env
$SUDO chown "spinifex-viperblock:$SPINIFEX_GROUP" /etc/spinifex/systemd.env
$SUDO chmod 0640 /etc/spinifex/systemd.env
info "Generated /etc/spinifex/systemd.env"
# Service helper scripts
# Service helper scripts (root-owned, group-executable by all service users)
if [ -d "$EXTRACT_DIR/scripts" ]; then
for script in "$EXTRACT_DIR"/scripts/*.sh; do
$SUDO install -o "$SPINIFEX_USER" -g "$SPINIFEX_GROUP" -m 0755 \
$SUDO install -o root -g "$SPINIFEX_GROUP" -m 0755 \
"$script" "/var/lib/spinifex/$(basename "$script")"
info " /var/lib/spinifex/$(basename "$script")"
done
@@ -294,15 +379,12 @@ install_systemd() {
fi
for unit in "$EXTRACT_DIR"/systemd/*; do
# Substitute User= and Group= with the detected service user
sed "s/^User=spinifex$/User=$SPINIFEX_USER/;s/^Group=spinifex$/Group=$SPINIFEX_GROUP/" \
"$unit" | $SUDO tee "/etc/systemd/system/$(basename "$unit")" > /dev/null
$SUDO chmod 0644 "/etc/systemd/system/$(basename "$unit")"
$SUDO install -m 0644 "$unit" "/etc/systemd/system/$(basename "$unit")"
info " /etc/systemd/system/$(basename "$unit")"
done
$SUDO systemctl daemon-reload
info "Systemd units installed (running as $SPINIFEX_USER:$SPINIFEX_GROUP)"
info "Systemd units installed (per-service users)"
}
# --- Install logrotate ---
@@ -343,7 +425,7 @@ print_summary() {
echo ""
echo " Version: $INSTALLED_VERSION"
echo " Architecture: $ARCH"
echo " Service user: $SPINIFEX_USER"
echo " Service users: spinifex-{nats,gw,daemon,storage,viperblock,vpcd,ui}"
echo " Binary: /usr/local/bin/spx"
echo " Config: /etc/spinifex/"
echo " Data: /var/lib/spinifex/"
@@ -384,7 +466,8 @@ main() {
handle_upgrade
install_apt_deps
install_aws_cli
detect_service_user
create_service_users
install_sudoers
download_spinifex
install_files
create_directories
+7
View File
@@ -11,6 +11,11 @@
set -euo pipefail
echo "WARNING: start-dev.sh is deprecated for local development." >&2
echo " Use 'make deploy' for daily iteration or 'scripts/dev-install.sh' for first-time setup." >&2
echo " start-dev.sh is retained only for pseudo-multinode CI (e2e_pseudo_multi)." >&2
echo "" >&2
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
MULGA_ROOT="$(cd "$PROJECT_ROOT/.." && pwd)"
@@ -33,6 +38,8 @@ echo "Using configuration directory: $CONFIG_DIR"
LOGS_DIR="$DATA_DIR/logs"
WAL_DIR="$DATA_DIR/spinifex"
echo "⚠️ Dev mode: all services run as $(whoami) without privilege separation"
echo ""
echo "🚀 Starting Spinifex development environment..."
echo "Project root: $PROJECT_ROOT"
echo "Data directory: $DATA_DIR"
+2 -2
View File
@@ -5,8 +5,8 @@
sudo apt update
sudo apt install -y make gcc git
wget https://go.dev/dl/go1.26.1.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.26.1.linux-amd64.tar.gz
wget https://go.dev/dl/go1.26.2.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.26.2.linux-amd64.tar.gz
echo "export PATH=$PATH:/usr/local/go/bin" >> $HOME/.bashrc
source $HOME/.bashrc
+96 -6
View File
@@ -40,6 +40,7 @@ type ConfigSettings struct {
Region string
NatsToken string
DataDir string
LogDir string
ConfigDir string
// Add more fields as needed
@@ -212,7 +213,9 @@ func CreateServiceDirectories(spxRoot string) {
filepath.Join(spxRoot, "nats"),
filepath.Join(spxRoot, "predastore"),
filepath.Join(spxRoot, "viperblock"),
filepath.Join(spxRoot, "vpcd"),
filepath.Join(spxRoot, "spinifex"),
filepath.Join(spxRoot, "awsgw"),
}
fmt.Println("\n📁 Creating directory structure...")
@@ -241,18 +244,30 @@ func FileExists(path string) bool {
func ChownRecursive(path, username string) {
u, err := user.Lookup(username)
if err != nil {
slog.Warn("ChownRecursive: user lookup failed, skipping", "user", username, "path", path, "err", err)
return
}
uid, err := strconv.Atoi(u.Uid)
if err != nil {
slog.Warn("ChownRecursive: invalid UID, skipping", "user", username, "uid", u.Uid, "err", err)
return
}
gid, err := strconv.Atoi(u.Gid)
if err != nil {
slog.Warn("ChownRecursive: invalid GID, skipping", "user", username, "gid", u.Gid, "err", err)
return
}
uid, _ := strconv.Atoi(u.Uid)
gid, _ := strconv.Atoi(u.Gid)
// Use os.Root to scope filesystem operations and avoid symlink TOCTOU races.
// Falls back to direct chown if os.Root is not available.
root, rootErr := os.OpenRoot(path)
if rootErr != nil {
// Fallback: direct chown on the top-level path only
if chownErr := os.Chown(path, uid, gid); chownErr != nil { // #nosec G122
slog.Debug("chown failed", "path", path, "err", chownErr)
// Fallback: direct chown on the top-level path only — subdirectory contents
// will retain their original ownership.
slog.Warn("ChownRecursive: OpenRoot not available, only top-level directory ownership changed",
"path", path, "err", rootErr)
if chownErr := os.Lchown(path, uid, gid); chownErr != nil {
slog.Warn("chown failed", "path", path, "err", chownErr)
}
return
}
@@ -271,6 +286,81 @@ func ChownRecursive(path, username string) {
})
}
// SetServiceOwnership sets per-service ownership on data/config directories
// and shared config files to root:spinifex with correct modes.
// Keep in sync with setup.sh create_directories() and plan doc section 2.
func SetServiceOwnership() {
grp, err := user.LookupGroup("spinifex")
if err != nil {
slog.Error("SetServiceOwnership: spinifex group not found, skipping all ownership changes", "err", err)
fmt.Fprintln(os.Stderr, "WARNING: spinifex group not found — service ownership not set. Run setup.sh first or create the group manually.")
return
}
gid, err := strconv.Atoi(grp.Gid)
if err != nil {
slog.Error("SetServiceOwnership: invalid spinifex group GID", "gid", grp.Gid, "err", err)
fmt.Fprintln(os.Stderr, "WARNING: invalid spinifex group GID — service ownership not set.")
return
}
// Per-service directory trees
for path, u := range map[string]string{
"/etc/spinifex/nats": "spinifex-nats",
"/var/lib/spinifex/nats": "spinifex-nats",
"/etc/spinifex/predastore": "spinifex-storage",
"/var/lib/spinifex/predastore": "spinifex-storage",
"/var/lib/spinifex/spinifex": "spinifex-daemon",
"/var/lib/spinifex/viperblock": "spinifex-viperblock",
"/var/lib/spinifex/vpcd": "spinifex-vpcd",
"/var/lib/spinifex/awsgw": "spinifex-gw",
} {
if _, err := os.Stat(path); err != nil {
continue
}
ChownRecursive(path, u)
}
// Shared data directories — root:spinifex 0770 so daemon + admin CLI can write
for _, dir := range []string{
"/var/lib/spinifex/images",
"/var/lib/spinifex/amis",
"/var/lib/spinifex/volumes",
"/var/lib/spinifex/state",
"/run/spinifex/nbd",
} {
if _, err := os.Stat(dir); err != nil {
continue
}
if err := os.Lchown(dir, 0, gid); err != nil {
slog.Warn("SetServiceOwnership: chown failed", "path", dir, "err", err)
}
if err := os.Chmod(dir, 0770); err != nil { //nolint:gosec // directories need group-write for daemon + admin CLI
slog.Warn("SetServiceOwnership: chmod failed", "path", dir, "err", err)
}
}
// Shared config files — root:spinifex, ca.key stays root:root 0600
// bootstrap.json lives in the awsgw data dir (not /etc/spinifex),
// so /etc/spinifex stays at 0750 (no group-write needed).
for path, mode := range map[string]os.FileMode{
"/etc/spinifex/spinifex.toml": 0640,
"/etc/spinifex/master.key": 0640,
"/etc/spinifex/server.pem": 0644,
"/etc/spinifex/server.key": 0640,
"/etc/spinifex/ca.pem": 0644,
} {
if _, err := os.Stat(path); err != nil {
continue
}
if err := os.Lchown(path, 0, gid); err != nil {
slog.Warn("SetServiceOwnership: chown failed", "path", path, "err", err)
}
if err := os.Chmod(path, mode); err != nil {
slog.Warn("SetServiceOwnership: chmod failed", "path", path, "err", err)
}
}
}
// updateAWSINIFile updates or creates an AWS INI file section with given key-value pairs
func UpdateAWSINIFile(path, section string, values map[string]string) error {
var cfg *ini.File
@@ -753,7 +843,7 @@ func SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP string)
if err := UpdateAWSINIFile(configPath, configSection, map[string]string{
"region": region,
"endpoint_url": fmt.Sprintf("https://%s:9999", endpointHost),
"endpoint_url": "https://" + net.JoinHostPort(endpointHost, "9999"),
"ca_bundle": certPath,
"output": "json",
}); err != nil {
+42 -42
View File
@@ -55,80 +55,80 @@ type BootstrapConfig struct {
// Config holds all configuration for the application
type Config struct {
// Node config
Node string `mapstructure:"node"`
Host string `mapstructure:"host"` // Unique hostname or IP of this node
Region string `mapstructure:"region"`
AZ string `mapstructure:"az"`
DataDir string `mapstructure:"data_dir"`
Services []string `mapstructure:"services"` // Which services this node runs locally
Node string `json:"Node" mapstructure:"node"`
Host string `json:"Host" mapstructure:"host"` // Unique hostname or IP of this node
Region string `json:"Region" mapstructure:"region"`
AZ string `json:"AZ" mapstructure:"az"`
DataDir string `json:"DataDir" mapstructure:"data_dir"`
Services []string `json:"Services" mapstructure:"services"` // Which services this node runs locally
Daemon DaemonConfig `mapstructure:"daemon"`
NATS NATSConfig `mapstructure:"nats"`
Predastore PredastoreConfig `mapstructure:"predastore"`
Viperblock ViperblockConfig `mapstructure:"viperblock"`
AWSGW AWSGWConfig `mapstructure:"awsgw"`
VPCD VPCDConfig `mapstructure:"vpcd"`
Daemon DaemonConfig `json:"Daemon" mapstructure:"daemon"`
NATS NATSConfig `json:"NATS" mapstructure:"nats"`
Predastore PredastoreConfig `json:"Predastore" mapstructure:"predastore"`
Viperblock ViperblockConfig `json:"Viperblock" mapstructure:"viperblock"`
AWSGW AWSGWConfig `json:"AWSGW" mapstructure:"awsgw"`
VPCD VPCDConfig `json:"VPCD" mapstructure:"vpcd"`
BaseDir string `mapstructure:"base_dir"`
WalDir string `mapstructure:"wal_dir"`
BaseDir string `json:"BaseDir" mapstructure:"base_dir"`
WalDir string `json:"WalDir" mapstructure:"wal_dir"`
}
type AWSGWConfig struct {
Host string `mapstructure:"host"`
TLSKey string `mapstructure:"tlskey"`
TLSCert string `mapstructure:"tlscert"`
Config string `mapstructure:"config"`
Host string `json:"Host" mapstructure:"host"`
TLSKey string `json:"TLSKey" mapstructure:"tlskey"`
TLSCert string `json:"TLSCert" mapstructure:"tlscert"`
Config string `json:"Config" mapstructure:"config"`
Debug bool `mapstructure:"debug"`
ExpectedNodes int `mapstructure:"expected_nodes"` // TODO: Replace with root cluster config
Debug bool `json:"Debug" mapstructure:"debug"`
ExpectedNodes int `json:"ExpectedNodes" mapstructure:"expected_nodes"` // TODO: Replace with root cluster config
}
type ViperblockConfig struct {
ShardWAL *bool `mapstructure:"shardwal"` // Enable sharded WAL (default false when nil)
ShardWAL *bool `json:"ShardWAL" mapstructure:"shardwal"` // Enable sharded WAL (default false when nil)
}
// VPCDConfig holds the VPC daemon (vpcd) configuration.
type VPCDConfig struct {
OVNNBAddr string `mapstructure:"ovn_nb_addr"` // OVN Northbound DB address (e.g., "tcp:127.0.0.1:6641")
OVNSBAddr string `mapstructure:"ovn_sb_addr"` // OVN Southbound DB address (e.g., "tcp:127.0.0.1:6642")
ExternalInterface string `mapstructure:"external_interface"` // WAN NIC name (e.g., "eth1", "enp0s3") — the physical NIC on the WAN bridge
WanBridge string `mapstructure:"wan_bridge"` // OVS bridge for WAN traffic (default "br-wan", maps to OVN "external" network)
BridgeMode string `mapstructure:"bridge_mode"` // "direct" or "macvlan" (auto-detected if empty)
OVNNBAddr string `json:"OVNNBAddr" mapstructure:"ovn_nb_addr"` // OVN Northbound DB address (e.g., "tcp:127.0.0.1:6641")
OVNSBAddr string `json:"OVNSBAddr" mapstructure:"ovn_sb_addr"` // OVN Southbound DB address (e.g., "tcp:127.0.0.1:6642")
ExternalInterface string `json:"ExternalInterface" mapstructure:"external_interface"` // WAN NIC name (e.g., "eth1", "enp0s3") — the physical NIC on the WAN bridge
WanBridge string `json:"WanBridge" mapstructure:"wan_bridge"` // OVS bridge for WAN traffic (default "br-wan", maps to OVN "external" network)
BridgeMode string `json:"BridgeMode" mapstructure:"bridge_mode"` // "direct" or "macvlan" (auto-detected if empty)
}
type PredastoreConfig struct {
Host string `mapstructure:"host"`
Bucket string `mapstructure:"bucket"`
Region string `mapstructure:"region"`
AccessKey string `mapstructure:"accesskey"`
SecretKey string `mapstructure:"secretkey"`
BaseDir string `mapstructure:"base_dir"`
NodeID int `mapstructure:"node_id"`
Host string `json:"Host" mapstructure:"host"`
Bucket string `json:"Bucket" mapstructure:"bucket"`
Region string `json:"Region" mapstructure:"region"`
AccessKey string `json:"AccessKey" mapstructure:"accesskey"`
SecretKey string `json:"SecretKey" mapstructure:"secretkey"`
BaseDir string `json:"BaseDir" mapstructure:"base_dir"`
NodeID int `json:"NodeID" mapstructure:"node_id"`
}
// DaemonConfig holds the daemon configuration
type DaemonConfig struct {
Host string `mapstructure:"host"`
TLSKey string `mapstructure:"tlskey"`
TLSCert string `mapstructure:"tlscert"`
DevNetworking bool `mapstructure:"dev_networking"` // VPC instances get both TAP + hostfwd for SSH dev access
Host string `json:"Host" mapstructure:"host"`
TLSKey string `json:"TLSKey" mapstructure:"tlskey"`
TLSCert string `json:"TLSCert" mapstructure:"tlscert"`
DevNetworking bool `json:"DevNetworking" mapstructure:"dev_networking"` // VPC instances get both TAP + hostfwd for SSH dev access
}
// NATSConfig holds the NATS configuration
type NATSConfig struct {
Host string `mapstructure:"host"`
ACL NATSACL `mapstructure:"acl"`
Sub NATSSub `mapstructure:"sub"`
Host string `json:"Host" mapstructure:"host"`
ACL NATSACL `json:"ACL" mapstructure:"acl"`
Sub NATSSub `json:"Sub" mapstructure:"sub"`
}
// NATSACL holds the NATS ACL configuration
type NATSACL struct {
Token string `mapstructure:"token"`
Token string `json:"Token" mapstructure:"token"`
}
// NATSSub holds the NATS subscription configuration
type NATSSub struct {
Subject string `mapstructure:"subject"`
Subject string `json:"Subject" mapstructure:"subject"`
}
// NodeBaseDir returns the BaseDir for the current node, or "" if the config
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"log/slog"
"net"
"net/http"
"strconv"
"sync"
"time"
@@ -202,7 +203,7 @@ func (d *Daemon) queryNATSRole() string {
if !d.config.HasService("nats") {
return ""
}
url := fmt.Sprintf("http://%s:%d/varz", d.daemonIP(), natsMonitorPort)
url := "http://" + net.JoinHostPort(d.daemonIP(), strconv.Itoa(natsMonitorPort)) + "/varz"
return fetchNATSRole(url, roleHTTPClient)
}
@@ -212,7 +213,7 @@ func (d *Daemon) queryPredastoreRole() string {
if !d.config.HasService("predastore") {
return ""
}
url := fmt.Sprintf("https://%s:%d/status", d.daemonIP(), predastoreDBPort)
url := "https://" + net.JoinHostPort(d.daemonIP(), strconv.Itoa(predastoreDBPort)) + "/status"
return fetchPredastoreRole(url, roleTLSHTTPClient)
}
-6
View File
@@ -31,12 +31,6 @@ const (
func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip OPTIONS requests (CORS preflight)
if r.Method == http.MethodOptions {
next.ServeHTTP(w, r)
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
gw.writeSigV4Error(w, r, awserrors.ErrorMissingAuthenticationToken)
-14
View File
@@ -333,20 +333,6 @@ func TestSigV4Auth_ValidSignature(t *testing.T) {
}
}
func TestSigV4Auth_OptionsSkipsAuth(t *testing.T) {
handler := setupTestApp(testAccessKey, testSecretKey)
req := httptest.NewRequest(http.MethodOptions, "/", nil)
req.Host = "localhost:9999"
// No Authorization header
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200 for OPTIONS, got %d", resp.StatusCode)
}
}
func TestSigV4Auth_ValidSignatureWithBody(t *testing.T) {
handler := setupTestApp(testAccessKey, testSecretKey)
@@ -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),
}
-64
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
@@ -98,9 +97,6 @@ func (gw *GatewayConfig) SetupRoutes() http.Handler {
r.Use(slogRequestLogger)
}
// CORS middleware for browser requests
r.Use(corsMiddleware)
// AWS SigV4 authentication middleware
r.Use(gw.SigV4AuthMiddleware())
@@ -110,66 +106,6 @@ func (gw *GatewayConfig) SetupRoutes() http.Handler {
return r
}
// corsAllowedOrigins builds the set of allowed CORS origins from localhost and
// all local non-loopback IPs on the spinifex-ui port (default 3000). This allows
// the UI to be accessed from any local address, not just localhost.
func corsAllowedOrigins() map[string]struct{} {
origins := map[string]struct{}{
"https://localhost:3000": {},
}
ifaces, err := net.Interfaces()
if err != nil {
return origins
}
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
}
origins[fmt.Sprintf("https://%s:3000", ip.String())] = struct{}{}
}
}
return origins
}
// corsMiddleware adds CORS headers for browser requests from the spinifex-ui.
func corsMiddleware(next http.Handler) http.Handler {
allowed := corsAllowedOrigins()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
origin = "https://localhost:3000"
}
if _, ok := allowed[origin]; ok {
w.Header().Set("Access-Control-Allow-Origin", origin)
} else {
w.Header().Set("Access-Control-Allow-Origin", "https://localhost:3000")
}
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,HEAD,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
w.Header().Set("Access-Control-Allow-Credentials", "true")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// Note, custom endpoints can be configured via ENV vars to the AWS SDK/CLI tool, with individual endpoints depending the service
// AWS_ENDPOINT_URL_EC2=https://localhost:9999/ aws --no-verify-ssl ec2 describe-instances
// aws --endpoint-url https://localhost:9999/ --no-verify-ssl eks list-clusters
-72
View File
@@ -882,78 +882,6 @@ func TestIsNATSTransient(t *testing.T) {
assert.False(t, isNATSTransient(errors.New("some other error")))
}
func TestCorsMiddleware_GETRequest(t *testing.T) {
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
})
handler := corsMiddleware(next)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.True(t, nextCalled)
assert.Equal(t, "https://localhost:3000", w.Header().Get("Access-Control-Allow-Origin"))
assert.Equal(t, "GET,POST,PUT,DELETE,HEAD,OPTIONS", w.Header().Get("Access-Control-Allow-Methods"))
assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Headers"))
assert.Equal(t, "true", w.Header().Get("Access-Control-Allow-Credentials"))
}
func TestCorsMiddleware_OPTIONSRequest(t *testing.T) {
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
})
handler := corsMiddleware(next)
req := httptest.NewRequest(http.MethodOptions, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.False(t, nextCalled, "next handler should not be called for OPTIONS")
assert.Equal(t, http.StatusNoContent, w.Code)
assert.Equal(t, "https://localhost:3000", w.Header().Get("Access-Control-Allow-Origin"))
}
func TestCorsMiddleware_LocalIPOrigin(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := corsMiddleware(next)
// Request from a local IP should be reflected
allowed := corsAllowedOrigins()
for origin := range allowed {
if origin == "https://localhost:3000" {
continue
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", origin)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, origin, w.Header().Get("Access-Control-Allow-Origin"))
return // test one IP is enough
}
}
func TestCorsMiddleware_UnknownOrigin(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := corsMiddleware(next)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://evil.example.com")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
// Unknown origins fall back to localhost
assert.Equal(t, "https://localhost:3000", w.Header().Get("Access-Control-Allow-Origin"))
}
func TestSlogRequestLogger_CallsNext(t *testing.T) {
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+2 -2
View File
@@ -86,8 +86,8 @@ func matchWildcard(pattern, value string) bool {
return true
}
if strings.HasSuffix(pattern, "*") {
prefix := pattern[:len(pattern)-1]
return strings.HasPrefix(value, prefix)
prefix := strings.ToLower(pattern[:len(pattern)-1])
return strings.HasPrefix(strings.ToLower(value), prefix)
}
return strings.EqualFold(pattern, value)
}
@@ -233,11 +233,13 @@ func TestMatchWildcard(t *testing.T) {
{"ec2:*", "ec2:RunInstances", true},
{"ec2:*", "ec2:DescribeInstances", true},
{"ec2:*", "s3:GetObject", false},
{"EC2:*", "ec2:RunInstances", true},
// Prefix wildcard
{"s3:Get*", "s3:GetObject", true},
{"s3:Get*", "s3:GetBucketPolicy", true},
{"s3:Get*", "s3:PutObject", false},
{"S3:get*", "s3:GetObject", true},
// Exact match
{"ec2:RunInstances", "ec2:RunInstances", true},
+4 -2
View File
@@ -6,7 +6,9 @@ import (
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"strconv"
"sync"
"time"
@@ -117,7 +119,7 @@ func queryDBNodeStatus(ctx context.Context, out *DBNodeStatus, host string, port
}
// Query /health
healthURL := fmt.Sprintf("https://%s:%d/health", queryHost, port)
healthURL := "https://" + net.JoinHostPort(queryHost, strconv.Itoa(port)) + "/health"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
if err != nil {
return
@@ -131,7 +133,7 @@ func queryDBNodeStatus(ctx context.Context, out *DBNodeStatus, host string, port
out.Healthy = resp.StatusCode == http.StatusOK
// Query /status
statusURL := fmt.Sprintf("https://%s:%d/status", queryHost, port)
statusURL := "https://" + net.JoinHostPort(queryHost, strconv.Itoa(port)) + "/status"
req, err = http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
if err != nil {
return
+24 -3
View File
@@ -107,8 +107,10 @@ 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
bootstrapPath := filepath.Join(nodeConfig.BaseDir, "config", "bootstrap.json")
// First boot: consume bootstrap.json → seed IAM users into NATS KV → delete file.
// Check data directory first (production: /var/lib/spinifex/awsgw/), then
// awsgw subdir (dev: ~/spinifex/awsgw/), then legacy config dir.
bootstrapPath := findBootstrapFile(nodeConfig.BaseDir)
data, err := handlers_iam.LoadBootstrapData(bootstrapPath)
switch {
case err == nil:
@@ -118,8 +120,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:
@@ -223,3 +226,21 @@ func initIAMService(natsConn *nats.Conn, masterKey []byte, clusterSize int) (*ha
retryDelay = min(retryDelay*2, 10*time.Second)
}
}
// findBootstrapFile returns the path to bootstrap.json, checking the data
// directory first (production), then the awsgw subdir (dev mode), then the
// legacy config dir. Returns the first path that exists, or the primary
// location if none exist (so the caller gets a clean "not found" error).
func findBootstrapFile(baseDir string) string {
candidates := []string{
filepath.Join(baseDir, "bootstrap.json"),
filepath.Join(baseDir, "awsgw", "bootstrap.json"),
filepath.Join(baseDir, "config", "bootstrap.json"),
}
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
return p
}
}
return candidates[0]
}
@@ -22,6 +22,7 @@ import (
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"testing"
@@ -252,7 +253,7 @@ func waitForServer(timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := client.Get(fmt.Sprintf("https://%s:%d/", testHost, testPort))
resp, err := client.Get("https://" + net.JoinHostPort(testHost, strconv.Itoa(testPort)) + "/")
if err == nil {
resp.Body.Close()
// Give a bit more time for config to load
@@ -273,7 +274,7 @@ func createS3Client(accessKey, secretKey string) *s3.S3 {
sess := session.Must(session.NewSession(&aws.Config{
Region: aws.String(testRegion),
Endpoint: aws.String(fmt.Sprintf("https://%s:%d", testHost, testPort)),
Endpoint: aws.String("https://" + net.JoinHostPort(testHost, strconv.Itoa(testPort))),
Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""),
S3ForcePathStyle: aws.Bool(true),
DisableSSL: aws.Bool(false),
@@ -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)
}
+159 -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,161 @@ 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, ok := backend.Client().Transport.(*http.Transport)
require.True(t, ok, "expected *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"
"io"
"net"
"net/http"
"strconv"
"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 := "https://" + net.JoinHostPort(host, strconv.Itoa(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)
}
@@ -601,6 +601,14 @@ func launchService(cfg *Config) (err error) {
slog.Info("NBDKit started successfully and is running")
}
// NBDKit creates the socket with its own umask (typically 0755).
// The daemon (different user, same group) needs write access to connect.
if nbdSocket != "" {
if err := os.Chmod(nbdSocket, 0770); err != nil { //nolint:gosec // socket needs group-write for cross-service access
slog.Warn("Failed to chmod NBD socket", "socket", nbdSocket, "err", err)
}
}
ebsResponse.Mounted = true
ebsResponse.URI = nbdURI
+3 -1
View File
@@ -95,7 +95,9 @@ func TestTopologyHandler_VPCDelete(t *testing.T) {
t.Fatalf("request vpc.delete: %v", err)
}
var result struct{ Success bool }
var result struct {
Success bool `json:"Success"`
}
_ = json.Unmarshal(resp.Data, &result)
if !result.Success {
t.Fatal("vpc.delete failed")
+28 -28
View File
@@ -3,19 +3,19 @@ package types
import "sync"
type EBSRequests struct {
Requests []EBSRequest `mapstructure:"ebs_requests"`
Requests []EBSRequest `json:"Requests" mapstructure:"ebs_requests"`
Mu sync.Mutex `json:"-"`
}
type EBSRequest struct {
Name string
VolType string
Boot bool
EFI bool
CloudInit bool
DeleteOnTermination bool
NBDURI string // NBD URI - socket path (nbd:unix:/path.sock) or TCP (nbd://host:port)
DeviceName string // AWS API device name (e.g. /dev/sdf) for hot-plugged volumes
Name string `json:"Name"`
VolType string `json:"VolType"`
Boot bool `json:"Boot"`
EFI bool `json:"EFI"`
CloudInit bool `json:"CloudInit"`
DeleteOnTermination bool `json:"DeleteOnTermination"`
NBDURI string `json:"NBDURI"` // NBD URI - socket path (nbd:unix:/path.sock) or TCP (nbd://host:port)
DeviceName string `json:"DeviceName"` // AWS API device name (e.g. /dev/sdf) for hot-plugged volumes
}
// NBDTransport defines the transport type for NBD connections
@@ -29,44 +29,44 @@ const (
)
type EBSMountResponse struct {
URI string
Mounted bool
Error string
URI string `json:"URI"`
Mounted bool `json:"Mounted"`
Error string `json:"Error"`
}
type EBSUnMountResponse struct {
Volume string
Mounted bool
Error string
Volume string `json:"Volume"`
Mounted bool `json:"Mounted"`
Error string `json:"Error"`
}
type EBSSyncRequest struct {
Volume string
Volume string `json:"Volume"`
}
type EBSSyncResponse struct {
Volume string
Synced bool
Error string
Volume string `json:"Volume"`
Synced bool `json:"Synced"`
Error string `json:"Error"`
}
type EBSDeleteRequest struct {
Volume string
Volume string `json:"Volume"`
}
type EBSDeleteResponse struct {
Volume string
Success bool
Error string
Volume string `json:"Volume"`
Success bool `json:"Success"`
Error string `json:"Error"`
}
type EBSSnapshotRequest struct {
Volume string
SnapshotID string
Volume string `json:"Volume"`
SnapshotID string `json:"SnapshotID"`
}
type EBSSnapshotResponse struct {
SnapshotID string
Success bool
Error string
SnapshotID string `json:"SnapshotID"`
Success bool `json:"Success"`
Error string `json:"Error"`
}
+17 -5
View File
@@ -3,6 +3,7 @@ package utils
import (
"errors"
"fmt"
"net"
"path/filepath"
"strconv"
"strings"
@@ -33,14 +34,25 @@ func GenerateUniqueSocketFile(volname string) (string, error) {
return "", errors.New("volume name is required")
}
pidPath := pidPath()
if pidPath == "" {
return "", errors.New("pid path is empty")
dir := NBDSocketDir()
if dir == "" {
return "", errors.New("nbd socket directory is empty")
}
timestamp := time.Now().UnixNano()
filename := fmt.Sprintf("nbd-%s-%d.sock", volname, timestamp)
return filepath.Join(pidPath, filename), nil
return filepath.Join(dir, filename), nil
}
// NBDSocketDir returns the directory for NBD Unix sockets.
// Under systemd installs, this is /run/spinifex/nbd/.
// In dev mode (no /run/spinifex/nbd/), falls back to pidPath().
func NBDSocketDir() string {
const systemdNBDDir = "/run/spinifex/nbd"
if dirExists(systemdNBDDir) {
return systemdNBDDir
}
return pidPath()
}
// IsSocketURI returns true if the NBD URI is a Unix socket path.
@@ -58,7 +70,7 @@ func FormatNBDSocketURI(socketPath string) string {
// FormatNBDTCPURI formats a host:port as an NBD URI for QEMU.
// Returns format: nbd://host:port
func FormatNBDTCPURI(host string, port int) string {
return fmt.Sprintf("nbd://%s:%d", host, port)
return "nbd://" + net.JoinHostPort(host, strconv.Itoa(port))
}
// ParseNBDURI parses an NBD URI into its components for QMP blockdev-add.
+20 -5
View File
@@ -530,21 +530,36 @@ dump_all_node_logs() {
echo "DUMPING LOGS FROM ALL NODES"
echo "=========================================="
# Show running spx/nats processes for context
echo ""
echo "--- Running processes ---"
ps auxw | grep -E 'spx|nats-server' | grep -v grep || echo "(none)"
for i in 1 2 3; do
local data_dir="$HOME/node$i"
local logs_dir="$data_dir/logs"
if [ -d "$logs_dir" ]; then
echo ""
echo "=== Node$i Logs ==="
echo ""
echo "=== Node$i ==="
# Show directory structure
echo "--- $data_dir contents ---"
sudo ls -la "$data_dir/" 2>/dev/null || echo "(dir not found)"
if sudo test -d "$logs_dir"; then
echo "--- $logs_dir contents ---"
sudo ls -la "$logs_dir/" 2>/dev/null || true
for log in nats predastore viperblock spinifex awsgw; do
if [ -f "$logs_dir/$log.log" ]; then
if sudo test -f "$logs_dir/$log.log"; then
echo ""
echo "--- $log.log (last 50 lines) ---"
tail -50 "$logs_dir/$log.log" 2>/dev/null || echo "(empty or not accessible)"
sudo tail -50 "$logs_dir/$log.log" 2>/dev/null || echo "(empty or not accessible)"
fi
done
else
echo "--- $logs_dir not found, checking PID files ---"
sudo find "$data_dir" -name '*.pid' -o -name '*.log' 2>/dev/null || true
fi
done
+19 -28
View File
@@ -11,9 +11,6 @@ set -euo pipefail
# Ensure Go is on PATH (SSH non-interactive shells don't source .bashrc)
export PATH="/usr/local/go/bin:$HOME/go/bin:$PATH"
# Ensure we are in the spinifex project root
cd "$(dirname "$0")/../.."
# ==========================================================================
# Argument parsing
# ==========================================================================
@@ -36,14 +33,12 @@ NATS_MONITOR_PORT=8222
PREDASTORE_PORT=8443
AWSGW_PORT=9999
SSH_KEY_PATH="$HOME/.ssh/tf-user-ap-southeast-2"
SPINIFEX_DATA_DIR="$HOME/spinifex"
SPINIFEX_CONFIG="$SPINIFEX_DATA_DIR/config/spinifex.toml"
SPINIFEX_BIN="./bin/spx"
SPINIFEX_BIN="spx"
# Use Spinifex profile for AWS CLI
export AWS_PROFILE=spinifex
# Trust Spinifex CA for AWS CLI v2 (bundles its own Python/certifi, ignores system CA store)
export AWS_CA_BUNDLE="$SPINIFEX_DATA_DIR/config/ca.pem"
export AWS_CA_BUNDLE="/etc/spinifex/ca.pem"
# Track test results
TESTS_PASSED=0
@@ -106,19 +101,15 @@ dump_all_node_logs() {
local ip="${NODE_IPS[$i]}"
echo ""
echo "=== Node $((i+1)) ($ip) ==="
if [ "$ip" = "$LOCAL_IP" ]; then
for f in "$SPINIFEX_DATA_DIR/logs/"*.log; do
[ -f "$f" ] || continue
echo "--- $(basename "$f") (last 50 lines) ---"
tail -50 "$f" 2>/dev/null || echo "(not found)"
done
else
peer_ssh "$ip" 'for f in ~/spinifex/logs/*.log; do
[ -f "$f" ] || continue
echo "--- $(basename "$f") (last 50 lines) ---"
tail -50 "$f" 2>/dev/null || echo "(not found)"
done' || echo "(node unreachable)"
fi
for svc in spinifex-nats spinifex-predastore spinifex-viperblock \
spinifex-daemon spinifex-awsgw spinifex-vpcd; do
echo "--- ${svc} (last 50 lines) ---"
if [ "$ip" = "$LOCAL_IP" ]; then
sudo journalctl -u "$svc" --no-pager -n 50 2>/dev/null || echo "(not found)"
else
peer_ssh "$ip" "sudo journalctl -u $svc --no-pager -n 50" 2>/dev/null || echo "(node unreachable)"
fi
done
done
echo ""
echo "=========================================="
@@ -403,7 +394,7 @@ pass_test "Daemon readiness"
# Spinifex CLI: get nodes
echo "Checking spx get nodes..."
GET_NODES_OUTPUT=$($SPINIFEX_BIN get nodes --config "$SPINIFEX_CONFIG" --timeout 5s 2>/dev/null)
GET_NODES_OUTPUT=$($SPINIFEX_BIN get nodes --timeout 5s 2>/dev/null)
echo "$GET_NODES_OUTPUT"
READY_COUNT=$(echo "$GET_NODES_OUTPUT" | grep -c "Ready" || true)
if [ "$READY_COUNT" -ge 3 ]; then
@@ -415,7 +406,7 @@ fi
# Spinifex CLI: get vms (should be empty)
echo "Checking spx get vms (empty)..."
GET_VMS_OUTPUT=$($SPINIFEX_BIN get vms --config "$SPINIFEX_CONFIG" --timeout 5s 2>/dev/null)
GET_VMS_OUTPUT=$($SPINIFEX_BIN get vms --timeout 5s 2>/dev/null)
echo "$GET_VMS_OUTPUT"
pass_test "spx get vms (empty)"
@@ -500,7 +491,7 @@ fi
# Verify spx get vms shows all instances
echo ""
echo "Verifying spx get vms..."
GET_VMS_OUTPUT=$($SPINIFEX_BIN get vms --config "$SPINIFEX_CONFIG" --timeout 5s 2>/dev/null)
GET_VMS_OUTPUT=$($SPINIFEX_BIN get vms --timeout 5s 2>/dev/null)
echo "$GET_VMS_OUTPUT"
for instance_id in "${INSTANCE_IDS[@]}"; do
if ! echo "$GET_VMS_OUTPUT" | grep -q "$instance_id"; then
@@ -787,8 +778,8 @@ echo "Stopping services on node2 ($NODE2_IP) to simulate node failure..."
# Stop services on node2 only — SPINIFEX_FORCE_LOCAL_STOP prevents coordinated
# cluster shutdown which would kill all nodes via NATS
peer_ssh "$NODE2_IP" "cd ~/Development/mulga/spinifex && SPINIFEX_FORCE_LOCAL_STOP=1 ./scripts/stop-dev.sh" || {
echo " WARNING: stop-dev.sh returned non-zero (may be expected)"
peer_ssh "$NODE2_IP" "sudo systemctl stop spinifex.target" || {
echo " WARNING: systemctl stop returned non-zero (may be expected)"
}
# Wait for NATS cluster to detect the failure and reform
@@ -848,7 +839,7 @@ echo "Phase 9: Node Recovery"
echo "========================================"
echo "Restarting services on node2 ($NODE2_IP)..."
peer_ssh "$NODE2_IP" "cd ~/Development/mulga/spinifex && ./scripts/start-dev.sh" || {
peer_ssh "$NODE2_IP" "sudo systemctl start spinifex.target" || {
echo " ERROR: Failed to restart services on node2"
fail_test "Node2 restart"
}
@@ -899,7 +890,7 @@ fi
# Verify spx get nodes shows 3 Ready again
echo " Checking spx get nodes after recovery..."
GET_NODES_RECOVER=$($SPINIFEX_BIN get nodes --config "$SPINIFEX_CONFIG" --timeout 10s 2>/dev/null || echo "")
GET_NODES_RECOVER=$($SPINIFEX_BIN get nodes --timeout 10s 2>/dev/null || echo "")
echo "$GET_NODES_RECOVER"
READY_RECOVER=$(echo "$GET_NODES_RECOVER" | grep -c "Ready" || true)
if [ "$READY_RECOVER" -ge 3 ]; then
@@ -1252,7 +1243,7 @@ else
echo "Step 4: Validating spread placement across nodes..."
# Use spx get vms to check node assignment
SPX_VMS=$($SPINIFEX_BIN get vms --config "$SPINIFEX_CONFIG" --timeout 5s 2>/dev/null)
SPX_VMS=$($SPINIFEX_BIN get vms --timeout 5s 2>/dev/null)
echo "$SPX_VMS"
SPREAD_NODES=()