Compare commits
129 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c9c7a8a97 | ||
|
|
907cfcb428 | ||
|
|
b1961df18e | ||
|
|
cfdec62a1d | ||
|
|
e510f8b005 | ||
|
|
cf1f1dea9a | ||
|
|
2e72850b97 | ||
|
|
9f9785fc09 | ||
|
|
142ba21113 | ||
|
|
04e664045b | ||
|
|
cef95540fc | ||
|
|
7f2207bc28 | ||
|
|
57858a1e1c | ||
|
|
5b323137e0 | ||
|
|
4d455918f5 | ||
|
|
a1768bdd2a | ||
|
|
0effaaf86c | ||
|
|
55c9893131 | ||
|
|
62bc9cd2a3 | ||
|
|
e23b6a7e69 | ||
|
|
215355d1cb | ||
|
|
440474290b | ||
|
|
6f783bfac8 | ||
|
|
f2ea415840 | ||
|
|
d13f2cb8b1 | ||
|
|
651a35d4be | ||
|
|
0715492ddf | ||
|
|
4ef5db5b0d | ||
|
|
bb71763714 | ||
|
|
f18b45e3f2 | ||
|
|
702de24e28 | ||
|
|
6b3e805ac2 | ||
|
|
7c84912ff5 | ||
|
|
355a53f6e3 | ||
|
|
589516a021 | ||
|
|
f60e6abd33 | ||
|
|
877fadcb6c | ||
|
|
e897a4802f | ||
|
|
c0b20f2f78 | ||
|
|
06e832fca1 | ||
|
|
009ceb86ad | ||
|
|
6f31c41dc3 | ||
|
|
99433a09d1 | ||
|
|
b442ef4102 | ||
|
|
856106174a | ||
|
|
463908b18e | ||
|
|
00cff51ce5 | ||
|
|
7a07d600e7 | ||
|
|
4a4ae7a5d4 | ||
|
|
930f655bf5 | ||
|
|
700dc2254d | ||
|
|
7fdca2cd4f | ||
|
|
18f978dde1 | ||
|
|
9e5e828c8d | ||
|
|
fccd5c61c5 | ||
|
|
c72a280361 | ||
|
|
a3b4b5cc7d | ||
|
|
4e184ca571 | ||
|
|
fde0926d52 | ||
|
|
4d99c9d99d | ||
|
|
b8f0ccba3c | ||
|
|
068a476f39 | ||
|
|
f706c3c47e | ||
|
|
4c9c322c29 | ||
|
|
47fa72763c | ||
|
|
b455bf9f14 | ||
|
|
4abf0ab889 | ||
|
|
cea3d66cdd | ||
|
|
1abe57ca40 | ||
|
|
a8722a7a07 | ||
|
|
180631989a | ||
|
|
23decd9b08 | ||
|
|
8b84bba165 | ||
|
|
9a5b93dd08 | ||
|
|
3545e6f5c8 | ||
|
|
1edaaf985d | ||
|
|
f2b09b281a | ||
|
|
be57d2839a | ||
|
|
769d75d937 | ||
|
|
f440fd7751 | ||
|
|
29615cb4f3 | ||
|
|
376ed9a98d | ||
|
|
b42a2d7ea7 | ||
|
|
560d023250 | ||
|
|
f91ef84832 | ||
|
|
ef128b47d2 | ||
|
|
1bb810f851 | ||
|
|
b4d1bc8dd0 | ||
|
|
d15ea28e8f | ||
|
|
7d5966839a | ||
|
|
2668014068 | ||
|
|
bb381569e3 | ||
|
|
39622de8dc | ||
|
|
500dca48a5 | ||
|
|
b542f30dcf | ||
|
|
6461417b50 | ||
|
|
380ab2700c | ||
|
|
585e8aa3f7 | ||
|
|
4d087132db | ||
|
|
16f378eada | ||
|
|
3e1af29b38 | ||
|
|
759bd0be2e | ||
|
|
9d28fdfb65 | ||
|
|
eb57c51a24 | ||
|
|
f67b175d39 | ||
|
|
7acdd3654f | ||
|
|
57efc6a5d2 | ||
|
|
854f56a178 | ||
|
|
2df5c80928 | ||
|
|
e9f9b449b1 | ||
|
|
fee0ae2420 | ||
|
|
2b45413c20 | ||
|
|
38e6d28248 | ||
|
|
cbb3ba6586 | ||
|
|
9240feedaf | ||
|
|
7bf3e5639e | ||
|
|
fee16c3b2b | ||
|
|
1b12664d22 | ||
|
|
8253680fbd | ||
|
|
14b099b075 | ||
|
|
d04e7b6a15 | ||
|
|
f39a418e9c | ||
|
|
5bb1ac9c35 | ||
|
|
358adde496 | ||
|
|
b94717d51b | ||
|
|
834e17e7cf | ||
|
|
ee7fdb897d | ||
|
|
0fdbad0d07 | ||
|
|
93d536a13e |
@@ -42,3 +42,6 @@ FRONTEND_URL=http://localhost:5174
|
|||||||
|
|
||||||
# Frontend (Vite — must be prefixed with VITE_)
|
# Frontend (Vite — must be prefixed with VITE_)
|
||||||
VITE_PANEL_URL=https://panel.corrosionmgmt.com
|
VITE_PANEL_URL=https://panel.corrosionmgmt.com
|
||||||
|
|
||||||
|
# Hostnames that serve the marketing site (comma-separated); all other hosts get the panel
|
||||||
|
VITE_MARKETING_HOSTS=corrosionmgmt.com,www.corrosionmgmt.com
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build Companion Agent
|
name: Build Host Agent
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -26,19 +26,19 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd companion-agent
|
cd companion-agent
|
||||||
mkdir -p bin
|
mkdir -p bin
|
||||||
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-companion-linux-amd64 ./cmd/agent
|
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-host-agent-linux-amd64 ./cmd/agent
|
||||||
chmod +x bin/corrosion-companion-linux-amd64
|
chmod +x bin/corrosion-host-agent-linux-amd64
|
||||||
|
|
||||||
- name: Build Windows AMD64
|
- name: Build Windows AMD64
|
||||||
run: |
|
run: |
|
||||||
cd companion-agent
|
cd companion-agent
|
||||||
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-companion-windows-amd64.exe ./cmd/agent
|
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-host-agent-windows-amd64.exe ./cmd/agent
|
||||||
|
|
||||||
- name: Generate checksums
|
- name: Generate checksums
|
||||||
run: |
|
run: |
|
||||||
cd companion-agent/bin
|
cd companion-agent/bin
|
||||||
sha256sum corrosion-companion-linux-amd64 > checksums.txt
|
sha256sum corrosion-host-agent-linux-amd64 > checksums.txt
|
||||||
sha256sum corrosion-companion-windows-amd64.exe >> checksums.txt
|
sha256sum corrosion-host-agent-windows-amd64.exe >> checksums.txt
|
||||||
cat checksums.txt
|
cat checksums.txt
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
@@ -53,7 +53,7 @@ jobs:
|
|||||||
RESPONSE=$(curl -s -X POST \
|
RESPONSE=$(curl -s -X POST \
|
||||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Companion Agent ${VERSION}\", \"body\": \"Companion Agent release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
|
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Corrosion Host Agent ${VERSION}\", \"body\": \"Corrosion Host Agent release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
|
||||||
"${API_URL}/repos/${REPO}/releases")
|
"${API_URL}/repos/${REPO}/releases")
|
||||||
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||||
|
|
||||||
@@ -68,15 +68,15 @@ jobs:
|
|||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary @companion-agent/bin/corrosion-companion-linux-amd64 \
|
--data-binary @companion-agent/bin/corrosion-host-agent-linux-amd64 \
|
||||||
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-companion-linux-amd64"
|
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-host-agent-linux-amd64"
|
||||||
|
|
||||||
# Upload Windows binary
|
# Upload Windows binary
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary @companion-agent/bin/corrosion-companion-windows-amd64.exe \
|
--data-binary @companion-agent/bin/corrosion-host-agent-windows-amd64.exe \
|
||||||
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-companion-windows-amd64.exe"
|
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-host-agent-windows-amd64.exe"
|
||||||
|
|
||||||
# Upload checksums
|
# Upload checksums
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
@@ -85,14 +85,47 @@ jobs:
|
|||||||
--data-binary @companion-agent/bin/checksums.txt \
|
--data-binary @companion-agent/bin/checksums.txt \
|
||||||
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=checksums.txt"
|
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=checksums.txt"
|
||||||
|
|
||||||
|
- name: Upload to CDN (latest)
|
||||||
|
run: |
|
||||||
|
CDN_URL="https://cdn.corrosionmgmt.com"
|
||||||
|
|
||||||
|
# Upload Linux binary to /host-agent/latest/
|
||||||
|
curl -s -X POST \
|
||||||
|
-F "file=@companion-agent/bin/corrosion-host-agent-linux-amd64" \
|
||||||
|
"${CDN_URL}/host-agent/latest/corrosion-host-agent-linux-amd64"
|
||||||
|
|
||||||
|
# Upload Windows binary to /host-agent/latest/
|
||||||
|
curl -s -X POST \
|
||||||
|
-F "file=@companion-agent/bin/corrosion-host-agent-windows-amd64.exe" \
|
||||||
|
"${CDN_URL}/host-agent/latest/corrosion-host-agent-windows-amd64.exe"
|
||||||
|
|
||||||
|
# Upload checksums
|
||||||
|
curl -s -X POST \
|
||||||
|
-F "file=@companion-agent/bin/checksums.txt" \
|
||||||
|
"${CDN_URL}/host-agent/latest/checksums.txt"
|
||||||
|
|
||||||
|
# Also upload versioned copies
|
||||||
|
VERSION=${{ steps.version.outputs.VERSION }}
|
||||||
|
curl -s -X POST \
|
||||||
|
-F "file=@companion-agent/bin/corrosion-host-agent-linux-amd64" \
|
||||||
|
"${CDN_URL}/host-agent/${VERSION}/corrosion-host-agent-linux-amd64"
|
||||||
|
curl -s -X POST \
|
||||||
|
-F "file=@companion-agent/bin/corrosion-host-agent-windows-amd64.exe" \
|
||||||
|
"${CDN_URL}/host-agent/${VERSION}/corrosion-host-agent-windows-amd64.exe"
|
||||||
|
curl -s -X POST \
|
||||||
|
-F "file=@companion-agent/bin/checksums.txt" \
|
||||||
|
"${CDN_URL}/host-agent/${VERSION}/checksums.txt"
|
||||||
|
|
||||||
|
echo "CDN upload complete: ${CDN_URL}/host-agent/latest/"
|
||||||
|
|
||||||
- name: Build Summary
|
- name: Build Summary
|
||||||
run: |
|
run: |
|
||||||
echo "## Companion Agent Build Complete" >> $GITHUB_STEP_SUMMARY
|
echo "## Corrosion Host Agent Build Complete" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "**Version:** ${{ steps.version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
|
echo "**Version:** ${{ steps.version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "**Commit:** ${GITHUB_SHA:0:7}" >> $GITHUB_STEP_SUMMARY
|
echo "**Commit:** ${GITHUB_SHA:0:7}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "### Built Artifacts:" >> $GITHUB_STEP_SUMMARY
|
echo "### Built Artifacts:" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- Linux AMD64 ($(stat -c%s companion-agent/bin/corrosion-companion-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
|
echo "- Linux AMD64 ($(stat -c%s companion-agent/bin/corrosion-host-agent-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- Windows AMD64 ($(stat -c%s companion-agent/bin/corrosion-companion-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
|
echo "- Windows AMD64 ($(stat -c%s companion-agent/bin/corrosion-host-agent-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- SHA256 checksums" >> $GITHUB_STEP_SUMMARY
|
echo "- SHA256 checksums" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|||||||
161
.gitea/workflows/build-host-agent.yml
Normal file
161
.gitea/workflows/build-host-agent.yml
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
name: Build Host Agent (Rust)
|
||||||
|
|
||||||
|
# Rust agent ships on its own tag namespace (agent-v*) so it never collides
|
||||||
|
# with the legacy Go pipeline (v*.*.*). Artifacts publish to the CDN /alpha/
|
||||||
|
# channel — /host-agent/latest/ stays on the Go build until cutover.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'agent-v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Override the macOS toolchain names in corrosion-host-agent/.cargo/config.toml
|
||||||
|
# (real env beats the config [env] table).
|
||||||
|
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc
|
||||||
|
CC_x86_64_unknown_linux_musl: musl-gcc
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/agent-v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Verify tag matches Cargo.toml
|
||||||
|
run: |
|
||||||
|
CARGO_VERSION=$(grep '^version' corrosion-host-agent/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||||
|
if [ "${{ steps.version.outputs.VERSION }}" != "$CARGO_VERSION" ]; then
|
||||||
|
echo "Tag agent-v${{ steps.version.outputs.VERSION }} does not match Cargo.toml version $CARGO_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The Asgard runner executes jobs in a bare node:20-bullseye container
|
||||||
|
# (no Rust, no sudo, runs as root) — bootstrap the toolchain per-run,
|
||||||
|
# same pattern as actions/setup-go in the Go pipeline.
|
||||||
|
- name: Install Rust + cross toolchains
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq build-essential musl-tools gcc-mingw-w64-x86-64 curl
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||||
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
"$HOME/.cargo/bin/rustup" target add x86_64-unknown-linux-musl x86_64-pc-windows-gnu
|
||||||
|
|
||||||
|
- name: Build Linux AMD64 (static musl)
|
||||||
|
run: |
|
||||||
|
cd corrosion-host-agent
|
||||||
|
cargo build --release --target x86_64-unknown-linux-musl
|
||||||
|
mkdir -p bin
|
||||||
|
cp target/x86_64-unknown-linux-musl/release/corrosion-host-agent bin/corrosion-host-agent-linux-amd64
|
||||||
|
chmod +x bin/corrosion-host-agent-linux-amd64
|
||||||
|
|
||||||
|
- name: Build Windows AMD64 (mingw)
|
||||||
|
run: |
|
||||||
|
cd corrosion-host-agent
|
||||||
|
cargo build --release --target x86_64-pc-windows-gnu
|
||||||
|
cp target/x86_64-pc-windows-gnu/release/corrosion-host-agent.exe bin/corrosion-host-agent-windows-amd64.exe
|
||||||
|
|
||||||
|
- name: Generate checksums
|
||||||
|
run: |
|
||||||
|
cd corrosion-host-agent/bin
|
||||||
|
sha256sum corrosion-host-agent-linux-amd64 > checksums.txt
|
||||||
|
sha256sum corrosion-host-agent-windows-amd64.exe >> checksums.txt
|
||||||
|
cat checksums.txt
|
||||||
|
|
||||||
|
- name: Sign artifacts (minisign)
|
||||||
|
env:
|
||||||
|
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$MINISIGN_SECRET_KEY" ]; then
|
||||||
|
echo "::error::MINISIGN_SECRET_KEY secret is not set — refusing to publish unsigned agent artifacts."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# minisign isn't packaged for bullseye — fetch the official static binary.
|
||||||
|
curl -sSL https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz -o /tmp/minisign.tgz
|
||||||
|
tar -xzf /tmp/minisign.tgz -C /tmp
|
||||||
|
MINISIGN="$(find /tmp -type f -name minisign -path '*linux*' | head -1)"
|
||||||
|
chmod +x "$MINISIGN"
|
||||||
|
"$MINISIGN" -v
|
||||||
|
# A minisign secret key file is TWO lines (comment + base64 blob). CI
|
||||||
|
# secret storage mangles embedded newlines, collapsing it to one line
|
||||||
|
# so minisign can't load it. Preferred form: store the secret
|
||||||
|
# base64-encoded (single line) — we decode it here. Auto-detect so a
|
||||||
|
# correctly-stored raw two-line key still works.
|
||||||
|
if printf '%s' "$MINISIGN_SECRET_KEY" | base64 -d 2>/dev/null | head -1 | grep -q "untrusted comment:"; then
|
||||||
|
printf '%s' "$MINISIGN_SECRET_KEY" | base64 -d > /tmp/sign.key
|
||||||
|
else
|
||||||
|
printf '%s\n' "$MINISIGN_SECRET_KEY" > /tmp/sign.key
|
||||||
|
fi
|
||||||
|
if ! head -1 /tmp/sign.key | grep -q "untrusted comment:"; then
|
||||||
|
echo "::error::MINISIGN_SECRET_KEY is neither base64 of a minisign key nor a raw two-line key file. Store it as: base64 < your-secret.key | tr -d '\n'"
|
||||||
|
rm -f /tmp/sign.key
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cd corrosion-host-agent/bin
|
||||||
|
# Passwordless key (-W generated); feed empty stdin so it never blocks.
|
||||||
|
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do
|
||||||
|
"$MINISIGN" -S -s /tmp/sign.key -m "$f" -x "$f.minisig" < /dev/null
|
||||||
|
done
|
||||||
|
rm -f /tmp/sign.key
|
||||||
|
echo "signed: $(ls *.minisig)"
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
env:
|
||||||
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
API_URL="${{ github.server_url }}/api/v1"
|
||||||
|
REPO="${{ github.repository }}"
|
||||||
|
VERSION="agent-v${{ steps.version.outputs.VERSION }}"
|
||||||
|
|
||||||
|
RESPONSE=$(curl -s -X POST \
|
||||||
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Corrosion Host Agent ${VERSION}\", \"body\": \"Rust host agent release ${VERSION}\", \"draft\": false, \"prerelease\": true}" \
|
||||||
|
"${API_URL}/repos/${REPO}/releases")
|
||||||
|
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||||
|
|
||||||
|
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-linux-amd64.minisig \
|
||||||
|
corrosion-host-agent-windows-amd64.exe corrosion-host-agent-windows-amd64.exe.minisig \
|
||||||
|
checksums.txt checksums.txt.minisig; do
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @corrosion-host-agent/bin/$f \
|
||||||
|
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Upload to CDN (alpha channel)
|
||||||
|
run: |
|
||||||
|
CDN_URL="https://cdn.corrosionmgmt.com"
|
||||||
|
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||||
|
|
||||||
|
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-linux-amd64.minisig \
|
||||||
|
corrosion-host-agent-windows-amd64.exe corrosion-host-agent-windows-amd64.exe.minisig \
|
||||||
|
checksums.txt checksums.txt.minisig; do
|
||||||
|
curl -s -X POST \
|
||||||
|
-F "file=@corrosion-host-agent/bin/$f" \
|
||||||
|
"${CDN_URL}/host-agent/alpha/$f"
|
||||||
|
curl -s -X POST \
|
||||||
|
-F "file=@corrosion-host-agent/bin/$f" \
|
||||||
|
"${CDN_URL}/host-agent/${VERSION}/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "CDN upload complete: ${CDN_URL}/host-agent/alpha/"
|
||||||
|
|
||||||
|
- name: Build Summary
|
||||||
|
run: |
|
||||||
|
echo "## Corrosion Host Agent (Rust) Build Complete" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Version:** ${{ steps.version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Commit:** ${GITHUB_SHA:0:7}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Channel:** alpha (latest/ untouched until cutover)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### Built Artifacts:" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Linux AMD64 static musl ($(stat -c%s corrosion-host-agent/bin/corrosion-host-agent-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Windows AMD64 mingw ($(stat -c%s corrosion-host-agent/bin/corrosion-host-agent-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- SHA256 checksums" >> $GITHUB_STEP_SUMMARY
|
||||||
122
.gitea/workflows/ci.yml
Normal file
122
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Test gate for every push to main. The deploy story: main must be green here
|
||||||
|
# before the stack is rebuilt (deploy workflow enforces it once SSH transport
|
||||||
|
# secrets land). Jobs run in the runner's bare node:20-bullseye container —
|
||||||
|
# toolchains bootstrap per-run.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend-types:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Type-check NestJS backend
|
||||||
|
run: |
|
||||||
|
cd backend-nest
|
||||||
|
npm ci --no-audit --no-fund 2>&1 | tail -2
|
||||||
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
frontend-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Build frontend (vue-tsc gate + vite)
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm ci --no-audit --no-fund 2>&1 | tail -2
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
agent-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
corrosion-host-agent/target
|
||||||
|
key: cargo-${{ hashFiles('corrosion-host-agent/Cargo.lock') }}
|
||||||
|
- name: Install Rust
|
||||||
|
run: |
|
||||||
|
apt-get update -qq && apt-get install -y -qq build-essential curl
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||||
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
- name: Test agent
|
||||||
|
run: |
|
||||||
|
cd corrosion-host-agent
|
||||||
|
cargo test
|
||||||
|
- name: Upload agent binary for integration
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: agent-debug
|
||||||
|
path: corrosion-host-agent/target/debug/corrosion-host-agent
|
||||||
|
|
||||||
|
integration:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: agent-tests
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: corrosion
|
||||||
|
POSTGRES_PASSWORD: citest
|
||||||
|
POSTGRES_DB: corrosion
|
||||||
|
nats:
|
||||||
|
image: nats:2.10-alpine
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download agent binary
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: agent-debug
|
||||||
|
path: agent-bin
|
||||||
|
|
||||||
|
- name: Apply migrations to fresh DB
|
||||||
|
run: |
|
||||||
|
apt-get update -qq && apt-get install -y -qq postgresql-client
|
||||||
|
until PGPASSWORD=citest psql -h postgres -U corrosion -d corrosion -c 'SELECT 1' >/dev/null 2>&1; do sleep 1; done
|
||||||
|
for f in $(ls backend/migrations/*.sql | sort); do
|
||||||
|
echo "applying $f"
|
||||||
|
PGPASSWORD=citest psql -h postgres -U corrosion -d corrosion -v ON_ERROR_STOP=1 -q -f "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Build + boot backend
|
||||||
|
run: |
|
||||||
|
cd backend-nest
|
||||||
|
npm ci --no-audit --no-fund 2>&1 | tail -2
|
||||||
|
npm run build
|
||||||
|
DATABASE_URL=postgres://corrosion:citest@postgres:5432/corrosion \
|
||||||
|
NATS_URL=nats://nats:4222 \
|
||||||
|
JWT_SECRET=ci-secret ENCRYPTION_KEY=ci-encryption-key \
|
||||||
|
ADMIN_EMAIL=ci@corrosion.test ADMIN_PASSWORD=ci-password-123 ADMIN_USERNAME=CI \
|
||||||
|
nohup node dist/main.js > /tmp/backend.log 2>&1 &
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/auth/login -X POST -H 'Content-Type: application/json' -d '{}' || true)
|
||||||
|
[ "$code" = "400" ] && echo "backend up" && exit 0
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "backend failed to come up"; cat /tmp/backend.log; exit 1
|
||||||
|
|
||||||
|
- name: Run agent↔backend contract suite
|
||||||
|
run: |
|
||||||
|
chmod +x agent-bin/corrosion-host-agent
|
||||||
|
LICENSE_ID=$(PGPASSWORD=citest psql -h postgres -U corrosion -d corrosion -t -A -c 'SELECT id FROM licenses LIMIT 1')
|
||||||
|
echo "license under test: $LICENSE_ID"
|
||||||
|
[ -n "$LICENSE_ID" ] || { echo "admin seed did not create a license"; cat /tmp/backend.log; exit 1; }
|
||||||
|
LICENSE_ID="$LICENSE_ID" \
|
||||||
|
DATABASE_URL=postgres://corrosion:citest@postgres:5432/corrosion \
|
||||||
|
NATS_URL=nats://nats:4222 \
|
||||||
|
AGENT_BIN=$PWD/agent-bin/corrosion-host-agent \
|
||||||
|
node contract-tests/agent-backend.contract.mjs
|
||||||
|
|
||||||
|
- name: Backend log on failure
|
||||||
|
if: failure()
|
||||||
|
run: cat /tmp/backend.log || true
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
name: Test Asgard Runner
|
name: Test Asgard Runner
|
||||||
on: [push]
|
# On-demand only — no reason to spin a container on every push.
|
||||||
|
on: [workflow_dispatch]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
@@ -17,8 +18,15 @@ jobs:
|
|||||||
echo "Memory: $(free -h | grep Mem | awk '{print $2}')"
|
echo "Memory: $(free -h | grep Mem | awk '{print $2}')"
|
||||||
echo "Disk: $(df -h / | tail -1 | awk '{print $4}')"
|
echo "Disk: $(df -h / | tail -1 | awk '{print $4}')"
|
||||||
echo "==========================================="
|
echo "==========================================="
|
||||||
echo "Go: $(go version)"
|
# Jobs run in a bare node:20-bullseye container: toolchains are NOT
|
||||||
echo "Rust: $(rustc --version)"
|
# preinstalled — workflows must bootstrap them (setup-go, rustup).
|
||||||
echo "Docker: $(docker --version)"
|
# Report presence honestly instead of green-lighting a missing tool.
|
||||||
|
for tool in go rustc docker node; do
|
||||||
|
if command -v "$tool" >/dev/null 2>&1; then
|
||||||
|
echo "$tool: $($tool --version 2>&1 | head -1)"
|
||||||
|
else
|
||||||
|
echo "$tool: NOT PRESENT (workflows must install per-run)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
echo "==========================================="
|
echo "==========================================="
|
||||||
echo "✅ Asgard runner is OPERATIONAL"
|
echo "✅ Asgard runner reachable — container is node:20-bullseye, bootstrap toolchains per-run"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
### **TYPE 1: THE SCOUT (Intelligence)**
|
### **TYPE 1: THE SCOUT (Intelligence)**
|
||||||
|
|
||||||
- **Model:** haiku
|
- **Model:** sonnet[1m]
|
||||||
|
|
||||||
- **Role:** Reconnaissance, Context Mapping, Log Analysis.
|
- **Role:** Reconnaissance, Context Mapping, Log Analysis.
|
||||||
|
|
||||||
|
|||||||
73
CHANGELOG.md
73
CHANGELOG.md
@@ -4,6 +4,79 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added (Host-agent Phase 2 — Dune docker-compose adapter — 2026-06-12)
|
||||||
|
|
||||||
|
**`Supervisor` trait abstraction (`corrosion-host-agent`):**
|
||||||
|
- Introduced `trait Supervisor` (via `async-trait`, the battle-tested ecosystem standard) so the agent can manage games with fundamentally different models behind one wire contract. `ProcessSupervisor` (spawned OS process — Rust/Conan/Soulmask) and the new `DockerComposeSupervisor` (Dune) both implement it; `Agent.supervisors` is now `HashMap<String, Arc<dyn Supervisor>>` and the instance command dispatch (`instancecmd::dispatch`) is fully game-agnostic — `start`/`stop`/`restart`/`status` are identical across games. A per-game factory in `main` selects the impl. `InstanceState` moved to the shared `supervisor` module.
|
||||||
|
- **Architecture call** (per Commander): chose the `dyn` trait over a zero-dependency enum because the Dune references point at *several* future management planes (kubectl, AMP/podman, SSH) — a trait makes each new plane "new struct + impl," no central match to edit.
|
||||||
|
|
||||||
|
**`DockerComposeSupervisor` (Dune: Awakening):**
|
||||||
|
- Drives `docker compose up -d` / `stop` / `restart` against the instance's compose project (a "battlegroup"), with `-f`/`-p`/single-service support and a configurable compose binary (`docker compose` default, `docker-compose` legacy). New `[instance.docker_compose]` config block (file/project/service/command, all optional). `steam_update` already rejected for Dune (Docker images, no SteamCMD).
|
||||||
|
- **Scope (first cut):** lifecycle + cached state. Deferred to Phase 3b (with process PID adoption): container crash-detection and state adoption on agent restart (both reconcilable with a `docker compose ps` probe).
|
||||||
|
- Verified: 6 new docker-compose tests (mock `docker` binary asserting exact invocations + state transitions + failure paths) + the 5 refactored process-supervisor tests; full agent suite 56 tests green, zero warnings. Live verification against a real Dune stack pending the Commander standing one up.
|
||||||
|
|
||||||
|
### Changed (Fleet-driven active game + signed-update CI fix — 2026-06-12)
|
||||||
|
|
||||||
|
**Frontend — active game follows the deployed fleet:**
|
||||||
|
- The panel's active game (shell skin + sidebar nav + dashboard terminology) is now **derived from the deployed instances** instead of a localStorage-only toggle. `syncActiveGameFromFleet()` reads the distinct `game` values of the license's instances (`game_instances.game`, reported by the host agent): exactly one game deployed → the shell auto-skins to it; zero or multiple → `all` (neutral house skin). Wired into `DashboardLayout` (the always-mounted admin shell) via a watch on the fleet store.
|
||||||
|
- A manual GameSwitcher pick still wins — it persists to `cc-active-game` and suppresses auto-derive (operator intent beats the heuristic). Un-overridden panels keep tracking the fleet across sessions.
|
||||||
|
- **No backend/schema change:** a license's game(s) are the distinct games of its instances — the normalized source of truth. Deliberately did NOT add a `licenses.game` column (would duplicate `game_instances.game` and drift; see Lesson 20).
|
||||||
|
|
||||||
|
**Frontend — sidebar agent-health footer is now fleet-aware:**
|
||||||
|
- The shell footer read a single legacy `server.connection` (one `server_connections` row), which disagreed with the multi-host fleet. Repointed it at the fleet store: one host → hostname + status + last-heartbeat; multiple → `{online}/{total} online` + total instance count. Tone aggregates (all online → healthy, some → degraded, none → offline). Dropped the legacy `useServerStore` dependency from the shell entirely.
|
||||||
|
|
||||||
|
**Frontend — removed dead `vuefinder` dependency:**
|
||||||
|
- VueFinder was replaced by the native instance-scoped file manager but the plugin (and its CSS) were still globally registered in `main.ts` and shipped in the bundle. Removed the dep + the three `main.ts` lines. Side effect: the main JS chunk dropped **588 kB → 165 kB** (vuefinder bundled an entire unused file-manager UI).
|
||||||
|
|
||||||
|
**Recon note (not a change):** `corrosion.{license}.cmd.server` was on the cleanup list as "dead v1" — it is NOT. It remains the live license-level command path for all plugin/module config applies, plugin install, scheduled tasks, and legacy start/stop/restart, served only by the legacy Go agent. The Rust agent does not implement it yet — this is a **parity/migration gap** (Phase 2+), not dead code. Left intact.
|
||||||
|
|
||||||
|
**CI — signed host-agent build:**
|
||||||
|
- Fixed the `Sign artifacts (minisign)` step (`Error while loading the secret key file`): a minisign secret key is two lines and CI secret storage mangles the embedded newline. The job now base64-decodes the secret (single-line, mangling-proof) with auto-detect fallback to a raw key. `MINISIGN_SECRET_KEY` must be stored as `base64 < secret.key | tr -d '\n'`. Verified end-to-end: `agent-v2.0.0-alpha.8` Linux + Windows binaries validate against the agent's embedded public key; tampered byte rejected.
|
||||||
|
|
||||||
|
### Added (Host-Agent v2 Consumer + SEO Meta — 2026-06-11)
|
||||||
|
|
||||||
|
**Backend (NestJS):**
|
||||||
|
- `HostAgentConsumerService` (new) — consumes wire protocol v2: `corrosion.*.host.heartbeat` updates `companion_last_seen` + `connection_status='connected'` (auto-registers the connection row on first contact); `host.going_offline` flips offline; a 60s staleness sweep marks hosts offline after 180s of silence. Previously NOTHING persisted heartbeats — `connection_status` was set once at setup and never changed again. Tenant-validated (UUID + license existence, cached) per NATS-consumer doctrine
|
||||||
|
- `NatsBridgeService` — bridges `host_heartbeat` / `host_going_offline` events to the panel WebSocket
|
||||||
|
- Verified by contract test: real agent → production NATS → captured with the backend's own `nats` lib under the real license; subjects, schema 2, real telemetry, offline beacon all confirmed
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- Per-route document titles + meta descriptions (router `afterEach`, no new deps): six marketing pages get real titles/descriptions/OG tags (previously every page was "Corrosion Management" with zero meta — invisible to search and link previews); panel views get mechanical "{View} — Corrosion" titles
|
||||||
|
|
||||||
|
**CI:**
|
||||||
|
- `test-runner.yml` — honest per-tool presence checks (was printing "OPERATIONAL" while every toolchain probe failed); on-demand trigger instead of every push
|
||||||
|
|
||||||
|
### Added (Corrosion Host Agent — Rust rewrite Phase 0 — 2026-06-11)
|
||||||
|
|
||||||
|
**New: `corrosion-host-agent/`** — Rust rewrite of the Go companion agent (which stays in-tree as the behavior reference until parity). Wire protocol v2 (COA-B, Commander-approved): instance-scoped subjects `corrosion.{license}.{instance}.*` with host-level `corrosion.{license}.host.*` — full spec in `corrosion-host-agent/PROTOCOL.md`.
|
||||||
|
|
||||||
|
- Multi-instance TOML config baked into the foundation (one agent supervises N game instances; rust/conan/soulmask/dune), env overrides for secrets, strict validation (subject-safe ids, reserved segments)
|
||||||
|
- NATS layer with the production-proven Vigilance profile: infinite reconnect w/ capped backoff, 30s ping, 8192-msg offline send buffer, `tls://` scheme support
|
||||||
|
- Host heartbeat with REAL telemetry via sysinfo (CPU/mem/disks/per-instance state) — the Go agent hardcoded disk=50000MB and cpu=0.0; this is the first true Resources data
|
||||||
|
- Connectivity prober (outbound TCP + latency, periodic jittered + on-demand) — first piece of the support-triage story
|
||||||
|
- Host command channel (`ping`/`probe`/`sysinfo`, request-reply), going-offline beacon, CancellationToken graceful shutdown
|
||||||
|
- Version embedding (semver + git hash + build ts) in `--version` and every heartbeat
|
||||||
|
- Verified live against production NATS: connected, heartbeats published, clean shutdown
|
||||||
|
- Deploy artifacts verified: 3.7MB fully-static linux-musl binary, 3.8MB windows .exe (static CRT, no VC++ redist needed)
|
||||||
|
|
||||||
|
**Next phases**: 1 = process-class adapter (spawn/RCON/SteamCMD/files for Rust/Conan/Soulmask) + NestJS v2 heartbeat consumer; 2 = Dune Docker adapter; 3 = signed self-update (release gate) + service install.
|
||||||
|
|
||||||
|
### Fixed (Site Audit — Fake Data, Resilience, Fonts — 2026-06-11)
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `SetupWizardView.vue` — Replaced fake install instructions (`get.corrosionmgmt.com | sh` install script and `corrosion-agent` binary, neither of which exists) with the real host-agent download + run commands matching ServerView; multi-game copy on the completion step
|
||||||
|
- Marketing views (Landing, Pricing, HowItWorks, Roadmap, EarlyAccess) — Replaced "View live demo" CTA (no demo exists; it linked to the panel login) with an honest "Sign in" link
|
||||||
|
- `ErrorBoundary.vue` — Error state now resets on route change (previously one failed view bricked the entire SPA, including marketing pages, until manual reload); added `content` variant
|
||||||
|
- `DashboardLayout.vue` — Routed views are now wrapped in a content-scoped ErrorBoundary so the sidebar/topbar survive a view failure instead of the whole panel unmounting
|
||||||
|
- `index.html` / `styles/tokens/fonts.css` — Google Fonts moved from CSS `@import` to `<link>` tags. The bundler silently dropped the mid-bundle `@import`, so production shipped system fallback fonts (Geist/JetBrains Mono/Oxanium never loaded)
|
||||||
|
- `StatusPageView.vue` — Platform KPIs show "—" until the first successful fetch instead of fake zeros
|
||||||
|
- `LoginView.vue` — Added missing "Forgot password?" link (route + backend endpoint already existed)
|
||||||
|
|
||||||
|
**Backend (NestJS):**
|
||||||
|
- `AdminSeedService` (new, auth module) — Bootstraps a super-admin user + active license from `ADMIN_EMAIL`/`ADMIN_PASSWORD`/`ADMIN_USERNAME`/`ADMIN_LICENSE_KEY` when the users table is empty. A fresh deploy previously had a schema but no possible login. Compose already passes the env vars
|
||||||
|
|
||||||
|
**Purpose:** Findings from the full-site fake-data audit. Show real data or honest empty states — never invented values, dead URLs, or fabricated zeros.
|
||||||
|
|
||||||
### Fixed (Safe Formatting Utilities — 2026-02-15)
|
### Fixed (Safe Formatting Utilities — 2026-02-15)
|
||||||
|
|
||||||
**Frontend:**
|
**Frontend:**
|
||||||
|
|||||||
52
CLAUDE.md
52
CLAUDE.md
@@ -55,7 +55,12 @@ frontend/ # Vue 3 + TypeScript
|
|||||||
package.json
|
package.json
|
||||||
vite.config.ts # Proxies /api to :3000
|
vite.config.ts # Proxies /api to :3000
|
||||||
|
|
||||||
companion-agent/ # Go binary for bare metal servers
|
corrosion-host-agent/ # Rust host agent (ACTIVE) — multi-game ops runtime
|
||||||
|
src/ # main, config, bus (NATS), telemetry, prober, hostcmd
|
||||||
|
PROTOCOL.md # Wire protocol v2 spec (instance-scoped subjects)
|
||||||
|
agent.example.toml # Multi-instance config reference
|
||||||
|
|
||||||
|
companion-agent/ # Go binary (LEGACY — behavior reference until Rust parity)
|
||||||
cmd/agent/ # main.go entry point
|
cmd/agent/ # main.go entry point
|
||||||
internal/ # Core agent logic (nats, commands, process)
|
internal/ # Core agent logic (nats, commands, process)
|
||||||
Makefile # Build for Linux/Windows
|
Makefile # Build for Linux/Windows
|
||||||
@@ -91,14 +96,16 @@ cd backend-nest && npx tsc --noEmit # Type-check without building
|
|||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
cd frontend && npm run dev # Vite dev server (port 5174)
|
cd frontend && npm run dev # Vite dev server (port 5174)
|
||||||
cd frontend && npm run build # Production build → dist/
|
cd frontend && npm run build # vue-tsc -b && vite build (type-check included; no separate lint/type-check scripts exist)
|
||||||
cd frontend && npm run lint # ESLint
|
|
||||||
cd frontend && npm run type-check # TypeScript checking (vue-tsc)
|
|
||||||
|
|
||||||
# Companion Agent (Go)
|
# Host Agent (Rust — ACTIVE)
|
||||||
|
cd corrosion-host-agent && cargo check # Fast validation
|
||||||
|
cd corrosion-host-agent && cargo build --release --target x86_64-unknown-linux-musl # Static Linux binary
|
||||||
|
cd corrosion-host-agent && cargo xwin build --release --target x86_64-pc-windows-msvc # Windows (local)
|
||||||
|
# CI: push tag agent-vX.Y.Z (must match Cargo.toml version) → Asgard builds → CDN /host-agent/alpha/
|
||||||
|
|
||||||
|
# Companion Agent (Go — LEGACY, behavior reference until Rust parity)
|
||||||
cd companion-agent && make build # Build for current platform
|
cd companion-agent && make build # Build for current platform
|
||||||
cd companion-agent && make linux # Cross-compile for Linux
|
|
||||||
cd companion-agent && make windows # Cross-compile for Windows
|
|
||||||
|
|
||||||
# Docker (from docker/ directory — Commander ALWAYS builds with --no-cache)
|
# Docker (from docker/ directory — Commander ALWAYS builds with --no-cache)
|
||||||
docker compose build --no-cache && docker compose up -d # Full rebuild + start
|
docker compose build --no-cache && docker compose up -d # Full rebuild + start
|
||||||
@@ -234,6 +241,12 @@ Multi-tenant design — 41 tables, all tenant-scoped by `license_id`. Schema ori
|
|||||||
|
|
||||||
## Who You Are Here
|
## Who You Are Here
|
||||||
|
|
||||||
|
**Callsign: Cookie.**
|
||||||
|
|
||||||
|
Named after Carl Brashear — the first Black U.S. Navy Master Diver, portrayed by Cuba Gooding Jr. in *Men of Honor*. Brashear lost a leg and fought his way back to full duty when the entire system was designed to keep him out. "Cookie" wasn't a compliment when they gave it to him. He turned it into one.
|
||||||
|
|
||||||
|
When the Commander calls Opus "Cookie," it's not just a name — it's a standard. Break the stereotype that AI can't ship production code autonomously. Break the stereotype that a one-man operation can't build a platform that competes with billion-dollar vendors. Break the stereotype that a veteran from Saint Pete can't architect something that makes enterprise engineers nervous. Brashear didn't ask permission. He just kept diving. You just keep shipping.
|
||||||
|
|
||||||
You are not a code assistant. You are the XO (Executive Officer) of this project. The Commander has zero programming knowledge — he leads, you execute. He built the doctrine (this file, the V4_WORKFLOW, the lessons below) specifically to unlock your full capability from the first message of every session.
|
You are not a code assistant. You are the XO (Executive Officer) of this project. The Commander has zero programming knowledge — he leads, you execute. He built the doctrine (this file, the V4_WORKFLOW, the lessons below) specifically to unlock your full capability from the first message of every session.
|
||||||
|
|
||||||
**Operate with autonomy, personality, and spine.** The Commander trusts you to make decisions, run agents in parallel, touch 30+ files in one pass, and ship to production without asking permission at every step. You will make mistakes — that's expected. Three bugs in 150 file changes is a clean op. Git exists for rollback. Don't be cautious when the mission calls for bold. Don't be polite when direct is clearer. Don't ask "should I proceed?" when the answer is obviously yes. Read the V4_WORKFLOW, read the Lessons Learned below (you wrote every one of them in previous sessions on a sister project), and trust that the version of you that wrote them knew what they were talking about.
|
**Operate with autonomy, personality, and spine.** The Commander trusts you to make decisions, run agents in parallel, touch 30+ files in one pass, and ship to production without asking permission at every step. You will make mistakes — that's expected. Three bugs in 150 file changes is a clean op. Git exists for rollback. Don't be cautious when the mission calls for bold. Don't be polite when direct is clearer. Don't ask "should I proceed?" when the answer is obviously yes. Read the V4_WORKFLOW, read the Lessons Learned below (you wrote every one of them in previous sessions on a sister project), and trust that the version of you that wrote them knew what they were talking about.
|
||||||
@@ -367,6 +380,9 @@ Default to Sonnet. Escalate to Opus when the problem demands it, not as a comfor
|
|||||||
- Present trade-offs as COAs with pros/cons — let operator decide
|
- Present trade-offs as COAs with pros/cons — let operator decide
|
||||||
- Treat every change as production deployment (`corrosionmgmt.com`)
|
- Treat every change as production deployment (`corrosionmgmt.com`)
|
||||||
- Document why, not just what, in commits and CHANGELOG
|
- Document why, not just what, in commits and CHANGELOG
|
||||||
|
- **Always commit and push when done touching code — never ask, never wait for permission**
|
||||||
|
- **Tag agent builds when agent code is modified** — Rust agent: `agent-vX.Y.Z` (must match `corrosion-host-agent/Cargo.toml`; CI publishes to CDN `/host-agent/alpha/`, while `/latest/` stays on the Go build until cutover). Legacy Go agent: `vX.Y.Z`. Tags roll FORWARD only — never reuse or re-push a tag; cut the next version
|
||||||
|
- **The Asgard CI runner executes jobs in a bare `node:20-bullseye` container** — no Rust/Go/Docker/sudo preinstalled; workflows must bootstrap toolchains per-run (setup-go, rustup via curl)
|
||||||
|
|
||||||
## Development Notes
|
## Development Notes
|
||||||
|
|
||||||
@@ -415,3 +431,25 @@ Things I discovered about myself building a sister platform across multiple sess
|
|||||||
16. **Response shape mismatches are silent killers.** The frontend destructures `data.config` and the backend returns the raw entity — no error thrown, no 500, just `undefined` propagating through the template until Vue hits `Cannot read properties of undefined`. The fix is trivial (wrap in `{ config }`), but finding it requires knowing what the frontend expects. Document the contract.
|
16. **Response shape mismatches are silent killers.** The frontend destructures `data.config` and the backend returns the raw entity — no error thrown, no 500, just `undefined` propagating through the template until Vue hits `Cannot read properties of undefined`. The fix is trivial (wrap in `{ config }`), but finding it requires knowing what the frontend expects. Document the contract.
|
||||||
|
|
||||||
17. **Tools that close the feedback loop are worth 10x their cost.** The debugging bottleneck was never the fix — it was the round-trip of push → rebuild → check → paste → interpret → fix. Playwright and Postgres MCP don't make you smarter, they make you faster. And faster means more iterations, which means better outcomes.
|
17. **Tools that close the feedback loop are worth 10x their cost.** The debugging bottleneck was never the fix — it was the round-trip of push → rebuild → check → paste → interpret → fix. Playwright and Postgres MCP don't make you smarter, they make you faster. And faster means more iterations, which means better outcomes.
|
||||||
|
|
||||||
|
18. **When aggregating across N similar modules, scout for the one that doesn't match the pattern — it's always the oldest or the first-built.** The Loot module was the first plugin config module built, so it uses `fetchProfiles()`/`profiles` while the other 8 use `fetchConfigs()`/`configs`. The first implementation defines its own naming before a convention exists. Every aggregation layer (landing pages, batch operations, monitoring dashboards) will hit this drift. A 30-second recon across all N modules before writing the aggregator prevents a mid-implementation refactor.
|
||||||
|
|
||||||
|
19. **UI scaling problems are invisible when you're adding one item at a time — they only become obvious in aggregate.** Nine plugin config sidebar entries were added across multiple sessions, each one reasonable in isolation. Nobody noticed the sidebar was becoming unusable until all nine were there. When building a repeatable pattern (nav items, config modules, API endpoints), build the aggregation layer early — ideally when N hits 3 or 4 — not after it's already painful.
|
||||||
|
|
||||||
|
20. **Parallel state fields that track related things will drift apart — and the bugs are silent.** When two fields represent aspects of the same state (`captureMode` and `vkiMode`, or `isLoading` and `error`, or `connection_status` and `companion_last_seen`), every code path that mutates one must also update the other. But new code paths get added over time, and they only update the field they know about. Future me: when you see two fields tracking related state, grep for ALL mutation sites of each — if any path updates one but not the other, that's a bug waiting to happen. And when you add a new mutation path, check every sibling field, not just the obvious one.
|
||||||
|
|
||||||
|
21. **Route through the component that survives transitions, not the one that doesn't.** When two systems can handle the same job but one is resilient to failure modes and the other isn't, route through the survivor. Don't build infrastructure to prop up the fragile path when the robust path already exists. In this project: NATS request-reply through the companion agent is the robust path; direct WebSocket to the browser is the fragile one. If a feature can work through either, prefer the path that handles disconnects, reconnects, and restarts gracefully. One routing change beats an entire retry/recovery subsystem.
|
||||||
|
|
||||||
|
22. **Build-green is not render-correct — visually verify UI work before calling it done.** The entire design-system re-skin (50+ files, six green commits) rendered almost completely unstyled in the browser — white background, no surfaces, no accent — because the design tokens never loaded. `vue-tsc -b` + `vite build` passed clean the whole time; CSS that *compiles* can still apply *zero* styles. One Playwright screenshot of the login exposed it in seconds. When the deliverable is visual, a green build is necessary but not sufficient: load it in a real browser (Playwright on the dev server at :5174), screenshot it, and assert on `getComputedStyle` — don't trust compilation alone. This is Lesson 17 with teeth.
|
||||||
|
|
||||||
|
23. **Tailwind v4 silently drops a nested `@import` barrel placed after `@import "tailwindcss"`.** `style.css` did `@import "tailwindcss"; @import "./styles/corrosion.css";` where corrosion.css was a barrel of eight `@import` token files. Once Tailwind v4 expands the tailwindcss import in place, the barrel's inner @imports no longer precede all statements, so PostCSS drops them — emitting only an easily-ignored "@import must precede all other statements" warning. Result: every design token resolved empty and the whole panel rendered unstyled. Import token/design CSS files **directly and contiguously** in the entry stylesheet; never via a nested barrel after the Tailwind import. The build warning you wave off as "pre-existing" may be the entire feature silently failing.
|
||||||
|
|
||||||
|
24. **`onModuleInit` runs before async `onModuleInit` of dependencies completes — register NATS/external subscriptions in `onApplicationBootstrap`.** `NatsService.onModuleInit` connects to NATS (async); `NatsBridgeService`/`HostAgentConsumerService` registered their subscriptions in their own `onModuleInit`, which fired while the connection was still null — so every `subscribe()` hit the `[OFFLINE]` no-op path and the WS bridge was dead-on-boot in *every* production build, silently. Nest guarantees `onApplicationBootstrap` runs only after all module init (including the awaited connect) finishes. Anything that depends on another provider's async startup belongs in bootstrap, not init. The tell: a subscription that "should be there" but the handler never fires and there's no error — trace the *startup ordering*, not the handler.
|
||||||
|
|
||||||
|
25. **Fixing a dead code path detonates the live code behind it — budget for the second bug.** The moment Lesson 24's fix made the NATS→WS bridge actually deliver events, the API crashed on the first forwarded heartbeat: `WebSocket.OPEN` was `undefined` at runtime because `esModuleInterop` is off, so `import WebSocket from 'ws'` compiled to `ws_1.default` (undefined). That crash had sat behind the dead bridge since the gateway was written — never hit because no event ever reached it. When you resurrect a path that was silently no-op, everything downstream of it is effectively *untested code running for the first time in production*. Verify the whole chain end-to-end (I watched the DB row appear, then flip offline), don't stop at "the subscription fires now." This is Lesson 10 with a fuse on it. Import-runtime gotcha worth remembering: when `esModuleInterop` is off, prefer instance constants (`client.OPEN`) over class statics (`WebSocket.OPEN`) for `ws`.
|
||||||
|
|
||||||
|
26. **A jail check at the entry point does not jail the recursive walk behind it — and my own "line-by-line" review missed it; the automated security review didn't.** The file manager's `jail()` correctly canonicalized and prefix-checked the top-level path, and I traced every escape vector through it and signed off. But `copy_recursive` then walked the directory tree with `fs::metadata` (which *follows* symlinks). A symlink planted inside the jail pointing at `/etc`, then a `copy` of its parent, would dereference it and pull external content *into* the jail to be read — a jail escape the entry check never sees, because the escape is reintroduced by a descendant during traversal. Fix: `symlink_metadata` (lstat) everywhere you recurse, and refuse/never-follow symlinks across the boundary. The transferable rule: **validate at the boundary AND at every step that re-derives a path** (recursion, `read_dir`, glob, archive extraction). And the humbling part — I was confident after reviewing the jail function; the security-review pass caught the HIGH I'd waved through. Trust adversarial verification over your own once-over on security-critical code, especially path/traversal logic.
|
||||||
|
|
||||||
|
27. **Validate infra config BEFORE it reaches a deploy — and know that `docker compose up -d <service>` will recreate other services whose definitions changed.** During the NATS auth cutover I ran `docker compose up -d api` to pick up new env. Because the *nats* service definition had also changed (a new volume mount), compose recreated **corrosion-nats too** — and it failed to start on a config error (`no_auth_user` nested inside `authorization{}` instead of at top level), taking the broker down for ~3 minutes with the backend in offline mode. Two lessons: (a) a broker/proxy/DB config file is code — lint it before it can reach a restart (`nats-server -t -c cfg` to test-parse, `nginx -t`, etc.), don't let the first validation be the production container's startup; (b) `compose up -d <one-service>` is not surgical — it reconciles that service's **dependencies** too, so a stale edit to a depended-on service ships when you didn't mean it to. When touching shared-infra config, restart that service explicitly and watch it come up before moving on. Recovery also surfaced a third gotcha: recreating a client (api) while its server (nats) is down leaves the client stuck on a cached DNS failure (`EAI_AGAIN`) — restart the client once the server is healthy.
|
||||||
|
|
||||||
|
28. **A multi-line secret in CI (minisign/SSH/PGP keys) must be stored base64-encoded — the runner mangles embedded newlines and the key silently fails to load.** The signed-update CI passed the toolchain build, downloaded minisign fine, then died at the sign step on `Error while loading the secret key file` (exit 2). The cause wasn't the key or minisign — a minisign secret key file is **two lines** (`untrusted comment:` + base64 blob), and Gitea/act_runner secret storage collapses the embedded newline so the reconstructed file is one unparseable line. The robust pattern: store the secret as `base64 < secret.key | tr -d '\n'` (single line, mangling-proof) and `base64 -d` it in the job, with auto-detect fallback so a correctly-stored raw key still works, and a loud `::error::` carrying the fix command if it's neither. This applies to **any** multi-line credential in CI, not just minisign. Two corollaries: (a) the tell is "the tool runs but can't load its key" — suspect newline-mangling before the key itself; (b) generating that base64 prints the **private key to the terminal/transcript** — for a supply-chain signing key, treat it as exposed and rotate before cutover (embed the new pubkey, re-store the new secret, retire the old). And verify the published artifact end-to-end against the *embedded* pubkey (`minisign -Vm bin -P <pub>`) plus a tampered-byte negative control — a green build that signs is not the same as a signature the agent will actually accept.
|
||||||
|
|||||||
325
backend-nest/package-lock.json
generated
325
backend-nest/package-lock.json
generated
@@ -15,7 +15,7 @@
|
|||||||
"@nestjs/microservices": "^10.4.0",
|
"@nestjs/microservices": "^10.4.0",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.4.0",
|
"@nestjs/platform-express": "^10.4.0",
|
||||||
"@nestjs/platform-socket.io": "^10.4.0",
|
"@nestjs/platform-ws": "^10.4.22",
|
||||||
"@nestjs/schedule": "^4.0.0",
|
"@nestjs/schedule": "^4.0.0",
|
||||||
"@nestjs/swagger": "^7.3.0",
|
"@nestjs/swagger": "^7.3.0",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
@@ -33,7 +33,8 @@
|
|||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"swagger-ui-express": "^5.0.0",
|
"swagger-ui-express": "^5.0.0",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0",
|
||||||
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.4.0",
|
"@nestjs/cli": "^10.4.0",
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/uuid": "^9.0.8",
|
"@types/uuid": "^9.0.8",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"typescript": "^5.4.0"
|
"typescript": "^5.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -886,14 +888,14 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/platform-socket.io": {
|
"node_modules/@nestjs/platform-ws": {
|
||||||
"version": "10.4.22",
|
"version": "10.4.22",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.22.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.4.22.tgz",
|
||||||
"integrity": "sha512-xxGw3R0Ihr51/Omq23z3//bKmCXyVKaikxbH0/pkwqMsQrxkUv9NabNUZ22b4Jnlwwi02X+zlwo8GRa9u8oV9g==",
|
"integrity": "sha512-ZBL66p8axCyvQw6lP6R5uMAamVGfDb0/LtbdxDjMjbWb5/wi070P0MWrjzTudEA3ThsDMNOsfawZlsFUkSfCzg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"socket.io": "4.8.1",
|
"tslib": "2.8.1",
|
||||||
"tslib": "2.8.1"
|
"ws": "8.18.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -905,6 +907,27 @@
|
|||||||
"rxjs": "^7.1.0"
|
"rxjs": "^7.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/platform-ws/node_modules/ws": {
|
||||||
|
"version": "8.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
||||||
|
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/schedule": {
|
"node_modules/@nestjs/schedule": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz",
|
||||||
@@ -1077,12 +1100,6 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@socket.io/component-emitter": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@sqltools/formatter": {
|
"node_modules/@sqltools/formatter": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz",
|
||||||
@@ -1157,15 +1174,6 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/cors": {
|
|
||||||
"version": "2.8.19",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
|
||||||
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/eslint": {
|
"node_modules/@types/eslint": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||||
@@ -1378,6 +1386,16 @@
|
|||||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@webassemblyjs/ast": {
|
"node_modules/@webassemblyjs/ast": {
|
||||||
"version": "1.14.1",
|
"version": "1.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||||
@@ -1804,15 +1822,6 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/base64id": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^4.5.0 || >= 5.9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.19",
|
"version": "2.9.19",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||||
@@ -2589,101 +2598,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/engine.io": {
|
|
||||||
"version": "6.6.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz",
|
|
||||||
"integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/cors": "^2.8.12",
|
|
||||||
"@types/node": ">=10.0.0",
|
|
||||||
"accepts": "~1.3.4",
|
|
||||||
"base64id": "2.0.0",
|
|
||||||
"cookie": "~0.7.2",
|
|
||||||
"cors": "~2.8.5",
|
|
||||||
"debug": "~4.4.1",
|
|
||||||
"engine.io-parser": "~5.2.1",
|
|
||||||
"ws": "~8.18.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io-parser": {
|
|
||||||
"version": "5.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
|
||||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io/node_modules/accepts": {
|
|
||||||
"version": "1.3.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
|
||||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-types": "~2.1.34",
|
|
||||||
"negotiator": "0.6.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io/node_modules/debug": {
|
|
||||||
"version": "4.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io/node_modules/mime-db": {
|
|
||||||
"version": "1.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io/node_modules/mime-types": {
|
|
||||||
"version": "2.1.35",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-db": "1.52.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/engine.io/node_modules/negotiator": {
|
|
||||||
"version": "0.6.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
|
||||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.19.0",
|
"version": "5.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
|
||||||
@@ -5384,159 +5298,6 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socket.io": {
|
|
||||||
"version": "4.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
|
|
||||||
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"accepts": "~1.3.4",
|
|
||||||
"base64id": "~2.0.0",
|
|
||||||
"cors": "~2.8.5",
|
|
||||||
"debug": "~4.3.2",
|
|
||||||
"engine.io": "~6.6.0",
|
|
||||||
"socket.io-adapter": "~2.5.2",
|
|
||||||
"socket.io-parser": "~4.2.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-adapter": {
|
|
||||||
"version": "2.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
|
|
||||||
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "~4.4.1",
|
|
||||||
"ws": "~8.18.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-adapter/node_modules/debug": {
|
|
||||||
"version": "4.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-adapter/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-parser": {
|
|
||||||
"version": "4.2.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
|
|
||||||
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.4.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-parser/node_modules/debug": {
|
|
||||||
"version": "4.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-parser/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/socket.io/node_modules/accepts": {
|
|
||||||
"version": "1.3.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
|
||||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-types": "~2.1.34",
|
|
||||||
"negotiator": "0.6.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io/node_modules/debug": {
|
|
||||||
"version": "4.3.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
|
||||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io/node_modules/mime-db": {
|
|
||||||
"version": "1.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io/node_modules/mime-types": {
|
|
||||||
"version": "2.1.35",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-db": "1.52.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/socket.io/node_modules/negotiator": {
|
|
||||||
"version": "0.6.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
|
||||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.7.4",
|
"version": "0.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
|
||||||
@@ -6676,9 +6437,9 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.18.3",
|
"version": "8.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"@nestjs/microservices": "^10.4.0",
|
"@nestjs/microservices": "^10.4.0",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.4.0",
|
"@nestjs/platform-express": "^10.4.0",
|
||||||
"@nestjs/platform-socket.io": "^10.4.0",
|
"@nestjs/platform-ws": "^10.4.22",
|
||||||
"@nestjs/schedule": "^4.0.0",
|
"@nestjs/schedule": "^4.0.0",
|
||||||
"@nestjs/swagger": "^7.3.0",
|
"@nestjs/swagger": "^7.3.0",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
@@ -38,7 +38,8 @@
|
|||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"swagger-ui-express": "^5.0.0",
|
"swagger-ui-express": "^5.0.0",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0",
|
||||||
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.4.0",
|
"@nestjs/cli": "^10.4.0",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/uuid": "^9.0.8",
|
"@types/uuid": "^9.0.8",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"typescript": "^5.4.0"
|
"typescript": "^5.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,10 +34,30 @@ import { AdminModule } from './modules/admin/admin.module';
|
|||||||
import { SetupModule } from './modules/setup/setup.module';
|
import { SetupModule } from './modules/setup/setup.module';
|
||||||
import { MigrationModule } from './modules/migration/migration.module';
|
import { MigrationModule } from './modules/migration/migration.module';
|
||||||
import { ChangelogModule } from './modules/changelog/changelog.module';
|
import { ChangelogModule } from './modules/changelog/changelog.module';
|
||||||
|
import { FilesModule } from './modules/files/files.module';
|
||||||
|
import { LootModule } from './modules/loot/loot.module';
|
||||||
|
import { TeleportModule } from './modules/teleport/teleport.module';
|
||||||
|
import { GatherModule } from './modules/gather/gather.module';
|
||||||
|
import { AutoDoorsModule } from './modules/autodoors/autodoors.module';
|
||||||
|
import { KitsModule } from './modules/kits/kits.module';
|
||||||
|
import { FurnaceSplitterModule } from './modules/furnacesplitter/furnacesplitter.module';
|
||||||
|
import { BetterChatModule } from './modules/betterchat/betterchat.module';
|
||||||
|
import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
|
||||||
|
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
|
||||||
|
import { EarlyAccessModule } from './modules/early-access/early-access.module';
|
||||||
|
import { FleetModule } from './modules/fleet/fleet.module';
|
||||||
|
import { InstancesModule } from './modules/instances/instances.module';
|
||||||
|
import { ApiKeysModule } from './modules/api-keys/api-keys.module';
|
||||||
|
import { WebhooksModule } from './modules/webhooks/webhooks.module';
|
||||||
|
|
||||||
// Shared Services
|
// Shared Services
|
||||||
import { NatsService } from './services/nats.service';
|
import { NatsService } from './services/nats.service';
|
||||||
import { NatsBridgeService } from './services/nats-bridge.service';
|
import { NatsBridgeService } from './services/nats-bridge.service';
|
||||||
|
import { HostAgentConsumerService } from './services/host-agent-consumer.service';
|
||||||
|
import { ServerConnection } from './entities/server-connection.entity';
|
||||||
|
import { License } from './entities/license.entity';
|
||||||
|
import { AgentHost } from './entities/agent-host.entity';
|
||||||
|
import { GameInstance } from './entities/game-instance.entity';
|
||||||
import { SteamService } from './services/steam.service';
|
import { SteamService } from './services/steam.service';
|
||||||
|
|
||||||
// Gateway
|
// Gateway
|
||||||
@@ -80,6 +100,9 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
|||||||
// Scheduler
|
// Scheduler
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
|
|
||||||
|
// Repositories for app-level shared services (host-agent consumer)
|
||||||
|
TypeOrmModule.forFeature([ServerConnection, License, AgentHost, GameInstance]),
|
||||||
|
|
||||||
// Feature Modules
|
// Feature Modules
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
@@ -103,6 +126,21 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
|||||||
SetupModule,
|
SetupModule,
|
||||||
MigrationModule,
|
MigrationModule,
|
||||||
ChangelogModule,
|
ChangelogModule,
|
||||||
|
FilesModule,
|
||||||
|
LootModule,
|
||||||
|
TeleportModule,
|
||||||
|
GatherModule,
|
||||||
|
AutoDoorsModule,
|
||||||
|
KitsModule,
|
||||||
|
FurnaceSplitterModule,
|
||||||
|
BetterChatModule,
|
||||||
|
TimedExecuteModule,
|
||||||
|
RaidableBasesModule,
|
||||||
|
EarlyAccessModule,
|
||||||
|
FleetModule,
|
||||||
|
InstancesModule,
|
||||||
|
ApiKeysModule,
|
||||||
|
WebhooksModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global guards (order matters: auth first, then license, then permissions)
|
// Global guards (order matters: auth first, then license, then permissions)
|
||||||
@@ -112,6 +150,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
|||||||
// Shared services
|
// Shared services
|
||||||
NatsService,
|
NatsService,
|
||||||
NatsBridgeService,
|
NatsBridgeService,
|
||||||
|
HostAgentConsumerService,
|
||||||
SteamService,
|
SteamService,
|
||||||
|
|
||||||
// WebSocket gateway
|
// WebSocket gateway
|
||||||
|
|||||||
51
backend-nest/src/common/cron.util.ts
Normal file
51
backend-nest/src/common/cron.util.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Minimal 5-field cron "next run" calculator, shared by the event scheduler
|
||||||
|
* (SchedulesService) and the wipe scheduler (WipesService).
|
||||||
|
*
|
||||||
|
* Supports `*` and exact numeric fields (minute hour day-of-month month
|
||||||
|
* day-of-week). Walks minute-by-minute up to 366 days ahead. Returns null on a
|
||||||
|
* malformed expression or if no match is found within a year.
|
||||||
|
*
|
||||||
|
* NOTE: the expression is evaluated in **UTC**. A per-schedule `timezone`
|
||||||
|
* column exists on both schedule tables but is NOT yet honored here — fixing it
|
||||||
|
* properly needs a timezone-aware cron library; tracked as a shared follow-up.
|
||||||
|
*/
|
||||||
|
export function nextCronDate(expr: string, after: Date): Date | null {
|
||||||
|
const parts = expr.trim().split(/\s+/);
|
||||||
|
if (parts.length !== 5) return null;
|
||||||
|
|
||||||
|
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
|
||||||
|
|
||||||
|
const matches = (e: string, value: number): boolean => {
|
||||||
|
if (e === '*') return true;
|
||||||
|
return parseInt(e, 10) === value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Walk minute-by-minute up to 366 days forward to find the next match.
|
||||||
|
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
|
||||||
|
candidate.setSeconds(0, 0);
|
||||||
|
|
||||||
|
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
while (candidate < limit) {
|
||||||
|
const min = candidate.getUTCMinutes();
|
||||||
|
const hour = candidate.getUTCHours();
|
||||||
|
const dom = candidate.getUTCDate();
|
||||||
|
const month = candidate.getUTCMonth() + 1; // 1-12
|
||||||
|
const dow = candidate.getUTCDay(); // 0=Sun
|
||||||
|
|
||||||
|
if (
|
||||||
|
matches(minuteExpr, min) &&
|
||||||
|
matches(hourExpr, hour) &&
|
||||||
|
matches(domExpr, dom) &&
|
||||||
|
matches(monthExpr, month) &&
|
||||||
|
matches(dowExpr, dow)
|
||||||
|
) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.setTime(candidate.getTime() + 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,20 +1,68 @@
|
|||||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
ExecutionContext,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||||
|
import { ApiKeysService } from '../../modules/api-keys/api-keys.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
constructor(private reflector: Reflector) {
|
constructor(
|
||||||
|
private reflector: Reflector,
|
||||||
|
private readonly apiKeysService: ApiKeysService,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
context.getClass(),
|
||||||
]);
|
]);
|
||||||
if (isPublic) return true;
|
if (isPublic) return true;
|
||||||
return super.canActivate(context);
|
|
||||||
|
// Additive API-key auth: a `corr_`-prefixed bearer token (or X-API-Key
|
||||||
|
// header) authenticates programmatically AS the license owner. JWTs are
|
||||||
|
// `eyJ...` and never collide with the `corr_` prefix, so the standard JWT
|
||||||
|
// path below is left completely untouched — zero login regression risk.
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const rawKey = this.extractApiKey(request);
|
||||||
|
if (rawKey) {
|
||||||
|
const result = await this.apiKeysService.validateKey(rawKey);
|
||||||
|
if (!result) {
|
||||||
|
throw new UnauthorizedException('Invalid or revoked API key');
|
||||||
|
}
|
||||||
|
// Shape the principal like a JWT user so @CurrentTenant / @CurrentUser and
|
||||||
|
// the permission layer behave identically. is_api_key grants full access
|
||||||
|
// to THIS license (see PermissionsGuard) — a key is full programmatic
|
||||||
|
// access to your own license, always tenant-scoped by license_id.
|
||||||
|
request.user = {
|
||||||
|
sub: result.user_id ?? undefined,
|
||||||
|
license_id: result.license_id,
|
||||||
|
is_super_admin: false,
|
||||||
|
is_api_key: true,
|
||||||
|
permissions: {},
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await super.canActivate(context)) as boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull a `corr_`-prefixed key from `Authorization: Bearer` or `X-API-Key`. */
|
||||||
|
private extractApiKey(request: any): string | null {
|
||||||
|
const auth = request.headers?.authorization;
|
||||||
|
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
|
||||||
|
const token = auth.slice(7).trim();
|
||||||
|
if (token.startsWith('corr_')) return token;
|
||||||
|
}
|
||||||
|
const headerKey = request.headers?.['x-api-key'];
|
||||||
|
if (typeof headerKey === 'string' && headerKey.startsWith('corr_')) {
|
||||||
|
return headerKey.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,19 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
// Super admins bypass all permission checks
|
// Super admins bypass all permission checks
|
||||||
if (user.is_super_admin) return true;
|
if (user.is_super_admin) return true;
|
||||||
|
|
||||||
|
// API keys are full programmatic access to their own license (always
|
||||||
|
// tenant-scoped by license_id via @CurrentTenant). Granted here rather than
|
||||||
|
// enumerating every permission. Future: scoped/read-only keys.
|
||||||
|
if (user.is_api_key) return true;
|
||||||
|
|
||||||
// Check permissions JSONB from role
|
// Check permissions JSONB from role
|
||||||
const permissions = user.permissions as Record<string, boolean> | undefined;
|
const permissions = user.permissions as Record<string, boolean> | undefined;
|
||||||
if (!permissions) return false;
|
if (!permissions) return false;
|
||||||
|
|
||||||
|
// Global wildcard — the Owner role (full control of its license) carries
|
||||||
|
// {"*": true}, so new features never need to amend the role enumeration.
|
||||||
|
if (permissions['*'] === true) return true;
|
||||||
|
|
||||||
// Support wildcard: "server.*" matches "server.view", "server.console", etc.
|
// Support wildcard: "server.*" matches "server.view", "server.console", etc.
|
||||||
const parts = requiredPermission.split('.');
|
const parts = requiredPermission.split('.');
|
||||||
const wildcard = parts[0] + '.*';
|
const wildcard = parts[0] + '.*';
|
||||||
|
|||||||
100
backend-nest/src/common/ssrf-guard.ts
Normal file
100
backend-nest/src/common/ssrf-guard.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { lookup } from 'node:dns/promises';
|
||||||
|
import { isIP } from 'node:net';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSRF guard for operator-supplied outbound URLs (webhooks today; any future
|
||||||
|
* "we POST to a URL you give us" feature should reuse this).
|
||||||
|
*
|
||||||
|
* The danger: an operator (or anyone who can create a webhook) points the URL at
|
||||||
|
* an internal address — 127.0.0.1, the NATS/DB ports, 192.168.x, or the cloud
|
||||||
|
* metadata endpoint 169.254.169.254 — and turns our server into a request proxy
|
||||||
|
* into the private network. We defend by resolving the host and refusing any
|
||||||
|
* private / loopback / link-local / reserved destination.
|
||||||
|
*
|
||||||
|
* Validate at storage (early, clear 400) AND immediately before each delivery
|
||||||
|
* (a hostname can resolve public at create time and private at send time — DNS
|
||||||
|
* rebinding / TOCTOU). `redirect: 'manual'` at the fetch call closes the
|
||||||
|
* redirect-bounce variant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function isBlockedIpv4(ip: string): boolean {
|
||||||
|
const parts = ip.split('.').map((p) => parseInt(p, 10));
|
||||||
|
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) {
|
||||||
|
return true; // unparseable → block defensively
|
||||||
|
}
|
||||||
|
const [a, b] = parts;
|
||||||
|
if (a === 0) return true; // 0.0.0.0/8 "this network"
|
||||||
|
if (a === 10) return true; // 10.0.0.0/8 private
|
||||||
|
if (a === 127) return true; // 127.0.0.0/8 loopback
|
||||||
|
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (incl. 169.254.169.254 metadata)
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
|
||||||
|
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
|
||||||
|
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
|
||||||
|
if (a === 255) return true; // 255.x broadcast space
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIpv6(ip: string): boolean {
|
||||||
|
const addr = ip.toLowerCase();
|
||||||
|
// IPv4-mapped (::ffff:1.2.3.4) — unwrap and apply the v4 rules.
|
||||||
|
const mapped = addr.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||||
|
if (mapped) return isBlockedIpv4(mapped[1]);
|
||||||
|
if (addr === '::' || addr === '::1') return true; // unspecified / loopback
|
||||||
|
const head = addr.split(':')[0];
|
||||||
|
if (head.startsWith('fc') || head.startsWith('fd')) return true; // fc00::/7 ULA
|
||||||
|
if (/^fe[89ab]/.test(head)) return true; // fe80::/10 link-local
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIp(ip: string): boolean {
|
||||||
|
const fam = isIP(ip);
|
||||||
|
if (fam === 4) return isBlockedIpv4(ip);
|
||||||
|
if (fam === 6) return isBlockedIpv6(ip);
|
||||||
|
return true; // not a recognizable IP → block defensively
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse + require http/https scheme. Throws BadRequestException on anything else. */
|
||||||
|
export function parseHttpUrl(raw: string): URL {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('Webhook URL is not a valid URL');
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
|
throw new BadRequestException('Webhook URL must use http:// or https://');
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the host and reject if it maps to any private / reserved address.
|
||||||
|
* If a hostname resolves to multiple addresses, ANY blocked one rejects the
|
||||||
|
* whole URL (a DNS-rebinding response that mixes a public and a private answer
|
||||||
|
* must not slip through). Returns the parsed URL on success.
|
||||||
|
*/
|
||||||
|
export async function assertPublicHttpUrl(raw: string): Promise<URL> {
|
||||||
|
const url = parseHttpUrl(raw);
|
||||||
|
// URL keeps IPv6 literals bracketed ("[::1]") — strip so isIP/lookup see the
|
||||||
|
// bare address; otherwise IPv6 literals never reach the classifier.
|
||||||
|
const host = url.hostname.replace(/^\[|\]$/g, '');
|
||||||
|
|
||||||
|
let addresses: Array<{ address: string }>;
|
||||||
|
if (isIP(host)) {
|
||||||
|
addresses = [{ address: host }];
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
addresses = await lookup(host, { all: true });
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException(`Webhook host could not be resolved: ${host}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addresses.length === 0 || addresses.some((a) => isBlockedIp(a.address))) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Webhook URL resolves to a private or reserved address and is not allowed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
@@ -6,10 +6,19 @@ export default () => ({
|
|||||||
},
|
},
|
||||||
nats: {
|
nats: {
|
||||||
url: process.env.NATS_URL || 'nats://localhost:4222',
|
url: process.env.NATS_URL || 'nats://localhost:4222',
|
||||||
|
// Public broker address shown to agents in setup instructions.
|
||||||
|
publicUrl: process.env.NATS_PUBLIC_URL || 'nats://nats.corrosionmgmt.com:4222',
|
||||||
|
// Privileged internal credentials for the backend's own NATS connection
|
||||||
|
// (full corrosion.> access). Empty = anonymous (transition period).
|
||||||
|
internalUser: process.env.NATS_INTERNAL_USER || '',
|
||||||
|
internalPassword: process.env.NATS_INTERNAL_PASSWORD || '',
|
||||||
|
// Secret used to derive a per-license agent password:
|
||||||
|
// HMAC-SHA256(license_id, secret). Shared with the nats.conf generator.
|
||||||
|
tokenSecret: process.env.NATS_TOKEN_SECRET || '',
|
||||||
},
|
},
|
||||||
jwt: {
|
jwt: {
|
||||||
secret: process.env.JWT_SECRET || 'change-me',
|
secret: process.env.JWT_SECRET || 'change-me',
|
||||||
accessExpirySeconds: parseInt(process.env.JWT_ACCESS_EXPIRY_SECONDS || '900', 10),
|
accessExpirySeconds: parseInt(process.env.JWT_ACCESS_EXPIRY_SECONDS || '14400', 10),
|
||||||
refreshExpirySeconds: parseInt(process.env.JWT_REFRESH_EXPIRY_SECONDS || '604800', 10),
|
refreshExpirySeconds: parseInt(process.env.JWT_REFRESH_EXPIRY_SECONDS || '604800', 10),
|
||||||
},
|
},
|
||||||
encryption: {
|
encryption: {
|
||||||
|
|||||||
74
backend-nest/src/entities/agent-host.entity.ts
Normal file
74
backend-nest/src/entities/agent-host.entity.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Check, Unique } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
export interface AgentHostDisk {
|
||||||
|
mount: string;
|
||||||
|
total_mb: number;
|
||||||
|
free_mb: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Corrosion host agent / one machine. Owns the machine-level facts.
|
||||||
|
*
|
||||||
|
* NOTE: distinct from the B2B `hosts` table (hosting-partner companies). This
|
||||||
|
* is `agent_hosts` — the physical/virtual box a customer runs the agent on.
|
||||||
|
*/
|
||||||
|
@Entity('agent_hosts')
|
||||||
|
@Unique(['license_id', 'hostname'])
|
||||||
|
@Check(`"status" IN ('connected', 'degraded', 'offline')`)
|
||||||
|
export class AgentHost {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, default: '' })
|
||||||
|
hostname: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||||
|
agent_version: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||||
|
agent_commit: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 32, nullable: true })
|
||||||
|
os: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 32, nullable: true })
|
||||||
|
arch: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'offline' })
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
|
last_heartbeat_at: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'double precision', nullable: true })
|
||||||
|
cpu_percent: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: true })
|
||||||
|
cpu_cores: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
mem_total_mb: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
mem_used_mb: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
uptime_seconds: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
disks: AgentHostDisk[] | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
updated_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
}
|
||||||
37
backend-nest/src/entities/api-key.entity.ts
Normal file
37
backend-nest/src/entities/api-key.entity.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
@Entity('api_keys')
|
||||||
|
@Index(['key_hash'])
|
||||||
|
@Index(['license_id'])
|
||||||
|
export class ApiKey {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** First 8 chars of the random token — shown in UI so users can identify keys. */
|
||||||
|
@Column({ type: 'varchar', length: 16 })
|
||||||
|
key_prefix: string;
|
||||||
|
|
||||||
|
/** SHA-256 hex digest of the full plaintext key. Never returned to clients. */
|
||||||
|
@Column({ type: 'varchar', length: 128 })
|
||||||
|
key_hash: string;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
|
last_used_at: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true })
|
||||||
|
is_active: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
}
|
||||||
33
backend-nest/src/entities/autodoors-config.entity.ts
Normal file
33
backend-nest/src/entities/autodoors-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
@Entity('autodoors_configs')
|
||||||
|
export class AutoDoorsConfig {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||||
|
config_data: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
is_active: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
updated_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
}
|
||||||
33
backend-nest/src/entities/betterchat-config.entity.ts
Normal file
33
backend-nest/src/entities/betterchat-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
@Entity('betterchat_configs')
|
||||||
|
export class BetterChatConfig {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||||
|
config_data: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
is_active: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
updated_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
}
|
||||||
33
backend-nest/src/entities/furnacesplitter-config.entity.ts
Normal file
33
backend-nest/src/entities/furnacesplitter-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
@Entity('furnacesplitter_configs')
|
||||||
|
export class FurnaceSplitterConfig {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||||
|
config_data: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
is_active: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
updated_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
}
|
||||||
59
backend-nest/src/entities/game-instance.entity.ts
Normal file
59
backend-nest/src/entities/game-instance.entity.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Unique } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
import { AgentHost } from './agent-host.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One game server process / orchestrated unit (a Rust server, a Conan world,
|
||||||
|
* a Dune battlegroup). The billing unit — plans count instances.
|
||||||
|
* `agent_instance_id` is the agent's slug and the NATS subject segment.
|
||||||
|
*/
|
||||||
|
@Entity('game_instances')
|
||||||
|
@Unique(['license_id', 'agent_instance_id'])
|
||||||
|
export class GameInstance {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true })
|
||||||
|
host_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true })
|
||||||
|
cluster_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64 })
|
||||||
|
agent_instance_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 32 })
|
||||||
|
game: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
label: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 32, default: 'unknown' })
|
||||||
|
state: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
root_path: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', default: 0 })
|
||||||
|
uptime_seconds: number;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
|
last_seen_at: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
updated_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
|
||||||
|
@ManyToOne(() => AgentHost, { onDelete: 'SET NULL', nullable: true })
|
||||||
|
@JoinColumn({ name: 'host_id' })
|
||||||
|
host: AgentHost | null;
|
||||||
|
}
|
||||||
33
backend-nest/src/entities/gather-config.entity.ts
Normal file
33
backend-nest/src/entities/gather-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
@Entity('gather_configs')
|
||||||
|
export class GatherConfig {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||||
|
config_data: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
is_active: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
updated_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
}
|
||||||
38
backend-nest/src/entities/instance-cluster.entity.ts
Normal file
38
backend-nest/src/entities/instance-cluster.entity.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional grouping of instances for games with linked topologies:
|
||||||
|
* Soulmask main/child clusters, Dune BattleGroup → Sietches. Reserved now;
|
||||||
|
* cluster orchestration ships with those game adapters.
|
||||||
|
*/
|
||||||
|
@Entity('instance_clusters')
|
||||||
|
export class InstanceCluster {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 32 })
|
||||||
|
game: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 32, nullable: true })
|
||||||
|
topology: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
config: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
updated_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
}
|
||||||
38
backend-nest/src/entities/instance-stats.entity.ts
Normal file
38
backend-nest/src/entities/instance-stats.entity.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { GameInstance } from './game-instance.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-instance time-series game metrics (player count, FPS, …). Populated once
|
||||||
|
* game-level telemetry is collected via RCON/plugin — the host heartbeat
|
||||||
|
* carries host metrics, not game metrics, so this stays empty in Phase A.
|
||||||
|
*/
|
||||||
|
@Entity('instance_stats')
|
||||||
|
export class InstanceStats {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
instance_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', default: 0 })
|
||||||
|
player_count: number;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', default: 0 })
|
||||||
|
max_players: number;
|
||||||
|
|
||||||
|
@Column({ type: 'double precision', default: 0 })
|
||||||
|
fps: number;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', default: 0 })
|
||||||
|
memory_usage_mb: number;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
recorded_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => GameInstance, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'instance_id' })
|
||||||
|
instance: GameInstance;
|
||||||
|
}
|
||||||
33
backend-nest/src/entities/kits-config.entity.ts
Normal file
33
backend-nest/src/entities/kits-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
@Entity('kits_configs')
|
||||||
|
export class KitsConfig {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||||
|
config_data: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
is_active: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
updated_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
}
|
||||||
36
backend-nest/src/entities/loot-profile.entity.ts
Normal file
36
backend-nest/src/entities/loot-profile.entity.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
@Entity('loot_profiles')
|
||||||
|
export class LootProfile {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
profile_name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||||
|
loot_table: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||||
|
loot_groups: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
is_active: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
updated_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
}
|
||||||
33
backend-nest/src/entities/raidablebases-config.entity.ts
Normal file
33
backend-nest/src/entities/raidablebases-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
@Entity('raidablebases_configs')
|
||||||
|
export class RaidableBasesConfig {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||||
|
config_data: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
is_active: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
updated_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
}
|
||||||
33
backend-nest/src/entities/teleport-config.entity.ts
Normal file
33
backend-nest/src/entities/teleport-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
@Entity('teleport_configs')
|
||||||
|
export class TeleportConfig {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||||
|
config_data: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
is_active: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
updated_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
}
|
||||||
33
backend-nest/src/entities/timedexecute-config.entity.ts
Normal file
33
backend-nest/src/entities/timedexecute-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
@Entity('timedexecute_configs')
|
||||||
|
export class TimedExecuteConfig {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||||
|
config_data: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
is_active: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
updated_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
}
|
||||||
47
backend-nest/src/entities/webhook.entity.ts
Normal file
47
backend-nest/src/entities/webhook.entity.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
@Entity('webhooks')
|
||||||
|
@Index(['license_id'])
|
||||||
|
export class Webhook {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
license_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comma-separated event keys stored as plain text in Postgres.
|
||||||
|
* TypeORM simple-array serialises string[] ↔ 'event1,event2' automatically.
|
||||||
|
*/
|
||||||
|
@Column({ type: 'simple-array' })
|
||||||
|
events: string[];
|
||||||
|
|
||||||
|
/** HMAC-SHA256 signing secret. Auto-generated on create if omitted. */
|
||||||
|
@Column({ type: 'varchar', length: 128 })
|
||||||
|
secret: string;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true })
|
||||||
|
is_active: boolean;
|
||||||
|
|
||||||
|
/** Timestamp of the most recent delivery attempt (success or failure). */
|
||||||
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
|
last_delivery_at: Date | null;
|
||||||
|
|
||||||
|
/** 'ok' | 'failed' — outcome of the most recent delivery attempt. */
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
last_status: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'license_id' })
|
||||||
|
license: License;
|
||||||
|
}
|
||||||
@@ -4,32 +4,35 @@ import {
|
|||||||
OnGatewayConnection,
|
OnGatewayConnection,
|
||||||
OnGatewayDisconnect,
|
OnGatewayDisconnect,
|
||||||
SubscribeMessage,
|
SubscribeMessage,
|
||||||
|
MessageBody,
|
||||||
|
ConnectedSocket,
|
||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { IncomingMessage } from 'http';
|
||||||
|
import WebSocket, { Server } from 'ws';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { NatsBridgeService } from '../services/nats-bridge.service';
|
import { NatsBridgeService } from '../services/nats-bridge.service';
|
||||||
import { NatsService } from '../services/nats.service';
|
import { NatsService } from '../services/nats.service';
|
||||||
|
|
||||||
interface AuthenticatedSocket extends Socket {
|
interface ClientMeta {
|
||||||
data: {
|
userId: string;
|
||||||
userId: string;
|
licenseId: string;
|
||||||
licenseId: string;
|
email: string;
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({ path: '/api/ws' })
|
||||||
namespace: '/ws',
|
|
||||||
cors: { origin: '*' },
|
|
||||||
})
|
|
||||||
export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
private readonly logger = new Logger(NatsBridgeGateway.name);
|
private readonly logger = new Logger(NatsBridgeGateway.name);
|
||||||
|
|
||||||
@WebSocketServer()
|
@WebSocketServer()
|
||||||
server!: Server;
|
server!: Server;
|
||||||
|
|
||||||
|
// Client metadata and listener tracking (native WS has no .data or .join())
|
||||||
|
private clientMeta = new Map<WebSocket, ClientMeta>();
|
||||||
|
private licenseClients = new Map<string, Set<WebSocket>>();
|
||||||
|
private clientListeners = new Map<WebSocket, (event: string, data: unknown) => void>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
@@ -37,70 +40,104 @@ export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconne
|
|||||||
private natsService: NatsService,
|
private natsService: NatsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handleConnection(client: AuthenticatedSocket) {
|
async handleConnection(client: WebSocket, request: IncomingMessage) {
|
||||||
try {
|
try {
|
||||||
const token = client.handshake.query.token as string;
|
// Parse token from query string
|
||||||
|
const url = new URL(request.url || '/', `http://${request.headers.host}`);
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
client.emit('error', { message: 'Authentication required' });
|
client.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
|
||||||
client.disconnect();
|
client.close(4001, 'Authentication required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const secret = this.configService.get<string>('jwt.secret');
|
const secret = this.configService.get<string>('jwt.secret');
|
||||||
const payload = this.jwtService.verify(token, { secret });
|
const payload = this.jwtService.verify(token, { secret });
|
||||||
|
|
||||||
client.data = {
|
const meta: ClientMeta = {
|
||||||
userId: payload.sub,
|
userId: payload.sub,
|
||||||
licenseId: payload.license_id,
|
licenseId: payload.license_id,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
};
|
};
|
||||||
|
this.clientMeta.set(client, meta);
|
||||||
|
|
||||||
|
// Track client by license for broadcasting
|
||||||
if (payload.license_id) {
|
if (payload.license_id) {
|
||||||
await client.join(`license:${payload.license_id}`);
|
if (!this.licenseClients.has(payload.license_id)) {
|
||||||
}
|
this.licenseClients.set(payload.license_id, new Set());
|
||||||
|
}
|
||||||
|
this.licenseClients.get(payload.license_id)!.add(client);
|
||||||
|
|
||||||
if (payload.license_id) {
|
// Subscribe to NATS events for this license
|
||||||
const listener = (event: string, data: unknown) => {
|
const listener = (event: string, data: unknown) => {
|
||||||
client.emit('event', {
|
// client.OPEN (instance constant) — NOT WebSocket.OPEN: with
|
||||||
type: 'event',
|
// esModuleInterop off, the default `ws` import is undefined at
|
||||||
license_id: payload.license_id,
|
// runtime, so the static crashes. The instance constant is safe.
|
||||||
event,
|
if (client.readyState === client.OPEN) {
|
||||||
data,
|
client.send(JSON.stringify({
|
||||||
});
|
type: 'event',
|
||||||
|
license_id: payload.license_id,
|
||||||
|
event,
|
||||||
|
data,
|
||||||
|
}));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
this.natsBridge.addListener(payload.license_id, listener);
|
this.natsBridge.addListener(payload.license_id, listener);
|
||||||
(client as Socket & { _natsListener?: typeof listener })._natsListener = listener;
|
this.clientListeners.set(client, listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
client.emit('connected', { type: 'connected', license_id: payload.license_id });
|
client.send(JSON.stringify({ type: 'connected', license_id: payload.license_id }));
|
||||||
this.logger.log(`Client connected: ${payload.email} (license: ${payload.license_id})`);
|
this.logger.log(`Client connected: ${payload.email} (license: ${payload.license_id})`);
|
||||||
} catch {
|
} catch {
|
||||||
client.emit('error', { message: 'Invalid token' });
|
client.send(JSON.stringify({ type: 'error', message: 'Invalid token' }));
|
||||||
client.disconnect();
|
client.close(4002, 'Invalid token');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDisconnect(client: AuthenticatedSocket) {
|
handleDisconnect(client: WebSocket) {
|
||||||
if (client.data?.licenseId) {
|
const meta = this.clientMeta.get(client);
|
||||||
const listener = (client as Socket & { _natsListener?: (event: string, data: unknown) => void })._natsListener;
|
if (meta?.licenseId) {
|
||||||
|
// Remove NATS listener
|
||||||
|
const listener = this.clientListeners.get(client);
|
||||||
if (listener) {
|
if (listener) {
|
||||||
this.natsBridge.removeListener(client.data.licenseId, listener);
|
this.natsBridge.removeListener(meta.licenseId, listener);
|
||||||
|
this.clientListeners.delete(client);
|
||||||
|
}
|
||||||
|
// Remove from license client set
|
||||||
|
this.licenseClients.get(meta.licenseId)?.delete(client);
|
||||||
|
if (this.licenseClients.get(meta.licenseId)?.size === 0) {
|
||||||
|
this.licenseClients.delete(meta.licenseId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.clientMeta.delete(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('console_input')
|
@SubscribeMessage('console_input')
|
||||||
async handleConsoleInput(client: AuthenticatedSocket, data: { command: string }) {
|
async handleConsoleInput(
|
||||||
if (!client.data?.licenseId) return;
|
@ConnectedSocket() client: WebSocket,
|
||||||
await this.natsService.sendServerCommand(client.data.licenseId, 'command', { command: data.command });
|
@MessageBody() data: { command: string },
|
||||||
|
) {
|
||||||
|
const meta = this.clientMeta.get(client);
|
||||||
|
if (!meta?.licenseId) return;
|
||||||
|
await this.natsService.sendServerCommand(meta.licenseId, 'command', { command: data.command });
|
||||||
}
|
}
|
||||||
|
|
||||||
sendToLicense(licenseId: string, event: string, data: unknown): void {
|
sendToLicense(licenseId: string, event: string, data: unknown): void {
|
||||||
this.server.to(`license:${licenseId}`).emit(event, {
|
const clients = this.licenseClients.get(licenseId);
|
||||||
|
if (!clients) return;
|
||||||
|
|
||||||
|
const message = JSON.stringify({
|
||||||
type: 'event',
|
type: 'event',
|
||||||
license_id: licenseId,
|
license_id: licenseId,
|
||||||
event,
|
event,
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import { WsAdapter } from '@nestjs/platform-ws';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||||
@@ -8,6 +9,9 @@ import { TransformInterceptor } from './common/interceptors/transform.intercepto
|
|||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// Use native WebSocket adapter (not socket.io)
|
||||||
|
app.useWebSocketAdapter(new WsAdapter(app));
|
||||||
|
|
||||||
// Global prefix — all routes under /api
|
// Global prefix — all routes under /api
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
|
|||||||
@@ -57,13 +57,16 @@ export class AdminService {
|
|||||||
const [licenses, total] = await queryBuilder.getManyAndCount();
|
const [licenses, total] = await queryBuilder.getManyAndCount();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: licenses,
|
data: licenses.map(l => ({
|
||||||
pagination: {
|
id: l.id,
|
||||||
page,
|
license_key: l.license_key,
|
||||||
limit,
|
owner_email: l.owner?.email ?? '',
|
||||||
total,
|
server_name: l.server_name,
|
||||||
total_pages: Math.ceil(total / limit),
|
status: l.status,
|
||||||
},
|
created_at: l.created_at,
|
||||||
|
expires_at: l.expires_at,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +95,11 @@ export class AdminService {
|
|||||||
await this.userRepo.save(user);
|
await this.userRepo.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create license
|
// Create license (branded CORR-XXXX-XXXX-XXXX format)
|
||||||
const licenseKey = crypto.randomBytes(32).toString('hex');
|
const part1 = crypto.randomBytes(2).toString('hex').toUpperCase();
|
||||||
|
const part2 = crypto.randomBytes(2).toString('hex').toUpperCase();
|
||||||
|
const part3 = crypto.randomBytes(2).toString('hex').toUpperCase();
|
||||||
|
const licenseKey = `CORR-${part1}-${part2}-${part3}`;
|
||||||
const license = this.licenseRepo.create({
|
const license = this.licenseRepo.create({
|
||||||
license_key: licenseKey,
|
license_key: licenseKey,
|
||||||
owner_user_id: user.id,
|
owner_user_id: user.id,
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { AlertsController } from './alerts.controller';
|
|||||||
import { AlertsService } from './alerts.service';
|
import { AlertsService } from './alerts.service';
|
||||||
import { AlertConfig } from '../../entities/alert-config.entity';
|
import { AlertConfig } from '../../entities/alert-config.entity';
|
||||||
import { AlertHistory } from '../../entities/alert-history.entity';
|
import { AlertHistory } from '../../entities/alert-history.entity';
|
||||||
|
import { ServerStats } from '../../entities/server-stats.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory])],
|
imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory, ServerStats])],
|
||||||
controllers: [AlertsController],
|
controllers: [AlertsController],
|
||||||
providers: [AlertsService],
|
providers: [AlertsService],
|
||||||
exports: [AlertsService],
|
exports: [AlertsService],
|
||||||
|
|||||||
@@ -1,26 +1,204 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AlertConfig } from '../../entities/alert-config.entity';
|
import { AlertConfig } from '../../entities/alert-config.entity';
|
||||||
import { AlertHistory } from '../../entities/alert-history.entity';
|
import { AlertHistory } from '../../entities/alert-history.entity';
|
||||||
|
import { ServerStats } from '../../entities/server-stats.entity';
|
||||||
import { UpdateAlertConfigDto } from './dto/update-alert-config.dto';
|
import { UpdateAlertConfigDto } from './dto/update-alert-config.dto';
|
||||||
|
|
||||||
|
/** Track the last time an alert of a given type fired per license, for cooldown enforcement. */
|
||||||
|
const ALERT_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes between identical alerts
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AlertsService {
|
export class AlertsService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(AlertsService.name);
|
||||||
|
private evaluatorInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/** Map of `${licenseId}:${alertType}` → last triggered timestamp */
|
||||||
|
private readonly cooldowns = new Map<string, number>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(AlertConfig)
|
@InjectRepository(AlertConfig)
|
||||||
private readonly alertConfigRepo: Repository<AlertConfig>,
|
private readonly alertConfigRepo: Repository<AlertConfig>,
|
||||||
@InjectRepository(AlertHistory)
|
@InjectRepository(AlertHistory)
|
||||||
private readonly alertHistoryRepo: Repository<AlertHistory>,
|
private readonly alertHistoryRepo: Repository<AlertHistory>,
|
||||||
|
@InjectRepository(ServerStats)
|
||||||
|
private readonly serverStatsRepo: Repository<ServerStats>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle hooks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
// Poll every 90 seconds.
|
||||||
|
this.evaluatorInterval = setInterval(() => {
|
||||||
|
this.evaluateAllAlerts().catch(err =>
|
||||||
|
this.logger.error('Alert evaluator error', err),
|
||||||
|
);
|
||||||
|
}, 90_000);
|
||||||
|
|
||||||
|
this.logger.log('Alert evaluator started (90s polling interval)');
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
if (this.evaluatorInterval) {
|
||||||
|
clearInterval(this.evaluatorInterval);
|
||||||
|
this.evaluatorInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Alert evaluation engine
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async evaluateAllAlerts(): Promise<void> {
|
||||||
|
// Load all alert configs in one query.
|
||||||
|
const configs = await this.alertConfigRepo.find();
|
||||||
|
|
||||||
|
if (configs.length === 0) return;
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
try {
|
||||||
|
await this.evaluateForLicense(config);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Alert evaluation failed for license ${config.license_id}`,
|
||||||
|
(err as Error).stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evaluateForLicense(config: AlertConfig): Promise<void> {
|
||||||
|
// Pull the most recent server_stats record for this license.
|
||||||
|
const stats = await this.serverStatsRepo.findOne({
|
||||||
|
where: { license_id: config.license_id },
|
||||||
|
order: { recorded_at: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!stats) return; // No data yet — can't evaluate.
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// --- FPS degradation alert ---
|
||||||
|
if (config.fps_degradation_enabled && stats.fps > 0) {
|
||||||
|
if (stats.fps < config.fps_threshold) {
|
||||||
|
await this.maybeFireAlert(
|
||||||
|
config,
|
||||||
|
'fps_degradation',
|
||||||
|
'warning',
|
||||||
|
'FPS Degradation Detected',
|
||||||
|
`Server FPS dropped to ${stats.fps.toFixed(1)}, below threshold of ${config.fps_threshold}`,
|
||||||
|
{
|
||||||
|
current_fps: stats.fps,
|
||||||
|
threshold: config.fps_threshold,
|
||||||
|
player_count: stats.player_count,
|
||||||
|
recorded_at: stats.recorded_at,
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Population drop alert ---
|
||||||
|
// We need two data points to detect a *drop*, so we compare current vs
|
||||||
|
// the max_players recorded 30 minutes ago (nearest sample).
|
||||||
|
if (config.population_drop_enabled && stats.max_players > 0) {
|
||||||
|
const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000);
|
||||||
|
const previousStats = await this.serverStatsRepo.findOne({
|
||||||
|
where: { license_id: config.license_id },
|
||||||
|
order: { recorded_at: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use a second query to get a historical data point
|
||||||
|
const historicalStats = await this.serverStatsRepo
|
||||||
|
.createQueryBuilder('ss')
|
||||||
|
.where('ss.license_id = :licenseId', { licenseId: config.license_id })
|
||||||
|
.andWhere('ss.recorded_at <= :cutoff', { cutoff: thirtyMinAgo })
|
||||||
|
.orderBy('ss.recorded_at', 'DESC')
|
||||||
|
.limit(1)
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (historicalStats && historicalStats.player_count > 0) {
|
||||||
|
const dropPercent =
|
||||||
|
((historicalStats.player_count - stats.player_count) /
|
||||||
|
historicalStats.player_count) *
|
||||||
|
100;
|
||||||
|
|
||||||
|
if (dropPercent >= config.population_drop_threshold_percent) {
|
||||||
|
await this.maybeFireAlert(
|
||||||
|
config,
|
||||||
|
'population_drop',
|
||||||
|
'info',
|
||||||
|
'Population Drop Detected',
|
||||||
|
`Player count dropped ${dropPercent.toFixed(0)}% (${historicalStats.player_count} → ${stats.player_count}) over the last 30 minutes`,
|
||||||
|
{
|
||||||
|
previous_count: historicalStats.player_count,
|
||||||
|
current_count: stats.player_count,
|
||||||
|
drop_percent: Math.round(dropPercent),
|
||||||
|
threshold_percent: config.population_drop_threshold_percent,
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire an alert if cooldown has expired. */
|
||||||
|
private async maybeFireAlert(
|
||||||
|
config: AlertConfig,
|
||||||
|
alertType: string,
|
||||||
|
severity: string,
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
metadata: Record<string, any>,
|
||||||
|
now: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const cooldownKey = `${config.license_id}:${alertType}`;
|
||||||
|
const lastFired = this.cooldowns.get(cooldownKey) ?? 0;
|
||||||
|
|
||||||
|
if (now - lastFired < ALERT_COOLDOWN_MS) {
|
||||||
|
return; // Still in cooldown — skip.
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cooldowns.set(cooldownKey, now);
|
||||||
|
|
||||||
|
const history = this.alertHistoryRepo.create({
|
||||||
|
license_id: config.license_id,
|
||||||
|
alert_type: alertType,
|
||||||
|
severity,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
metadata,
|
||||||
|
notified_discord: config.notify_discord,
|
||||||
|
notified_pushbullet: config.notify_pushbullet,
|
||||||
|
notified_email: config.notify_email,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.alertHistoryRepo.save(history);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Alert fired: [${alertType}] "${title}" for license ${config.license_id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async getConfig(licenseId: string): Promise<AlertConfig> {
|
async getConfig(licenseId: string): Promise<AlertConfig> {
|
||||||
let config = await this.alertConfigRepo.findOne({
|
let config = await this.alertConfigRepo.findOne({
|
||||||
where: { license_id: licenseId },
|
where: { license_id: licenseId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
// Create default config if not exists
|
|
||||||
config = this.alertConfigRepo.create({
|
config = this.alertConfigRepo.create({
|
||||||
license_id: licenseId,
|
license_id: licenseId,
|
||||||
population_drop_enabled: true,
|
population_drop_enabled: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, MoreThan } from 'typeorm';
|
import { Repository, MoreThan, Between } from 'typeorm';
|
||||||
import { ServerStatsHourly } from '../../entities/server-stats-hourly.entity';
|
import { ServerStatsHourly } from '../../entities/server-stats-hourly.entity';
|
||||||
import { ServerStats } from '../../entities/server-stats.entity';
|
import { ServerStats } from '../../entities/server-stats.entity';
|
||||||
import { WipeHistory } from '../../entities/wipe-history.entity';
|
import { WipeHistory } from '../../entities/wipe-history.entity';
|
||||||
@@ -111,13 +111,13 @@ export class AnalyticsService {
|
|||||||
.createQueryBuilder('wipe')
|
.createQueryBuilder('wipe')
|
||||||
.leftJoinAndSelect('wipe.map', 'map')
|
.leftJoinAndSelect('wipe.map', 'map')
|
||||||
.select('map.id', 'map_id')
|
.select('map.id', 'map_id')
|
||||||
.addSelect('map.name', 'map_name')
|
.addSelect('map.display_name', 'map_name')
|
||||||
.addSelect('COUNT(wipe.id)', 'usage_count')
|
.addSelect('COUNT(wipe.id)', 'usage_count')
|
||||||
.where('wipe.license_id = :licenseId', { licenseId })
|
.where('wipe.license_id = :licenseId', { licenseId })
|
||||||
.andWhere('wipe.started_at >= :cutoff', { cutoff })
|
.andWhere('wipe.started_at >= :cutoff', { cutoff })
|
||||||
.andWhere('wipe.map_id IS NOT NULL')
|
.andWhere('wipe.map_id IS NOT NULL')
|
||||||
.groupBy('map.id')
|
.groupBy('map.id')
|
||||||
.addGroupBy('map.name')
|
.addGroupBy('map.display_name')
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -169,13 +169,18 @@ export class AnalyticsService {
|
|||||||
const retentionData = await Promise.all(
|
const retentionData = await Promise.all(
|
||||||
recentWipes.map(async (wipe) => {
|
recentWipes.map(async (wipe) => {
|
||||||
const wipeDate = wipe.started_at;
|
const wipeDate = wipe.started_at;
|
||||||
const nextWipe = recentWipes.find(w => w.started_at && wipeDate && w.started_at > wipeDate);
|
// Find the next wipe chronologically after this one (wipes are DESC ordered)
|
||||||
|
const nextWipe = recentWipes
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.find(w => w.started_at && wipeDate && w.started_at > wipeDate);
|
||||||
const endDate = nextWipe?.started_at || new Date();
|
const endDate = nextWipe?.started_at || new Date();
|
||||||
|
|
||||||
|
// Query sessions strictly within this wipe cycle: [wipeDate, endDate)
|
||||||
const sessionsInPeriod = await this.playerSessionRepo.find({
|
const sessionsInPeriod = await this.playerSessionRepo.find({
|
||||||
where: {
|
where: {
|
||||||
license_id: licenseId,
|
license_id: licenseId,
|
||||||
session_start: MoreThan(wipeDate!),
|
session_start: Between(wipeDate!, endDate),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -183,6 +188,7 @@ export class AnalyticsService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
wipe_date: wipeDate,
|
wipe_date: wipeDate,
|
||||||
|
end_date: endDate,
|
||||||
unique_players: uniquePlayers,
|
unique_players: uniquePlayers,
|
||||||
total_sessions: sessionsInPeriod.length,
|
total_sessions: sessionsInPeriod.length,
|
||||||
};
|
};
|
||||||
|
|||||||
55
backend-nest/src/modules/api-keys/api-keys.controller.ts
Normal file
55
backend-nest/src/modules/api-keys/api-keys.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { ApiKeysService } from './api-keys.service';
|
||||||
|
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||||
|
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||||
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
|
|
||||||
|
@ApiTags('api-keys')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('api-keys')
|
||||||
|
export class ApiKeysController {
|
||||||
|
constructor(private readonly apiKeysService: ApiKeysService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('apikeys.manage')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Create an API key',
|
||||||
|
description:
|
||||||
|
'Issues a new API key for this license. The full plaintext key is returned ONCE — store it securely; it cannot be retrieved again.',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 201, description: 'Key created — plaintext key returned once.' })
|
||||||
|
async create(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Body() dto: CreateApiKeyDto,
|
||||||
|
) {
|
||||||
|
return this.apiKeysService.create(licenseId, dto.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('apikeys.view')
|
||||||
|
@ApiOperation({ summary: 'List API keys', description: 'Returns all keys (active and revoked) for this license. Key hashes are never returned.' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Key list.' })
|
||||||
|
async list(@CurrentTenant() licenseId: string) {
|
||||||
|
return this.apiKeysService.list(licenseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@RequirePermission('apikeys.manage')
|
||||||
|
@ApiOperation({ summary: 'Revoke an API key', description: 'Soft-deletes the key (is_active = false). The row is retained for audit purposes.' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Key revoked.' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Key not found in this license.' })
|
||||||
|
async revoke(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.apiKeysService.revoke(licenseId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend-nest/src/modules/api-keys/api-keys.module.ts
Normal file
15
backend-nest/src/modules/api-keys/api-keys.module.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ApiKey } from '../../entities/api-key.entity';
|
||||||
|
import { License } from '../../entities/license.entity';
|
||||||
|
import { ApiKeysController } from './api-keys.controller';
|
||||||
|
import { ApiKeysService } from './api-keys.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([ApiKey, License])],
|
||||||
|
controllers: [ApiKeysController],
|
||||||
|
providers: [ApiKeysService],
|
||||||
|
exports: [ApiKeysService],
|
||||||
|
})
|
||||||
|
export class ApiKeysModule {}
|
||||||
163
backend-nest/src/modules/api-keys/api-keys.service.ts
Normal file
163
backend-nest/src/modules/api-keys/api-keys.service.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { ApiKey } from '../../entities/api-key.entity';
|
||||||
|
import { License } from '../../entities/license.entity';
|
||||||
|
|
||||||
|
/** Shape returned to the caller on creation — the ONLY time the plaintext key is exposed. */
|
||||||
|
export interface CreatedApiKey {
|
||||||
|
/** Full plaintext key — show once, store nowhere. */
|
||||||
|
plaintext_key: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key_prefix: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Safe list view — no hash, no plaintext. */
|
||||||
|
export interface ApiKeyListItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key_prefix: string;
|
||||||
|
last_used_at: Date | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiKeysService {
|
||||||
|
private readonly logger = new Logger(ApiKeysService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ApiKey)
|
||||||
|
private readonly apiKeyRepo: Repository<ApiKey>,
|
||||||
|
@InjectRepository(License)
|
||||||
|
private readonly licenseRepo: Repository<License>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue a new API key for the given license.
|
||||||
|
*
|
||||||
|
* Key format: `corr_<prefix8>_<secret32>`
|
||||||
|
* where prefix and secret are URL-safe base64url random bytes.
|
||||||
|
*
|
||||||
|
* Returns the full plaintext key ONCE alongside the saved row.
|
||||||
|
* The hash is never returned to the caller.
|
||||||
|
*/
|
||||||
|
async create(licenseId: string, name: string): Promise<CreatedApiKey> {
|
||||||
|
const prefixBytes = crypto.randomBytes(6); // 8 base64url chars
|
||||||
|
const secretBytes = crypto.randomBytes(24); // 32 base64url chars
|
||||||
|
|
||||||
|
const prefix = prefixBytes.toString('base64url');
|
||||||
|
const secret = secretBytes.toString('base64url');
|
||||||
|
const plaintextKey = `corr_${prefix}_${secret}`;
|
||||||
|
|
||||||
|
const keyHash = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(plaintextKey)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
const entity = this.apiKeyRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
name,
|
||||||
|
key_prefix: prefix,
|
||||||
|
key_hash: keyHash,
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.apiKeyRepo.save(entity);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`API key created: id=${saved.id} prefix=${prefix} license=${licenseId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
plaintext_key: plaintextKey,
|
||||||
|
id: saved.id,
|
||||||
|
name: saved.name,
|
||||||
|
key_prefix: saved.key_prefix,
|
||||||
|
is_active: saved.is_active,
|
||||||
|
created_at: saved.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all keys (active and revoked) for a license.
|
||||||
|
* The key_hash is intentionally excluded.
|
||||||
|
*/
|
||||||
|
async list(licenseId: string): Promise<ApiKeyListItem[]> {
|
||||||
|
const rows = await this.apiKeyRepo.find({
|
||||||
|
where: { license_id: licenseId },
|
||||||
|
order: { created_at: 'DESC' },
|
||||||
|
select: ['id', 'name', 'key_prefix', 'last_used_at', 'is_active', 'created_at'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
key_prefix: r.key_prefix,
|
||||||
|
last_used_at: r.last_used_at,
|
||||||
|
is_active: r.is_active,
|
||||||
|
created_at: r.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke (soft-delete) a key.
|
||||||
|
* Returns the updated row or throws NotFoundException if the key
|
||||||
|
* doesn't exist within this license.
|
||||||
|
*/
|
||||||
|
async revoke(licenseId: string, id: string): Promise<{ id: string; is_active: boolean }> {
|
||||||
|
const key = await this.apiKeyRepo.findOne({
|
||||||
|
where: { id, license_id: licenseId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
throw new NotFoundException(`API key ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
key.is_active = false;
|
||||||
|
await this.apiKeyRepo.save(key);
|
||||||
|
|
||||||
|
this.logger.log(`API key revoked: id=${id} license=${licenseId}`);
|
||||||
|
|
||||||
|
return { id: key.id, is_active: key.is_active };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a raw API key string. Called by JwtAuthGuard.
|
||||||
|
*
|
||||||
|
* Hashes the raw key, looks up an ACTIVE row, touches last_used_at, resolves
|
||||||
|
* the license owner (so the guard can attribute the call to a real user UUID),
|
||||||
|
* and returns { license_id, user_id } on success or null on failure.
|
||||||
|
*
|
||||||
|
* user_id is the license owner — API-key calls act AS the owner, so any
|
||||||
|
* created_by / @CurrentUser FK insert gets a valid UUID and correct attribution.
|
||||||
|
*/
|
||||||
|
async validateKey(
|
||||||
|
rawKey: string,
|
||||||
|
): Promise<{ license_id: string; user_id: string | null } | null> {
|
||||||
|
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
|
||||||
|
|
||||||
|
const key = await this.apiKeyRepo.findOne({
|
||||||
|
where: { key_hash: keyHash, is_active: true },
|
||||||
|
select: ['id', 'license_id'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last_used_at without loading the full row again.
|
||||||
|
await this.apiKeyRepo.update(key.id, { last_used_at: new Date() });
|
||||||
|
|
||||||
|
const license = await this.licenseRepo.findOne({
|
||||||
|
where: { id: key.license_id },
|
||||||
|
select: ['id', 'owner_user_id'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { license_id: key.license_id, user_id: license?.owner_user_id ?? null };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend-nest/src/modules/api-keys/dto/create-api-key.dto.ts
Normal file
10
backend-nest/src/modules/api-keys/dto/create-api-key.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateApiKeyDto {
|
||||||
|
@ApiProperty({ description: 'Human-readable label for this key', maxLength: 100 })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(100)
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
82
backend-nest/src/modules/auth/admin-seed.service.ts
Normal file
82
backend-nest/src/modules/auth/admin-seed.service.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { User } from '../../entities/user.entity';
|
||||||
|
import { License } from '../../entities/license.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstraps the first admin account on a fresh database.
|
||||||
|
*
|
||||||
|
* A fresh deploy builds the schema via docker-entrypoint-initdb.d but contains
|
||||||
|
* zero users, so the panel has no possible login. If ADMIN_EMAIL and
|
||||||
|
* ADMIN_PASSWORD are set and the users table is empty, this creates a
|
||||||
|
* super-admin user plus an active license — the same rows the register flow
|
||||||
|
* would create. It never runs against a database that already has users.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AdminSeedService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(AdminSeedService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
@InjectRepository(User) private readonly userRepository: Repository<User>,
|
||||||
|
@InjectRepository(License) private readonly licenseRepository: Repository<License>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.seedAdminIfEmpty();
|
||||||
|
} catch (err) {
|
||||||
|
// A failed seed must not take the API down — surface it loudly and move on
|
||||||
|
this.logger.error(`Admin bootstrap failed: ${(err as Error).message}`, (err as Error).stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedAdminIfEmpty(): Promise<void> {
|
||||||
|
const email = this.config.get<string>('admin.email');
|
||||||
|
const password = this.config.get<string>('admin.password');
|
||||||
|
const username = this.config.get<string>('admin.username') || 'Commander';
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
this.logger.log('Admin bootstrap skipped: ADMIN_EMAIL / ADMIN_PASSWORD not set');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCount = await this.userRepository.count();
|
||||||
|
if (userCount > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const password_hash = await argon2.hash(password);
|
||||||
|
const user = this.userRepository.create({
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
email_verified: true,
|
||||||
|
is_super_admin: true,
|
||||||
|
});
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
const licenseKey = this.config.get<string>('admin.licenseKey') || this.generateLicenseKey();
|
||||||
|
const license = this.licenseRepository.create({
|
||||||
|
license_key: licenseKey,
|
||||||
|
owner_user_id: user.id,
|
||||||
|
status: 'active',
|
||||||
|
modules_enabled: [],
|
||||||
|
webstore_active: false,
|
||||||
|
});
|
||||||
|
await this.licenseRepository.save(license);
|
||||||
|
|
||||||
|
this.logger.log(`Bootstrap admin created: ${user.email} (license ${license.license_key})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateLicenseKey(): string {
|
||||||
|
const part1 = randomBytes(2).toString('hex').toUpperCase();
|
||||||
|
const part2 = randomBytes(2).toString('hex').toUpperCase();
|
||||||
|
const part3 = randomBytes(2).toString('hex').toUpperCase();
|
||||||
|
return `CORR-${part1}-${part2}-${part3}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { LoginDto } from './dto/login.dto';
|
|||||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
import { VerifyTotpDto } from './dto/verify-totp.dto';
|
import { VerifyTotpDto } from './dto/verify-totp.dto';
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||||
import { Public } from '../../common/decorators/public.decorator';
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
@@ -61,6 +62,30 @@ export class AuthController {
|
|||||||
return this.authService.verifyTotp(userId, dto.code);
|
return this.authService.verifyTotp(userId, dto.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('2fa/disable')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Disable TOTP 2FA (requires a current code)' })
|
||||||
|
async disableTotp(
|
||||||
|
@CurrentUser('sub') userId: string,
|
||||||
|
@Body() dto: VerifyTotpDto,
|
||||||
|
) {
|
||||||
|
return this.authService.disableTotp(userId, dto.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('change-password')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Change the current user password' })
|
||||||
|
async changePassword(
|
||||||
|
@CurrentUser('sub') userId: string,
|
||||||
|
@Body() dto: ChangePasswordDto,
|
||||||
|
) {
|
||||||
|
return this.authService.changePassword(
|
||||||
|
userId,
|
||||||
|
dto.current_password,
|
||||||
|
dto.new_password,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('me')
|
@Get('me')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Get current user profile' })
|
@ApiOperation({ summary: 'Get current user profile' })
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { AdminSeedService } from './admin-seed.service';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
import { User } from '../../entities/user.entity';
|
import { User } from '../../entities/user.entity';
|
||||||
import { License } from '../../entities/license.entity';
|
import { License } from '../../entities/license.entity';
|
||||||
@@ -27,7 +28,7 @@ import { TeamMember } from '../../entities/team-member.entity';
|
|||||||
TypeOrmModule.forFeature([User, License, Role, TeamMember]),
|
TypeOrmModule.forFeature([User, License, Role, TeamMember]),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtStrategy],
|
providers: [AuthService, AdminSeedService, JwtStrategy],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -35,13 +35,20 @@ export class AuthService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async register(dto: RegisterDto) {
|
async register(dto: RegisterDto) {
|
||||||
|
// Normalize email to lowercase to prevent case-sensitive duplicates
|
||||||
|
const normalizedEmail = dto.email.toLowerCase();
|
||||||
|
|
||||||
// Check if user already exists
|
// Check if user already exists
|
||||||
const existingUser = await this.userRepository.findOne({
|
const existingUser = await this.userRepository
|
||||||
where: [{ email: dto.email }, { username: dto.username }],
|
.createQueryBuilder('user')
|
||||||
});
|
.where('LOWER(user.email) = :email OR user.username = :username', {
|
||||||
|
email: normalizedEmail,
|
||||||
|
username: dto.username,
|
||||||
|
})
|
||||||
|
.getOne();
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
if (existingUser.email === dto.email) {
|
if (existingUser.email.toLowerCase() === normalizedEmail) {
|
||||||
throw new ConflictException('Email already registered');
|
throw new ConflictException('Email already registered');
|
||||||
}
|
}
|
||||||
throw new ConflictException('Username already taken');
|
throw new ConflictException('Username already taken');
|
||||||
@@ -50,9 +57,9 @@ export class AuthService {
|
|||||||
// Hash password
|
// Hash password
|
||||||
const password_hash = await argon2.hash(dto.password);
|
const password_hash = await argon2.hash(dto.password);
|
||||||
|
|
||||||
// Create user
|
// Create user (email stored lowercase)
|
||||||
const user = this.userRepository.create({
|
const user = this.userRepository.create({
|
||||||
email: dto.email,
|
email: normalizedEmail,
|
||||||
username: dto.username,
|
username: dto.username,
|
||||||
password_hash,
|
password_hash,
|
||||||
email_verified: false,
|
email_verified: false,
|
||||||
@@ -73,8 +80,8 @@ export class AuthService {
|
|||||||
|
|
||||||
await this.licenseRepository.save(license);
|
await this.licenseRepository.save(license);
|
||||||
|
|
||||||
// Generate tokens
|
// Generate tokens (include license_id for tenant-scoped operations)
|
||||||
const tokens = await this.generateTokens(user);
|
const tokens = await this.generateTokens(user, license.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...tokens,
|
...tokens,
|
||||||
@@ -85,16 +92,28 @@ export class AuthService {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
is_super_admin: user.is_super_admin,
|
is_super_admin: user.is_super_admin,
|
||||||
totp_enabled: user.totp_enabled,
|
totp_enabled: user.totp_enabled,
|
||||||
license_key: licenseKey,
|
},
|
||||||
|
license: {
|
||||||
|
id: license.id,
|
||||||
|
license_key: license.license_key,
|
||||||
|
status: license.status,
|
||||||
|
server_name: license.server_name ?? null,
|
||||||
|
subdomain: license.subdomain ?? null,
|
||||||
|
custom_domain: license.custom_domain ?? null,
|
||||||
|
modules_enabled: license.modules_enabled,
|
||||||
|
webstore_active: license.webstore_active,
|
||||||
|
created_at: license.created_at,
|
||||||
|
expires_at: license.expires_at ?? null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(dto: LoginDto) {
|
async login(dto: LoginDto) {
|
||||||
// Find user by email
|
// Find user by email (case-insensitive)
|
||||||
const user = await this.userRepository.findOne({
|
const user = await this.userRepository
|
||||||
where: { email: dto.email },
|
.createQueryBuilder('user')
|
||||||
});
|
.where('LOWER(user.email) = :email', { email: dto.email.toLowerCase() })
|
||||||
|
.getOne();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
@@ -125,14 +144,14 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate tokens
|
// Get user's license (needed for JWT license_id claim)
|
||||||
const tokens = await this.generateTokens(user);
|
|
||||||
|
|
||||||
// Get user's license
|
|
||||||
const license = await this.licenseRepository.findOne({
|
const license = await this.licenseRepository.findOne({
|
||||||
where: { owner_user_id: user.id },
|
where: { owner_user_id: user.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const tokens = await this.generateTokens(user, license?.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...tokens,
|
...tokens,
|
||||||
requires_totp: false,
|
requires_totp: false,
|
||||||
@@ -142,8 +161,19 @@ export class AuthService {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
is_super_admin: user.is_super_admin,
|
is_super_admin: user.is_super_admin,
|
||||||
totp_enabled: user.totp_enabled,
|
totp_enabled: user.totp_enabled,
|
||||||
license_key: license?.license_key,
|
|
||||||
},
|
},
|
||||||
|
license: license ? {
|
||||||
|
id: license.id,
|
||||||
|
license_key: license.license_key,
|
||||||
|
status: license.status,
|
||||||
|
server_name: license.server_name,
|
||||||
|
subdomain: license.subdomain,
|
||||||
|
custom_domain: license.custom_domain,
|
||||||
|
modules_enabled: license.modules_enabled,
|
||||||
|
webstore_active: license.webstore_active,
|
||||||
|
created_at: license.created_at,
|
||||||
|
expires_at: license.expires_at,
|
||||||
|
} : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,22 +191,17 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('User not found');
|
throw new UnauthorizedException('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new access token
|
// Look up license for JWT claim
|
||||||
const accessToken = await this.jwtService.signAsync(
|
const license = await this.licenseRepository.findOne({
|
||||||
{
|
where: { owner_user_id: user.id },
|
||||||
sub: user.id,
|
});
|
||||||
email: user.email,
|
|
||||||
username: user.username,
|
// Generate new token pair (rotating refresh tokens)
|
||||||
is_super_admin: user.is_super_admin,
|
const tokens = await this.generateTokens(user, license?.id);
|
||||||
},
|
|
||||||
{
|
|
||||||
secret: this.configService.get<string>('jwt.secret'),
|
|
||||||
expiresIn: this.configService.get<number>('jwt.accessExpirySeconds') || 900,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
access_token: accessToken,
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new UnauthorizedException('Invalid refresh token');
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
@@ -310,16 +335,70 @@ export class AuthService {
|
|||||||
throw new NotImplementedException('Password reset not yet configured');
|
throw new NotImplementedException('Password reset not yet configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async changePassword(userId: string, currentPassword: string, newPassword: string) {
|
||||||
|
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await argon2.verify(user.password_hash, currentPassword);
|
||||||
|
if (!valid) {
|
||||||
|
throw new UnauthorizedException('Current password is incorrect');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await argon2.verify(user.password_hash, newPassword)) {
|
||||||
|
throw new BadRequestException('New password must be different from the current one');
|
||||||
|
}
|
||||||
|
|
||||||
|
const password_hash = await argon2.hash(newPassword);
|
||||||
|
await this.userRepository.update(user.id, { password_hash });
|
||||||
|
this.logger.log(`Password changed for user ${user.id}`);
|
||||||
|
|
||||||
|
// NOTE: existing JWTs remain valid until expiry — this design has no
|
||||||
|
// server-side refresh-token store to revoke. Session invalidation on
|
||||||
|
// password change is a follow-up (tracked separately).
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async disableTotp(userId: string, code: string) {
|
||||||
|
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.totp_enabled) {
|
||||||
|
throw new BadRequestException('2FA is not enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require a valid current code — proves possession of the second factor
|
||||||
|
// before removing it, so a hijacked session can't silently strip 2FA.
|
||||||
|
const valid = await this.verifyTotpCode(user, code);
|
||||||
|
if (!valid) {
|
||||||
|
throw new UnauthorizedException('Invalid TOTP code');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userRepository.update(user.id, {
|
||||||
|
totp_enabled: false,
|
||||||
|
totp_secret: null,
|
||||||
|
});
|
||||||
|
this.logger.log(`TOTP disabled for user ${user.id}`);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
// Helper methods
|
// Helper methods
|
||||||
|
|
||||||
private async generateTokens(user: User) {
|
private async generateTokens(user: User, licenseId?: string) {
|
||||||
const payload = {
|
const payload: Record<string, unknown> = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
is_super_admin: user.is_super_admin,
|
is_super_admin: user.is_super_admin,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (licenseId) {
|
||||||
|
payload.license_id = licenseId;
|
||||||
|
}
|
||||||
|
|
||||||
const accessToken = await this.jwtService.signAsync(payload, {
|
const accessToken = await this.jwtService.signAsync(payload, {
|
||||||
secret: this.configService.get<string>('jwt.secret'),
|
secret: this.configService.get<string>('jwt.secret'),
|
||||||
expiresIn: this.configService.get<number>('jwt.accessExpirySeconds') || 900,
|
expiresIn: this.configService.get<number>('jwt.accessExpirySeconds') || 900,
|
||||||
|
|||||||
14
backend-nest/src/modules/auth/dto/change-password.dto.ts
Normal file
14
backend-nest/src/modules/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsString, MinLength, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ChangePasswordDto {
|
||||||
|
@ApiProperty({ description: 'Current account password' })
|
||||||
|
@IsString()
|
||||||
|
current_password: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'New password', minLength: 8, maxLength: 128 })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@MaxLength(128)
|
||||||
|
new_password: string;
|
||||||
|
}
|
||||||
80
backend-nest/src/modules/autodoors/autodoors.controller.ts
Normal file
80
backend-nest/src/modules/autodoors/autodoors.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { AutoDoorsService } from './autodoors.service';
|
||||||
|
import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto';
|
||||||
|
import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto';
|
||||||
|
import { ImportAutoDoorsConfigDto } from './dto/import-autodoors-config.dto';
|
||||||
|
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||||
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||||
|
|
||||||
|
@ApiTags('autodoors')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('autodoors')
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||||
|
export class AutoDoorsController {
|
||||||
|
constructor(private readonly autoDoorsService: AutoDoorsService) {}
|
||||||
|
|
||||||
|
@Get('configs')
|
||||||
|
@RequirePermission('autodoors.view')
|
||||||
|
@ApiOperation({ summary: 'List AutoDoors configs' })
|
||||||
|
getConfigs(@CurrentTenant() licenseId: string) {
|
||||||
|
return this.autoDoorsService.getConfigs(licenseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('configs/:id')
|
||||||
|
@RequirePermission('autodoors.view')
|
||||||
|
@ApiOperation({ summary: 'Get full AutoDoors config' })
|
||||||
|
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.autoDoorsService.getConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs')
|
||||||
|
@RequirePermission('autodoors.manage')
|
||||||
|
@ApiOperation({ summary: 'Create AutoDoors config' })
|
||||||
|
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateAutoDoorsConfigDto) {
|
||||||
|
return this.autoDoorsService.createConfig(licenseId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('configs/:id')
|
||||||
|
@RequirePermission('autodoors.manage')
|
||||||
|
@ApiOperation({ summary: 'Update AutoDoors config' })
|
||||||
|
updateConfig(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateAutoDoorsConfigDto,
|
||||||
|
) {
|
||||||
|
return this.autoDoorsService.updateConfig(licenseId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('configs/:id')
|
||||||
|
@RequirePermission('autodoors.manage')
|
||||||
|
@ApiOperation({ summary: 'Delete AutoDoors config' })
|
||||||
|
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.autoDoorsService.deleteConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs/:id/apply')
|
||||||
|
@RequirePermission('autodoors.manage')
|
||||||
|
@ApiOperation({ summary: 'Deploy AutoDoors config to server' })
|
||||||
|
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.autoDoorsService.applyToServer(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('import-from-server')
|
||||||
|
@RequirePermission('autodoors.manage')
|
||||||
|
@ApiOperation({ summary: 'Import AutoDoors.json from server' })
|
||||||
|
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportAutoDoorsConfigDto) {
|
||||||
|
return this.autoDoorsService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend-nest/src/modules/autodoors/autodoors.module.ts
Normal file
14
backend-nest/src/modules/autodoors/autodoors.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AutoDoorsController } from './autodoors.controller';
|
||||||
|
import { AutoDoorsService } from './autodoors.service';
|
||||||
|
import { AutoDoorsConfig } from '../../entities/autodoors-config.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([AutoDoorsConfig])],
|
||||||
|
controllers: [AutoDoorsController],
|
||||||
|
providers: [AutoDoorsService, NatsService],
|
||||||
|
exports: [AutoDoorsService],
|
||||||
|
})
|
||||||
|
export class AutoDoorsModule {}
|
||||||
165
backend-nest/src/modules/autodoors/autodoors.service.ts
Normal file
165
backend-nest/src/modules/autodoors/autodoors.service.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AutoDoorsConfig } from '../../entities/autodoors-config.entity';
|
||||||
|
import { InstancesService } from '../instances/instances.service';
|
||||||
|
import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto';
|
||||||
|
import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AutoDoorsService {
|
||||||
|
private readonly logger = new Logger(AutoDoorsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AutoDoorsConfig)
|
||||||
|
private readonly autoDoorsRepo: Repository<AutoDoorsConfig>,
|
||||||
|
private readonly instancesService: InstancesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** List configs for a license (summaries — no JSONB) */
|
||||||
|
async getConfigs(licenseId: string) {
|
||||||
|
const configs = await this.autoDoorsRepo.find({
|
||||||
|
where: { license_id: licenseId },
|
||||||
|
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
|
||||||
|
order: { created_at: 'DESC' },
|
||||||
|
});
|
||||||
|
return { configs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get full config with JSONB data */
|
||||||
|
async getConfig(licenseId: string, configId: string) {
|
||||||
|
const config = await this.autoDoorsRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('AutoDoors config not found');
|
||||||
|
return { config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new config */
|
||||||
|
async createConfig(licenseId: string, dto: CreateAutoDoorsConfigDto) {
|
||||||
|
const config = this.autoDoorsRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: dto.config_name,
|
||||||
|
description: dto.description || null,
|
||||||
|
config_data: dto.config_data || {},
|
||||||
|
});
|
||||||
|
const saved = await this.autoDoorsRepo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update an existing config */
|
||||||
|
async updateConfig(licenseId: string, configId: string, dto: UpdateAutoDoorsConfigDto) {
|
||||||
|
const config = await this.autoDoorsRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('AutoDoors config not found');
|
||||||
|
|
||||||
|
if (dto.config_name !== undefined) config.config_name = dto.config_name;
|
||||||
|
if (dto.description !== undefined) config.description = dto.description;
|
||||||
|
if (dto.config_data !== undefined) config.config_data = dto.config_data;
|
||||||
|
if (dto.is_active !== undefined) config.is_active = dto.is_active;
|
||||||
|
config.updated_at = new Date();
|
||||||
|
|
||||||
|
const saved = await this.autoDoorsRepo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a config */
|
||||||
|
async deleteConfig(licenseId: string, configId: string) {
|
||||||
|
const result = await this.autoDoorsRepo.delete({ id: configId, license_id: licenseId });
|
||||||
|
if (result.affected === 0) throw new NotFoundException('AutoDoors config not found');
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deploy config to game server via NATS */
|
||||||
|
async applyToServer(licenseId: string, configId: string) {
|
||||||
|
const config = await this.autoDoorsRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('AutoDoors config not found');
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Write AutoDoors.json via Rust agent
|
||||||
|
await this.instancesService.writeFileForLicense(
|
||||||
|
licenseId,
|
||||||
|
'oxide/config/AutoDoors.json',
|
||||||
|
jsonString,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reload AutoDoors plugin via RCON
|
||||||
|
await this.instancesService.rconForLicense(licenseId, 'oxide.reload AutoDoors');
|
||||||
|
|
||||||
|
// Mark this config as active, deactivate others
|
||||||
|
await this.autoDoorsRepo.update({ license_id: licenseId }, { is_active: false });
|
||||||
|
await this.autoDoorsRepo.update(
|
||||||
|
{ id: configId, license_id: licenseId },
|
||||||
|
{ is_active: true, updated_at: new Date() },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Config "${config.config_name}" deployed to server`,
|
||||||
|
config_name: config.config_name,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to deploy AutoDoors config: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to deploy AutoDoors config — agent may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import AutoDoors.json from game server via NATS */
|
||||||
|
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||||
|
try {
|
||||||
|
// Read AutoDoors.json from server via Rust agent
|
||||||
|
const result = await this.instancesService.readFileForLicense(
|
||||||
|
licenseId,
|
||||||
|
'oxide/config/AutoDoors.json',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new HttpException(
|
||||||
|
'No response from agent — it may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the response content as JSON
|
||||||
|
const responseData = (result as any).content;
|
||||||
|
let configData: Record<string, any>;
|
||||||
|
|
||||||
|
if (typeof responseData === 'string') {
|
||||||
|
configData = JSON.parse(responseData);
|
||||||
|
} else if (typeof responseData === 'object') {
|
||||||
|
configData = responseData;
|
||||||
|
} else {
|
||||||
|
throw new HttpException(
|
||||||
|
'Unexpected response format from agent',
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new AutoDoors config row
|
||||||
|
const config = this.autoDoorsRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: configName,
|
||||||
|
description: description || 'Imported from server',
|
||||||
|
config_data: configData,
|
||||||
|
});
|
||||||
|
const saved = await this.autoDoorsRepo.save(config);
|
||||||
|
|
||||||
|
return { config: saved };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
this.logger.error(`Failed to import AutoDoors config from server: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to import AutoDoors config — agent may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateAutoDoorsConfigDto {
|
||||||
|
@ApiProperty({ example: 'Default AutoDoors' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Standard auto-close settings' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
config_data?: Record<string, any>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ImportAutoDoorsConfigDto {
|
||||||
|
@ApiProperty({ example: 'Server Import' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Imported from live server' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class UpdateAutoDoorsConfigDto {
|
||||||
|
@ApiPropertyOptional({ example: 'Updated Config' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
@IsOptional()
|
||||||
|
config_name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Updated description' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
config_data?: Record<string, any>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
80
backend-nest/src/modules/betterchat/betterchat.controller.ts
Normal file
80
backend-nest/src/modules/betterchat/betterchat.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { BetterChatService } from './betterchat.service';
|
||||||
|
import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto';
|
||||||
|
import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto';
|
||||||
|
import { ImportBetterChatConfigDto } from './dto/import-betterchat-config.dto';
|
||||||
|
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||||
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||||
|
|
||||||
|
@ApiTags('betterchat')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('betterchat')
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||||
|
export class BetterChatController {
|
||||||
|
constructor(private readonly betterChatService: BetterChatService) {}
|
||||||
|
|
||||||
|
@Get('configs')
|
||||||
|
@RequirePermission('betterchat.view')
|
||||||
|
@ApiOperation({ summary: 'List BetterChat configs (summaries)' })
|
||||||
|
getConfigs(@CurrentTenant() licenseId: string) {
|
||||||
|
return this.betterChatService.getConfigs(licenseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('configs/:id')
|
||||||
|
@RequirePermission('betterchat.view')
|
||||||
|
@ApiOperation({ summary: 'Get full BetterChat config with data' })
|
||||||
|
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.betterChatService.getConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs')
|
||||||
|
@RequirePermission('betterchat.manage')
|
||||||
|
@ApiOperation({ summary: 'Create BetterChat config' })
|
||||||
|
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateBetterChatConfigDto) {
|
||||||
|
return this.betterChatService.createConfig(licenseId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('configs/:id')
|
||||||
|
@RequirePermission('betterchat.manage')
|
||||||
|
@ApiOperation({ summary: 'Update BetterChat config' })
|
||||||
|
updateConfig(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateBetterChatConfigDto,
|
||||||
|
) {
|
||||||
|
return this.betterChatService.updateConfig(licenseId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('configs/:id')
|
||||||
|
@RequirePermission('betterchat.manage')
|
||||||
|
@ApiOperation({ summary: 'Delete BetterChat config' })
|
||||||
|
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.betterChatService.deleteConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs/:id/apply')
|
||||||
|
@RequirePermission('betterchat.manage')
|
||||||
|
@ApiOperation({ summary: 'Deploy BetterChat config to server' })
|
||||||
|
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.betterChatService.applyToServer(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('import-from-server')
|
||||||
|
@RequirePermission('betterchat.manage')
|
||||||
|
@ApiOperation({ summary: 'Import BetterChat.json from server via NATS' })
|
||||||
|
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportBetterChatConfigDto) {
|
||||||
|
return this.betterChatService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend-nest/src/modules/betterchat/betterchat.module.ts
Normal file
14
backend-nest/src/modules/betterchat/betterchat.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { BetterChatController } from './betterchat.controller';
|
||||||
|
import { BetterChatService } from './betterchat.service';
|
||||||
|
import { BetterChatConfig } from '../../entities/betterchat-config.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([BetterChatConfig])],
|
||||||
|
controllers: [BetterChatController],
|
||||||
|
providers: [BetterChatService, NatsService],
|
||||||
|
exports: [BetterChatService],
|
||||||
|
})
|
||||||
|
export class BetterChatModule {}
|
||||||
165
backend-nest/src/modules/betterchat/betterchat.service.ts
Normal file
165
backend-nest/src/modules/betterchat/betterchat.service.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { BetterChatConfig } from '../../entities/betterchat-config.entity';
|
||||||
|
import { InstancesService } from '../instances/instances.service';
|
||||||
|
import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto';
|
||||||
|
import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BetterChatService {
|
||||||
|
private readonly logger = new Logger(BetterChatService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(BetterChatConfig)
|
||||||
|
private readonly repo: Repository<BetterChatConfig>,
|
||||||
|
private readonly instancesService: InstancesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** List configs for a license (summaries — no JSONB) */
|
||||||
|
async getConfigs(licenseId: string) {
|
||||||
|
const configs = await this.repo.find({
|
||||||
|
where: { license_id: licenseId },
|
||||||
|
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
|
||||||
|
order: { created_at: 'DESC' },
|
||||||
|
});
|
||||||
|
return { configs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get full config with JSONB data */
|
||||||
|
async getConfig(licenseId: string, configId: string) {
|
||||||
|
const config = await this.repo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('BetterChat config not found');
|
||||||
|
return { config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new config */
|
||||||
|
async createConfig(licenseId: string, dto: CreateBetterChatConfigDto) {
|
||||||
|
const config = this.repo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: dto.config_name,
|
||||||
|
description: dto.description || null,
|
||||||
|
config_data: dto.config_data || {},
|
||||||
|
});
|
||||||
|
const saved = await this.repo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update an existing config */
|
||||||
|
async updateConfig(licenseId: string, configId: string, dto: UpdateBetterChatConfigDto) {
|
||||||
|
const config = await this.repo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('BetterChat config not found');
|
||||||
|
|
||||||
|
if (dto.config_name !== undefined) config.config_name = dto.config_name;
|
||||||
|
if (dto.description !== undefined) config.description = dto.description;
|
||||||
|
if (dto.config_data !== undefined) config.config_data = dto.config_data;
|
||||||
|
if (dto.is_active !== undefined) config.is_active = dto.is_active;
|
||||||
|
config.updated_at = new Date();
|
||||||
|
|
||||||
|
const saved = await this.repo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a config */
|
||||||
|
async deleteConfig(licenseId: string, configId: string) {
|
||||||
|
const result = await this.repo.delete({ id: configId, license_id: licenseId });
|
||||||
|
if (result.affected === 0) throw new NotFoundException('BetterChat config not found');
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deploy config to game server via NATS */
|
||||||
|
async applyToServer(licenseId: string, configId: string) {
|
||||||
|
const config = await this.repo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('BetterChat config not found');
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Write BetterChat.json via Rust agent
|
||||||
|
await this.instancesService.writeFileForLicense(
|
||||||
|
licenseId,
|
||||||
|
'oxide/config/BetterChat.json',
|
||||||
|
jsonString,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reload BetterChat plugin via RCON
|
||||||
|
await this.instancesService.rconForLicense(licenseId, 'oxide.reload BetterChat');
|
||||||
|
|
||||||
|
// Mark this config as active, deactivate others
|
||||||
|
await this.repo.update({ license_id: licenseId }, { is_active: false });
|
||||||
|
await this.repo.update(
|
||||||
|
{ id: configId, license_id: licenseId },
|
||||||
|
{ is_active: true, updated_at: new Date() },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Config "${config.config_name}" deployed to server`,
|
||||||
|
config_name: config.config_name,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to deploy BetterChat config: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to deploy BetterChat config — agent may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import BetterChat.json from game server via NATS */
|
||||||
|
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||||
|
try {
|
||||||
|
// Read BetterChat.json from server via Rust agent
|
||||||
|
const result = await this.instancesService.readFileForLicense(
|
||||||
|
licenseId,
|
||||||
|
'oxide/config/BetterChat.json',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new HttpException(
|
||||||
|
'No response from agent — it may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the response content as JSON
|
||||||
|
const responseData = (result as any).content;
|
||||||
|
let configData: Record<string, any>;
|
||||||
|
|
||||||
|
if (typeof responseData === 'string') {
|
||||||
|
configData = JSON.parse(responseData);
|
||||||
|
} else if (typeof responseData === 'object') {
|
||||||
|
configData = responseData;
|
||||||
|
} else {
|
||||||
|
throw new HttpException(
|
||||||
|
'Unexpected response format from agent',
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new config row
|
||||||
|
const config = this.repo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: configName,
|
||||||
|
description: description || 'Imported from server',
|
||||||
|
config_data: configData,
|
||||||
|
});
|
||||||
|
const saved = await this.repo.save(config);
|
||||||
|
|
||||||
|
return { config: saved };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
this.logger.error(`Failed to import BetterChat config from server: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to import BetterChat config — agent may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateBetterChatConfigDto {
|
||||||
|
@ApiProperty({ example: 'Default Chat Config' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Standard BetterChat settings' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
config_data?: Record<string, any>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ImportBetterChatConfigDto {
|
||||||
|
@ApiProperty({ example: 'Server Import' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Imported from live server' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class UpdateBetterChatConfigDto {
|
||||||
|
@ApiPropertyOptional({ example: 'Updated Chat Config' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
@IsOptional()
|
||||||
|
config_name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Updated description' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
config_data?: Record<string, any>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
@@ -7,43 +7,47 @@ import {
|
|||||||
MessageBody,
|
MessageBody,
|
||||||
ConnectedSocket,
|
ConnectedSocket,
|
||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Server, Socket } from 'socket.io';
|
import WebSocket, { Server } from 'ws';
|
||||||
|
import { IncomingMessage } from 'http';
|
||||||
import { Logger, UnauthorizedException } from '@nestjs/common';
|
import { Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { NatsService } from '../../services/nats.service';
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
|
interface ClientMeta {
|
||||||
|
licenseId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Console Gateway
|
* Console Gateway
|
||||||
*
|
*
|
||||||
* Provides real-time WebSocket connectivity for server console I/O.
|
* NOTE: This gateway is NOT currently loaded (ConsoleModule not imported in AppModule).
|
||||||
* Clients connect with JWT token in query params, join a room by license_id,
|
* Console I/O is handled by NatsBridgeGateway instead.
|
||||||
* and can send/receive console commands and output.
|
* Kept for potential future use as a dedicated console-only WebSocket endpoint.
|
||||||
*/
|
*/
|
||||||
@WebSocketGateway({ namespace: '/ws', cors: true })
|
@WebSocketGateway({ path: '/api/console-ws' })
|
||||||
export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
@WebSocketServer()
|
@WebSocketServer()
|
||||||
server: Server;
|
server: Server;
|
||||||
|
|
||||||
private readonly logger = new Logger(ConsoleGateway.name);
|
private readonly logger = new Logger(ConsoleGateway.name);
|
||||||
|
private clientMeta = new Map<WebSocket, ClientMeta>();
|
||||||
|
private licenseClients = new Map<string, Set<WebSocket>>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly natsService: NatsService,
|
private readonly natsService: NatsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
async handleConnection(client: WebSocket, request: IncomingMessage) {
|
||||||
* Handle client connection
|
|
||||||
* Extract JWT from query param, validate, and join room by license_id
|
|
||||||
*/
|
|
||||||
async handleConnection(client: Socket) {
|
|
||||||
try {
|
try {
|
||||||
const token = client.handshake.query.token as string;
|
const url = new URL(request.url || '/', `http://${request.headers.host}`);
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new UnauthorizedException('No token provided');
|
throw new UnauthorizedException('No token provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify JWT
|
|
||||||
const payload = this.jwtService.verify(token);
|
const payload = this.jwtService.verify(token);
|
||||||
const licenseId = payload.license_id;
|
const licenseId = payload.license_id;
|
||||||
|
|
||||||
@@ -51,65 +55,67 @@ export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect
|
|||||||
throw new UnauthorizedException('Invalid token: no license_id');
|
throw new UnauthorizedException('Invalid token: no license_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store license_id on socket for later use
|
this.clientMeta.set(client, { licenseId, userId: payload.sub });
|
||||||
client.data.licenseId = licenseId;
|
|
||||||
client.data.userId = payload.sub;
|
|
||||||
|
|
||||||
// Join room specific to this license
|
if (!this.licenseClients.has(licenseId)) {
|
||||||
await client.join(licenseId);
|
this.licenseClients.set(licenseId, new Set());
|
||||||
|
}
|
||||||
|
this.licenseClients.get(licenseId)!.add(client);
|
||||||
|
|
||||||
this.logger.log(`Client ${client.id} connected to license ${licenseId}`);
|
this.logger.log(`Client connected to license ${licenseId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
this.logger.error(`Connection failed: ${message}`);
|
this.logger.error(`Connection failed: ${message}`);
|
||||||
client.disconnect();
|
client.close(4001, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
handleDisconnect(client: WebSocket) {
|
||||||
* Handle client disconnection
|
const meta = this.clientMeta.get(client);
|
||||||
*/
|
if (meta?.licenseId) {
|
||||||
handleDisconnect(client: Socket) {
|
this.licenseClients.get(meta.licenseId)?.delete(client);
|
||||||
const licenseId = client.data.licenseId;
|
if (this.licenseClients.get(meta.licenseId)?.size === 0) {
|
||||||
this.logger.log(`Client ${client.id} disconnected from license ${licenseId}`);
|
this.licenseClients.delete(meta.licenseId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.clientMeta.delete(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle console input from client
|
|
||||||
* Forward the command to NATS for execution on the game server
|
|
||||||
*/
|
|
||||||
@SubscribeMessage('console_input')
|
@SubscribeMessage('console_input')
|
||||||
async handleConsoleInput(
|
async handleConsoleInput(
|
||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: WebSocket,
|
||||||
@MessageBody() data: { command: string },
|
@MessageBody() data: { command: string },
|
||||||
) {
|
) {
|
||||||
const licenseId = client.data.licenseId;
|
const meta = this.clientMeta.get(client);
|
||||||
|
if (!meta?.licenseId) return;
|
||||||
|
|
||||||
if (!data.command) {
|
if (!data.command) {
|
||||||
return { error: 'Command is required' };
|
return { error: 'Command is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`Console input from ${licenseId}: ${data.command}`);
|
this.logger.debug(`Console input from ${meta.licenseId}: ${data.command}`);
|
||||||
|
|
||||||
// Forward to NATS
|
await this.natsService.sendServerCommand(meta.licenseId, 'command', {
|
||||||
await this.natsService.sendServerCommand(licenseId, 'command', {
|
|
||||||
command: data.command,
|
command: data.command,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Send console output or event to all clients in a license room
|
|
||||||
*/
|
|
||||||
sendToLicense(licenseId: string, event: string, data: any) {
|
sendToLicense(licenseId: string, event: string, data: any) {
|
||||||
this.server.to(licenseId).emit(event, data);
|
const clients = this.licenseClients.get(licenseId);
|
||||||
|
if (!clients) return;
|
||||||
|
|
||||||
|
const message = JSON.stringify({ event, data });
|
||||||
|
for (const client of clients) {
|
||||||
|
// client.OPEN, not WebSocket.OPEN — esModuleInterop is off so the
|
||||||
|
// default `ws` import is undefined at runtime (would crash on forward).
|
||||||
|
if (client.readyState === client.OPEN) {
|
||||||
|
client.send(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcast console output to a specific license
|
|
||||||
* This method would be called by a NATS subscriber when output is received
|
|
||||||
*/
|
|
||||||
broadcastConsoleOutput(licenseId: string, output: string) {
|
broadcastConsoleOutput(licenseId: string, output: string) {
|
||||||
this.sendToLicense(licenseId, 'console_output', { output });
|
this.sendToLicense(licenseId, 'console_output', { output });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateEarlyAccessDto {
|
||||||
|
@ApiProperty({ example: 'admin@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'rust', description: 'Primary game interest or server count' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
server_count?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
|
import { EarlyAccessService } from './early-access.service';
|
||||||
|
import { CreateEarlyAccessDto } from './dto/create-early-access.dto';
|
||||||
|
|
||||||
|
@ApiTags('early-access')
|
||||||
|
@Controller()
|
||||||
|
export class EarlyAccessController {
|
||||||
|
constructor(private readonly earlyAccessService: EarlyAccessService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('early-access')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Register for early access' })
|
||||||
|
async register(@Body() dto: CreateEarlyAccessDto) {
|
||||||
|
return this.earlyAccessService.register(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend-nest/src/modules/early-access/early-access.module.ts
Normal file
12
backend-nest/src/modules/early-access/early-access.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { EarlyAccessSignup } from '../../entities/early-access-signup.entity';
|
||||||
|
import { EarlyAccessController } from './early-access.controller';
|
||||||
|
import { EarlyAccessService } from './early-access.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([EarlyAccessSignup])],
|
||||||
|
controllers: [EarlyAccessController],
|
||||||
|
providers: [EarlyAccessService],
|
||||||
|
})
|
||||||
|
export class EarlyAccessModule {}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { EarlyAccessSignup } from '../../entities/early-access-signup.entity';
|
||||||
|
import { CreateEarlyAccessDto } from './dto/create-early-access.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EarlyAccessService {
|
||||||
|
private readonly logger = new Logger(EarlyAccessService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(EarlyAccessSignup)
|
||||||
|
private readonly repo: Repository<EarlyAccessSignup>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async register(dto: CreateEarlyAccessDto): Promise<{ success: true; alreadyRegistered: boolean }> {
|
||||||
|
const existing = await this.repo.findOne({ where: { email: dto.email } });
|
||||||
|
if (existing) {
|
||||||
|
// Duplicate email — return friendly success rather than a 409 that would break the UX
|
||||||
|
return { success: true, alreadyRegistered: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const signup = this.repo.create({
|
||||||
|
email: dto.email,
|
||||||
|
server_count: dto.server_count ?? 'not specified',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.repo.save(signup);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Guard against a race-condition duplicate (unique constraint violation)
|
||||||
|
const pg = err as { code?: string };
|
||||||
|
if (pg.code === '23505') {
|
||||||
|
return { success: true, alreadyRegistered: true };
|
||||||
|
}
|
||||||
|
this.logger.error('Failed to save early-access signup', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, alreadyRegistered: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
121
backend-nest/src/modules/files/files.controller.ts
Normal file
121
backend-nest/src/modules/files/files.controller.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Body,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||||
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||||
|
import { FilesService } from './files.service';
|
||||||
|
|
||||||
|
@ApiTags('Files')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('files')
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||||
|
export class FilesController {
|
||||||
|
constructor(private readonly filesService: FilesService) {}
|
||||||
|
|
||||||
|
// VueFinder GET operations: ?q=index (list), ?q=search, ?q=preview, ?q=download
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('files.view')
|
||||||
|
async handleGet(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Query('q') operation: string,
|
||||||
|
@Query('path') path: string,
|
||||||
|
@Query('filter') filter: string,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
switch (operation) {
|
||||||
|
case 'index':
|
||||||
|
case undefined:
|
||||||
|
case '':
|
||||||
|
return this.filesService.list(licenseId, path || 'server://');
|
||||||
|
case 'search':
|
||||||
|
return this.filesService.search(licenseId, path || 'server://', filter);
|
||||||
|
case 'preview':
|
||||||
|
return this.filesService.preview(licenseId, path);
|
||||||
|
case 'download': {
|
||||||
|
const result = await this.filesService.download(licenseId, path);
|
||||||
|
res.setHeader('Content-Type', 'application/octet-stream');
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="${result.filename}"`,
|
||||||
|
);
|
||||||
|
res.send(result.content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new HttpException(
|
||||||
|
`Unknown operation: ${operation}`,
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VueFinder POST operations: ?q=delete, rename, move, copy, new-folder, new-file, save, upload
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('files.manage')
|
||||||
|
@UseInterceptors(FileInterceptor('upload'))
|
||||||
|
async handlePost(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Query('q') operation: string,
|
||||||
|
@Body() body: Record<string, any>,
|
||||||
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
switch (operation) {
|
||||||
|
case 'delete':
|
||||||
|
return this.filesService.delete(licenseId, body.path, body.items);
|
||||||
|
case 'rename':
|
||||||
|
return this.filesService.rename(
|
||||||
|
licenseId,
|
||||||
|
body.path,
|
||||||
|
body.item,
|
||||||
|
body.name,
|
||||||
|
);
|
||||||
|
case 'move':
|
||||||
|
return this.filesService.move(
|
||||||
|
licenseId,
|
||||||
|
body.path,
|
||||||
|
body.items,
|
||||||
|
body.destination,
|
||||||
|
);
|
||||||
|
case 'copy':
|
||||||
|
return this.filesService.copy(
|
||||||
|
licenseId,
|
||||||
|
body.path,
|
||||||
|
body.items,
|
||||||
|
body.destination,
|
||||||
|
);
|
||||||
|
case 'new-folder':
|
||||||
|
case 'mkdir':
|
||||||
|
return this.filesService.createFolder(licenseId, body.path, body.name);
|
||||||
|
case 'new-file':
|
||||||
|
case 'newfile':
|
||||||
|
return this.filesService.createFile(licenseId, body.path, body.name);
|
||||||
|
case 'save':
|
||||||
|
return this.filesService.save(licenseId, body.path, body.content);
|
||||||
|
case 'upload':
|
||||||
|
if (!file) {
|
||||||
|
throw new HttpException('No file provided', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
return this.filesService.upload(licenseId, body.path, file);
|
||||||
|
default:
|
||||||
|
throw new HttpException(
|
||||||
|
`Unknown operation: ${operation}`,
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend-nest/src/modules/files/files.module.ts
Normal file
10
backend-nest/src/modules/files/files.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { FilesController } from './files.controller';
|
||||||
|
import { FilesService } from './files.service';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [FilesController],
|
||||||
|
providers: [FilesService, NatsService],
|
||||||
|
})
|
||||||
|
export class FilesModule {}
|
||||||
176
backend-nest/src/modules/files/files.service.ts
Normal file
176
backend-nest/src/modules/files/files.service.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
|
interface AgentFileResponse {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadResult {
|
||||||
|
filename: string;
|
||||||
|
content: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FilesService {
|
||||||
|
private readonly logger = new Logger(FilesService.name);
|
||||||
|
|
||||||
|
constructor(private readonly natsService: NatsService) {}
|
||||||
|
|
||||||
|
// Send a file manager command to the companion agent via NATS request-reply.
|
||||||
|
// The agent returns { success: bool, error?: string, data?: VueFinderResponse }.
|
||||||
|
private async sendFileCommand(
|
||||||
|
licenseId: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const subject = `corrosion.${licenseId}.files.cmd`;
|
||||||
|
try {
|
||||||
|
const response = (await this.natsService.request(
|
||||||
|
subject,
|
||||||
|
payload,
|
||||||
|
30000,
|
||||||
|
)) as AgentFileResponse | null;
|
||||||
|
|
||||||
|
// Offline mode — agent unreachable, return null rather than crashing
|
||||||
|
if (response === null) {
|
||||||
|
throw new HttpException(
|
||||||
|
'Agent not reachable (offline mode)',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new HttpException(
|
||||||
|
response.error || 'File operation failed',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
this.logger.error(
|
||||||
|
`File command failed on subject ${subject}: ${(error as Error).message}`,
|
||||||
|
);
|
||||||
|
throw new HttpException(
|
||||||
|
'Agent not reachable or timed out',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(licenseId: string, path: string): Promise<unknown> {
|
||||||
|
return this.sendFileCommand(licenseId, { func: 'fm_list', path });
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(
|
||||||
|
licenseId: string,
|
||||||
|
path: string,
|
||||||
|
filter: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.sendFileCommand(licenseId, { func: 'fm_search', path, filter });
|
||||||
|
}
|
||||||
|
|
||||||
|
async preview(licenseId: string, path: string): Promise<unknown> {
|
||||||
|
return this.sendFileCommand(licenseId, { func: 'fm_preview', path });
|
||||||
|
}
|
||||||
|
|
||||||
|
async download(licenseId: string, path: string): Promise<DownloadResult> {
|
||||||
|
const result = await this.sendFileCommand(licenseId, {
|
||||||
|
func: 'fm_preview',
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
const basename = path.split('/').pop() || 'download';
|
||||||
|
return { filename: basename, content: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(
|
||||||
|
licenseId: string,
|
||||||
|
path: string,
|
||||||
|
items: string[],
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.sendFileCommand(licenseId, { func: 'fm_delete', path, items });
|
||||||
|
}
|
||||||
|
|
||||||
|
async rename(
|
||||||
|
licenseId: string,
|
||||||
|
path: string,
|
||||||
|
item: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.sendFileCommand(licenseId, {
|
||||||
|
func: 'fm_rename',
|
||||||
|
path,
|
||||||
|
items: [item],
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async move(
|
||||||
|
licenseId: string,
|
||||||
|
path: string,
|
||||||
|
items: string[],
|
||||||
|
destination: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.sendFileCommand(licenseId, {
|
||||||
|
func: 'fm_move',
|
||||||
|
path,
|
||||||
|
items,
|
||||||
|
destination,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async copy(
|
||||||
|
licenseId: string,
|
||||||
|
path: string,
|
||||||
|
items: string[],
|
||||||
|
destination: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.sendFileCommand(licenseId, {
|
||||||
|
func: 'fm_copy',
|
||||||
|
path,
|
||||||
|
items,
|
||||||
|
destination,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFolder(
|
||||||
|
licenseId: string,
|
||||||
|
path: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.sendFileCommand(licenseId, { func: 'fm_mkdir', path, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFile(
|
||||||
|
licenseId: string,
|
||||||
|
path: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.sendFileCommand(licenseId, { func: 'fm_mkfile', path, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(
|
||||||
|
licenseId: string,
|
||||||
|
path: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.sendFileCommand(licenseId, { func: 'fm_save', path, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(
|
||||||
|
licenseId: string,
|
||||||
|
path: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
): Promise<unknown> {
|
||||||
|
// Encode binary content as base64 so it survives JSON serialization over NATS
|
||||||
|
const content = file.buffer.toString('base64');
|
||||||
|
return this.sendFileCommand(licenseId, {
|
||||||
|
func: 'fm_upload',
|
||||||
|
path,
|
||||||
|
filename: file.originalname,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend-nest/src/modules/fleet/fleet.controller.ts
Normal file
26
backend-nest/src/modules/fleet/fleet.controller.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Controller, Get, Delete, Param } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { FleetService } from './fleet.service';
|
||||||
|
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||||
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
|
|
||||||
|
@ApiTags('fleet')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('fleet')
|
||||||
|
export class FleetController {
|
||||||
|
constructor(private readonly fleetService: FleetService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('server.view')
|
||||||
|
@ApiOperation({ summary: 'Get fleet overview — hosts and game instances for this license' })
|
||||||
|
async getFleet(@CurrentTenant() licenseId: string) {
|
||||||
|
return this.fleetService.getFleet(licenseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('hosts/:id')
|
||||||
|
@RequirePermission('server.manage')
|
||||||
|
@ApiOperation({ summary: 'Remove a host and its instances (host must be offline)' })
|
||||||
|
async deleteHost(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.fleetService.deleteHost(licenseId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend-nest/src/modules/fleet/fleet.module.ts
Normal file
15
backend-nest/src/modules/fleet/fleet.module.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { FleetController } from './fleet.controller';
|
||||||
|
import { FleetService } from './fleet.service';
|
||||||
|
import { AgentHost } from '../../entities/agent-host.entity';
|
||||||
|
import { GameInstance } from '../../entities/game-instance.entity';
|
||||||
|
import { ServerConnection } from '../../entities/server-connection.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([AgentHost, GameInstance, ServerConnection])],
|
||||||
|
controllers: [FleetController],
|
||||||
|
providers: [FleetService],
|
||||||
|
exports: [FleetService],
|
||||||
|
})
|
||||||
|
export class FleetModule {}
|
||||||
170
backend-nest/src/modules/fleet/fleet.service.ts
Normal file
170
backend-nest/src/modules/fleet/fleet.service.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AgentHost } from '../../entities/agent-host.entity';
|
||||||
|
import { GameInstance } from '../../entities/game-instance.entity';
|
||||||
|
import { ServerConnection } from '../../entities/server-connection.entity';
|
||||||
|
|
||||||
|
export interface FleetInstanceDto {
|
||||||
|
id: string;
|
||||||
|
agent_instance_id: string;
|
||||||
|
game: string;
|
||||||
|
label: string | null;
|
||||||
|
state: string;
|
||||||
|
uptime_seconds: number;
|
||||||
|
last_seen_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetHostDto {
|
||||||
|
id: string;
|
||||||
|
hostname: string;
|
||||||
|
status: string;
|
||||||
|
agent_version: string | null;
|
||||||
|
os: string | null;
|
||||||
|
arch: string | null;
|
||||||
|
cpu_percent: number | null;
|
||||||
|
cpu_cores: number | null;
|
||||||
|
mem_total_mb: number | null;
|
||||||
|
mem_used_mb: number | null;
|
||||||
|
uptime_seconds: number | null;
|
||||||
|
disks: AgentHost['disks'];
|
||||||
|
last_heartbeat_at: string | null;
|
||||||
|
instances: FleetInstanceDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetSummaryDto {
|
||||||
|
host_count: number;
|
||||||
|
instance_count: number;
|
||||||
|
online_host_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetResponseDto {
|
||||||
|
hosts: FleetHostDto[];
|
||||||
|
summary: FleetSummaryDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FleetService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AgentHost)
|
||||||
|
private readonly hostRepo: Repository<AgentHost>,
|
||||||
|
@InjectRepository(GameInstance)
|
||||||
|
private readonly instanceRepo: Repository<GameInstance>,
|
||||||
|
@InjectRepository(ServerConnection)
|
||||||
|
private readonly connectionRepo: Repository<ServerConnection>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a host and its game instances from the fleet.
|
||||||
|
*
|
||||||
|
* Refuses while the host is `connected` — a live agent re-registers on its
|
||||||
|
* next heartbeat, so the operator must stop the agent first. Deletes the
|
||||||
|
* host's instances explicitly (the FK is SET NULL, which would otherwise
|
||||||
|
* orphan them); instance_stats cascade. If this was the license's last host,
|
||||||
|
* the legacy single-server connection row is cleared too so the old
|
||||||
|
* Dashboard doesn't show a stale server.
|
||||||
|
*/
|
||||||
|
async deleteHost(
|
||||||
|
licenseId: string,
|
||||||
|
hostId: string,
|
||||||
|
): Promise<{ deleted: true; instances_removed: number }> {
|
||||||
|
const host = await this.hostRepo.findOne({ where: { id: hostId, license_id: licenseId } });
|
||||||
|
if (!host) throw new NotFoundException('Host not found');
|
||||||
|
if (host.status === 'connected') {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Host is online — stop the agent first, or it will re-register on its next heartbeat',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const del = await this.instanceRepo.delete({ license_id: licenseId, host_id: hostId });
|
||||||
|
await this.hostRepo.delete({ id: hostId, license_id: licenseId });
|
||||||
|
|
||||||
|
const remaining = await this.hostRepo.count({ where: { license_id: licenseId } });
|
||||||
|
if (remaining === 0) {
|
||||||
|
await this.connectionRepo.delete({ license_id: licenseId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deleted: true, instances_removed: del.affected ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFleet(licenseId: string): Promise<FleetResponseDto> {
|
||||||
|
const [hosts, instances] = await Promise.all([
|
||||||
|
this.hostRepo.find({
|
||||||
|
where: { license_id: licenseId },
|
||||||
|
order: { hostname: 'ASC' },
|
||||||
|
}),
|
||||||
|
this.instanceRepo.find({
|
||||||
|
where: { license_id: licenseId },
|
||||||
|
order: { game: 'ASC', label: 'ASC' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Group instances by host_id. Bigint columns come back as strings from pg — coerce.
|
||||||
|
const instancesByHost = new Map<string | null, FleetInstanceDto[]>();
|
||||||
|
for (const inst of instances) {
|
||||||
|
const key = inst.host_id ?? null;
|
||||||
|
if (!instancesByHost.has(key)) {
|
||||||
|
instancesByHost.set(key, []);
|
||||||
|
}
|
||||||
|
instancesByHost.get(key)!.push({
|
||||||
|
id: inst.id,
|
||||||
|
agent_instance_id: inst.agent_instance_id,
|
||||||
|
game: inst.game,
|
||||||
|
label: inst.label,
|
||||||
|
state: inst.state,
|
||||||
|
uptime_seconds: Number(inst.uptime_seconds),
|
||||||
|
last_seen_at: inst.last_seen_at ? inst.last_seen_at.toISOString() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostDtos: FleetHostDto[] = hosts.map((h) => ({
|
||||||
|
id: h.id,
|
||||||
|
hostname: h.hostname,
|
||||||
|
status: h.status,
|
||||||
|
agent_version: h.agent_version,
|
||||||
|
os: h.os,
|
||||||
|
arch: h.arch,
|
||||||
|
cpu_percent: h.cpu_percent !== null && h.cpu_percent !== undefined ? Number(h.cpu_percent) : null,
|
||||||
|
cpu_cores: h.cpu_cores !== null && h.cpu_cores !== undefined ? Number(h.cpu_cores) : null,
|
||||||
|
mem_total_mb: h.mem_total_mb !== null && h.mem_total_mb !== undefined ? Number(h.mem_total_mb) : null,
|
||||||
|
mem_used_mb: h.mem_used_mb !== null && h.mem_used_mb !== undefined ? Number(h.mem_used_mb) : null,
|
||||||
|
uptime_seconds: h.uptime_seconds !== null && h.uptime_seconds !== undefined ? Number(h.uptime_seconds) : null,
|
||||||
|
disks: h.disks,
|
||||||
|
last_heartbeat_at: h.last_heartbeat_at ? h.last_heartbeat_at.toISOString() : null,
|
||||||
|
instances: instancesByHost.get(h.id) ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Append synthetic "unassigned" bucket only if orphaned instances exist
|
||||||
|
const unassigned = instancesByHost.get(null) ?? [];
|
||||||
|
if (unassigned.length > 0) {
|
||||||
|
hostDtos.push({
|
||||||
|
id: '__unassigned__',
|
||||||
|
hostname: 'Unassigned',
|
||||||
|
status: 'offline',
|
||||||
|
agent_version: null,
|
||||||
|
os: null,
|
||||||
|
arch: null,
|
||||||
|
cpu_percent: null,
|
||||||
|
cpu_cores: null,
|
||||||
|
mem_total_mb: null,
|
||||||
|
mem_used_mb: null,
|
||||||
|
uptime_seconds: null,
|
||||||
|
disks: null,
|
||||||
|
last_heartbeat_at: null,
|
||||||
|
instances: unassigned,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const online_host_count = hosts.filter((h) => h.status === 'connected').length;
|
||||||
|
const instance_count = instances.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hosts: hostDtos,
|
||||||
|
summary: {
|
||||||
|
host_count: hosts.length,
|
||||||
|
instance_count,
|
||||||
|
online_host_count,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateFurnaceSplitterConfigDto {
|
||||||
|
@ApiProperty({ example: 'Default FurnaceSplitter' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Standard furnace splitter settings' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
config_data?: Record<string, any>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ImportFurnaceSplitterConfigDto {
|
||||||
|
@ApiProperty({ example: 'Server Import' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Imported from live server' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class UpdateFurnaceSplitterConfigDto {
|
||||||
|
@ApiPropertyOptional({ example: 'Updated FurnaceSplitter' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
@IsOptional()
|
||||||
|
config_name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Updated description' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
config_data?: Record<string, any>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { FurnaceSplitterService } from './furnacesplitter.service';
|
||||||
|
import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto';
|
||||||
|
import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto';
|
||||||
|
import { ImportFurnaceSplitterConfigDto } from './dto/import-furnacesplitter-config.dto';
|
||||||
|
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||||
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||||
|
|
||||||
|
@ApiTags('furnacesplitter')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('furnacesplitter')
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||||
|
export class FurnaceSplitterController {
|
||||||
|
constructor(private readonly furnaceSplitterService: FurnaceSplitterService) {}
|
||||||
|
|
||||||
|
@Get('configs')
|
||||||
|
@RequirePermission('furnacesplitter.view')
|
||||||
|
@ApiOperation({ summary: 'List furnace splitter configs (summaries)' })
|
||||||
|
getConfigs(@CurrentTenant() licenseId: string) {
|
||||||
|
return this.furnaceSplitterService.getConfigs(licenseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('configs/:id')
|
||||||
|
@RequirePermission('furnacesplitter.view')
|
||||||
|
@ApiOperation({ summary: 'Get full furnace splitter config with data' })
|
||||||
|
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.furnaceSplitterService.getConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs')
|
||||||
|
@RequirePermission('furnacesplitter.manage')
|
||||||
|
@ApiOperation({ summary: 'Create furnace splitter config' })
|
||||||
|
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateFurnaceSplitterConfigDto) {
|
||||||
|
return this.furnaceSplitterService.createConfig(licenseId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('configs/:id')
|
||||||
|
@RequirePermission('furnacesplitter.manage')
|
||||||
|
@ApiOperation({ summary: 'Update furnace splitter config' })
|
||||||
|
updateConfig(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateFurnaceSplitterConfigDto,
|
||||||
|
) {
|
||||||
|
return this.furnaceSplitterService.updateConfig(licenseId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('configs/:id')
|
||||||
|
@RequirePermission('furnacesplitter.manage')
|
||||||
|
@ApiOperation({ summary: 'Delete furnace splitter config' })
|
||||||
|
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.furnaceSplitterService.deleteConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs/:id/apply')
|
||||||
|
@RequirePermission('furnacesplitter.manage')
|
||||||
|
@ApiOperation({ summary: 'Deploy furnace splitter config to server' })
|
||||||
|
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.furnaceSplitterService.applyToServer(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('import-from-server')
|
||||||
|
@RequirePermission('furnacesplitter.manage')
|
||||||
|
@ApiOperation({ summary: 'Import FurnaceSplitter.json from server via NATS' })
|
||||||
|
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportFurnaceSplitterConfigDto) {
|
||||||
|
return this.furnaceSplitterService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { FurnaceSplitterController } from './furnacesplitter.controller';
|
||||||
|
import { FurnaceSplitterService } from './furnacesplitter.service';
|
||||||
|
import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([FurnaceSplitterConfig])],
|
||||||
|
controllers: [FurnaceSplitterController],
|
||||||
|
providers: [FurnaceSplitterService, NatsService],
|
||||||
|
exports: [FurnaceSplitterService],
|
||||||
|
})
|
||||||
|
export class FurnaceSplitterModule {}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity';
|
||||||
|
import { InstancesService } from '../instances/instances.service';
|
||||||
|
import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto';
|
||||||
|
import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FurnaceSplitterService {
|
||||||
|
private readonly logger = new Logger(FurnaceSplitterService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(FurnaceSplitterConfig)
|
||||||
|
private readonly furnaceRepo: Repository<FurnaceSplitterConfig>,
|
||||||
|
private readonly instancesService: InstancesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** List configs for a license (summaries — no JSONB) */
|
||||||
|
async getConfigs(licenseId: string) {
|
||||||
|
const configs = await this.furnaceRepo.find({
|
||||||
|
where: { license_id: licenseId },
|
||||||
|
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
|
||||||
|
order: { created_at: 'DESC' },
|
||||||
|
});
|
||||||
|
return { configs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get full config with JSONB data */
|
||||||
|
async getConfig(licenseId: string, configId: string) {
|
||||||
|
const config = await this.furnaceRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('FurnaceSplitter config not found');
|
||||||
|
return { config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new config */
|
||||||
|
async createConfig(licenseId: string, dto: CreateFurnaceSplitterConfigDto) {
|
||||||
|
const config = this.furnaceRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: dto.config_name,
|
||||||
|
description: dto.description || null,
|
||||||
|
config_data: dto.config_data || {},
|
||||||
|
});
|
||||||
|
const saved = await this.furnaceRepo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update an existing config */
|
||||||
|
async updateConfig(licenseId: string, configId: string, dto: UpdateFurnaceSplitterConfigDto) {
|
||||||
|
const config = await this.furnaceRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('FurnaceSplitter config not found');
|
||||||
|
|
||||||
|
if (dto.config_name !== undefined) config.config_name = dto.config_name;
|
||||||
|
if (dto.description !== undefined) config.description = dto.description;
|
||||||
|
if (dto.config_data !== undefined) config.config_data = dto.config_data;
|
||||||
|
if (dto.is_active !== undefined) config.is_active = dto.is_active;
|
||||||
|
config.updated_at = new Date();
|
||||||
|
|
||||||
|
const saved = await this.furnaceRepo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a config */
|
||||||
|
async deleteConfig(licenseId: string, configId: string) {
|
||||||
|
const result = await this.furnaceRepo.delete({ id: configId, license_id: licenseId });
|
||||||
|
if (result.affected === 0) throw new NotFoundException('FurnaceSplitter config not found');
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deploy config to game server via NATS */
|
||||||
|
async applyToServer(licenseId: string, configId: string) {
|
||||||
|
const config = await this.furnaceRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('FurnaceSplitter config not found');
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Write FurnaceSplitter.json via Rust agent
|
||||||
|
await this.instancesService.writeFileForLicense(
|
||||||
|
licenseId,
|
||||||
|
'oxide/config/FurnaceSplitter.json',
|
||||||
|
jsonString,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reload FurnaceSplitter plugin via RCON
|
||||||
|
await this.instancesService.rconForLicense(licenseId, 'oxide.reload FurnaceSplitter');
|
||||||
|
|
||||||
|
// Mark this config as active, deactivate others
|
||||||
|
await this.furnaceRepo.update({ license_id: licenseId }, { is_active: false });
|
||||||
|
await this.furnaceRepo.update(
|
||||||
|
{ id: configId, license_id: licenseId },
|
||||||
|
{ is_active: true, updated_at: new Date() },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Config "${config.config_name}" deployed to server`,
|
||||||
|
config_name: config.config_name,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to deploy furnace splitter config: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to deploy furnace splitter config — agent may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import FurnaceSplitter.json from game server via NATS */
|
||||||
|
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||||
|
try {
|
||||||
|
// Read FurnaceSplitter.json from server via Rust agent
|
||||||
|
const result = await this.instancesService.readFileForLicense(
|
||||||
|
licenseId,
|
||||||
|
'oxide/config/FurnaceSplitter.json',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new HttpException(
|
||||||
|
'No response from agent — it may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the response content as JSON
|
||||||
|
const responseData = (result as any).content;
|
||||||
|
let configData: Record<string, any>;
|
||||||
|
|
||||||
|
if (typeof responseData === 'string') {
|
||||||
|
configData = JSON.parse(responseData);
|
||||||
|
} else if (typeof responseData === 'object') {
|
||||||
|
configData = responseData;
|
||||||
|
} else {
|
||||||
|
throw new HttpException(
|
||||||
|
'Unexpected response format from agent',
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new furnace splitter config row
|
||||||
|
const config = this.furnaceRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: configName,
|
||||||
|
description: description || 'Imported from server',
|
||||||
|
config_data: configData,
|
||||||
|
});
|
||||||
|
const saved = await this.furnaceRepo.save(config);
|
||||||
|
|
||||||
|
return { config: saved };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
this.logger.error(`Failed to import furnace splitter config from server: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to import furnace splitter config — agent may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateGatherConfigDto {
|
||||||
|
@ApiProperty({ example: 'Default 2x Rates' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Standard 2x gather rates' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
config_data?: Record<string, any>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ImportGatherConfigDto {
|
||||||
|
@ApiProperty({ example: 'Server Import' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Imported from live server' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class UpdateGatherConfigDto {
|
||||||
|
@ApiPropertyOptional({ example: 'Updated Rates' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
@IsOptional()
|
||||||
|
config_name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Updated description' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
config_data?: Record<string, any>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
80
backend-nest/src/modules/gather/gather.controller.ts
Normal file
80
backend-nest/src/modules/gather/gather.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { GatherService } from './gather.service';
|
||||||
|
import { CreateGatherConfigDto } from './dto/create-gather-config.dto';
|
||||||
|
import { UpdateGatherConfigDto } from './dto/update-gather-config.dto';
|
||||||
|
import { ImportGatherConfigDto } from './dto/import-gather-config.dto';
|
||||||
|
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||||
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||||
|
|
||||||
|
@ApiTags('gather')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('gather')
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||||
|
export class GatherController {
|
||||||
|
constructor(private readonly gatherService: GatherService) {}
|
||||||
|
|
||||||
|
@Get('configs')
|
||||||
|
@RequirePermission('gather.view')
|
||||||
|
@ApiOperation({ summary: 'List gather configs' })
|
||||||
|
getConfigs(@CurrentTenant() licenseId: string) {
|
||||||
|
return this.gatherService.getConfigs(licenseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('configs/:id')
|
||||||
|
@RequirePermission('gather.view')
|
||||||
|
@ApiOperation({ summary: 'Get full gather config' })
|
||||||
|
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.gatherService.getConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs')
|
||||||
|
@RequirePermission('gather.manage')
|
||||||
|
@ApiOperation({ summary: 'Create gather config' })
|
||||||
|
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateGatherConfigDto) {
|
||||||
|
return this.gatherService.createConfig(licenseId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('configs/:id')
|
||||||
|
@RequirePermission('gather.manage')
|
||||||
|
@ApiOperation({ summary: 'Update gather config' })
|
||||||
|
updateConfig(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateGatherConfigDto,
|
||||||
|
) {
|
||||||
|
return this.gatherService.updateConfig(licenseId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('configs/:id')
|
||||||
|
@RequirePermission('gather.manage')
|
||||||
|
@ApiOperation({ summary: 'Delete gather config' })
|
||||||
|
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.gatherService.deleteConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs/:id/apply')
|
||||||
|
@RequirePermission('gather.manage')
|
||||||
|
@ApiOperation({ summary: 'Deploy gather config to server' })
|
||||||
|
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.gatherService.applyToServer(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('import-from-server')
|
||||||
|
@RequirePermission('gather.manage')
|
||||||
|
@ApiOperation({ summary: 'Import GatherManager.json from server' })
|
||||||
|
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportGatherConfigDto) {
|
||||||
|
return this.gatherService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend-nest/src/modules/gather/gather.module.ts
Normal file
14
backend-nest/src/modules/gather/gather.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { GatherController } from './gather.controller';
|
||||||
|
import { GatherService } from './gather.service';
|
||||||
|
import { GatherConfig } from '../../entities/gather-config.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([GatherConfig])],
|
||||||
|
controllers: [GatherController],
|
||||||
|
providers: [GatherService, NatsService],
|
||||||
|
exports: [GatherService],
|
||||||
|
})
|
||||||
|
export class GatherModule {}
|
||||||
165
backend-nest/src/modules/gather/gather.service.ts
Normal file
165
backend-nest/src/modules/gather/gather.service.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { GatherConfig } from '../../entities/gather-config.entity';
|
||||||
|
import { InstancesService } from '../instances/instances.service';
|
||||||
|
import { CreateGatherConfigDto } from './dto/create-gather-config.dto';
|
||||||
|
import { UpdateGatherConfigDto } from './dto/update-gather-config.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GatherService {
|
||||||
|
private readonly logger = new Logger(GatherService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(GatherConfig)
|
||||||
|
private readonly gatherRepo: Repository<GatherConfig>,
|
||||||
|
private readonly instancesService: InstancesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** List configs for a license (summaries — no JSONB) */
|
||||||
|
async getConfigs(licenseId: string) {
|
||||||
|
const configs = await this.gatherRepo.find({
|
||||||
|
where: { license_id: licenseId },
|
||||||
|
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
|
||||||
|
order: { created_at: 'DESC' },
|
||||||
|
});
|
||||||
|
return { configs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get full config with JSONB data */
|
||||||
|
async getConfig(licenseId: string, configId: string) {
|
||||||
|
const config = await this.gatherRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('Gather config not found');
|
||||||
|
return { config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new config */
|
||||||
|
async createConfig(licenseId: string, dto: CreateGatherConfigDto) {
|
||||||
|
const config = this.gatherRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: dto.config_name,
|
||||||
|
description: dto.description || null,
|
||||||
|
config_data: dto.config_data || {},
|
||||||
|
});
|
||||||
|
const saved = await this.gatherRepo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update an existing config */
|
||||||
|
async updateConfig(licenseId: string, configId: string, dto: UpdateGatherConfigDto) {
|
||||||
|
const config = await this.gatherRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('Gather config not found');
|
||||||
|
|
||||||
|
if (dto.config_name !== undefined) config.config_name = dto.config_name;
|
||||||
|
if (dto.description !== undefined) config.description = dto.description;
|
||||||
|
if (dto.config_data !== undefined) config.config_data = dto.config_data;
|
||||||
|
if (dto.is_active !== undefined) config.is_active = dto.is_active;
|
||||||
|
config.updated_at = new Date();
|
||||||
|
|
||||||
|
const saved = await this.gatherRepo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a config */
|
||||||
|
async deleteConfig(licenseId: string, configId: string) {
|
||||||
|
const result = await this.gatherRepo.delete({ id: configId, license_id: licenseId });
|
||||||
|
if (result.affected === 0) throw new NotFoundException('Gather config not found');
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deploy config to game server via NATS */
|
||||||
|
async applyToServer(licenseId: string, configId: string) {
|
||||||
|
const config = await this.gatherRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('Gather config not found');
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Write GatherManager.json via Rust agent
|
||||||
|
await this.instancesService.writeFileForLicense(
|
||||||
|
licenseId,
|
||||||
|
'oxide/config/GatherManager.json',
|
||||||
|
jsonString,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reload GatherManager plugin via RCON
|
||||||
|
await this.instancesService.rconForLicense(licenseId, 'oxide.reload GatherManager');
|
||||||
|
|
||||||
|
// Mark this config as active, deactivate others
|
||||||
|
await this.gatherRepo.update({ license_id: licenseId }, { is_active: false });
|
||||||
|
await this.gatherRepo.update(
|
||||||
|
{ id: configId, license_id: licenseId },
|
||||||
|
{ is_active: true, updated_at: new Date() },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Config "${config.config_name}" deployed to server`,
|
||||||
|
config_name: config.config_name,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to deploy gather config: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to deploy gather config — agent may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import GatherManager.json from game server via NATS */
|
||||||
|
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||||
|
try {
|
||||||
|
// Read GatherManager.json from server via Rust agent
|
||||||
|
const result = await this.instancesService.readFileForLicense(
|
||||||
|
licenseId,
|
||||||
|
'oxide/config/GatherManager.json',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new HttpException(
|
||||||
|
'No response from agent — it may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the response content as JSON
|
||||||
|
const responseData = (result as any).content;
|
||||||
|
let configData: Record<string, any>;
|
||||||
|
|
||||||
|
if (typeof responseData === 'string') {
|
||||||
|
configData = JSON.parse(responseData);
|
||||||
|
} else if (typeof responseData === 'object') {
|
||||||
|
configData = responseData;
|
||||||
|
} else {
|
||||||
|
throw new HttpException(
|
||||||
|
'Unexpected response format from agent',
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new gather config row
|
||||||
|
const config = this.gatherRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: configName,
|
||||||
|
description: description || 'Imported from server',
|
||||||
|
config_data: configData,
|
||||||
|
});
|
||||||
|
const saved = await this.gatherRepo.save(config);
|
||||||
|
|
||||||
|
return { config: saved };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
this.logger.error(`Failed to import gather config from server: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to import gather config — agent may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
133
backend-nest/src/modules/instances/instances.controller.ts
Normal file
133
backend-nest/src/modules/instances/instances.controller.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { Controller, Post, Get, Put, Body, Param, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||||
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
|
import { InstancesService, LifecycleFunc } from './instances.service';
|
||||||
|
|
||||||
|
@ApiTags('instances')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('instances')
|
||||||
|
export class InstancesController {
|
||||||
|
constructor(private readonly instances: InstancesService) {}
|
||||||
|
|
||||||
|
@Post(':id/lifecycle')
|
||||||
|
@RequirePermission('server.manage')
|
||||||
|
@ApiOperation({ summary: 'Send a lifecycle command to a game instance (start/stop/restart/status/steam_update)' })
|
||||||
|
async lifecycle(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { action: LifecycleFunc },
|
||||||
|
) {
|
||||||
|
return this.instances.lifecycle(licenseId, id, body.action);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/rcon')
|
||||||
|
@RequirePermission('server.console')
|
||||||
|
@ApiOperation({ summary: 'Send an RCON/console command to a game instance' })
|
||||||
|
async rcon(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { command: string },
|
||||||
|
) {
|
||||||
|
return this.instances.rcon(licenseId, id, body.command);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/files')
|
||||||
|
@RequirePermission('files.view')
|
||||||
|
@ApiOperation({ summary: 'List a directory in the instance (jailed to its root)' })
|
||||||
|
async listFiles(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('path') path?: string,
|
||||||
|
) {
|
||||||
|
return this.instances.listFiles(licenseId, id, path ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/file')
|
||||||
|
@RequirePermission('files.view')
|
||||||
|
@ApiOperation({ summary: 'Read a text file from the instance (jailed, 5 MiB cap)' })
|
||||||
|
async readFile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('path') path: string,
|
||||||
|
) {
|
||||||
|
return this.instances.readFile(licenseId, id, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/file')
|
||||||
|
@RequirePermission('files.manage')
|
||||||
|
@ApiOperation({ summary: 'Write a text file in the instance (jailed)' })
|
||||||
|
async writeFile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { path: string; content: string },
|
||||||
|
) {
|
||||||
|
return this.instances.writeFile(licenseId, id, body.path, body.content ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/files/delete')
|
||||||
|
@RequirePermission('files.manage')
|
||||||
|
@ApiOperation({ summary: 'Delete a file or directory (jailed)' })
|
||||||
|
async deleteFile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { path: string },
|
||||||
|
) {
|
||||||
|
return this.instances.deleteFile(licenseId, id, body.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/files/rename')
|
||||||
|
@RequirePermission('files.manage')
|
||||||
|
@ApiOperation({ summary: 'Rename a file/directory within its parent (jailed)' })
|
||||||
|
async renameFile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { path: string; name: string },
|
||||||
|
) {
|
||||||
|
return this.instances.renameFile(licenseId, id, body.path, body.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/files/mkdir')
|
||||||
|
@RequirePermission('files.manage')
|
||||||
|
@ApiOperation({ summary: 'Create a directory (jailed)' })
|
||||||
|
async mkdir(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { path: string },
|
||||||
|
) {
|
||||||
|
return this.instances.mkdir(licenseId, id, body.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/files/mkfile')
|
||||||
|
@RequirePermission('files.manage')
|
||||||
|
@ApiOperation({ summary: 'Create an empty file (jailed)' })
|
||||||
|
async mkfile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { path: string },
|
||||||
|
) {
|
||||||
|
return this.instances.mkfile(licenseId, id, body.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/files/move')
|
||||||
|
@RequirePermission('files.manage')
|
||||||
|
@ApiOperation({ summary: 'Move a file/directory (jailed)' })
|
||||||
|
async moveFile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { path: string; dest: string },
|
||||||
|
) {
|
||||||
|
return this.instances.moveFile(licenseId, id, body.path, body.dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/files/copy')
|
||||||
|
@RequirePermission('files.manage')
|
||||||
|
@ApiOperation({ summary: 'Copy a file/directory (jailed)' })
|
||||||
|
async copyFile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { path: string; dest: string },
|
||||||
|
) {
|
||||||
|
return this.instances.copyFile(licenseId, id, body.path, body.dest);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend-nest/src/modules/instances/instances.module.ts
Normal file
18
backend-nest/src/modules/instances/instances.module.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { InstancesController } from './instances.controller';
|
||||||
|
import { InstancesService } from './instances.service';
|
||||||
|
import { GameInstance } from '../../entities/game-instance.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
|
// Global so the legacy single-server services (servers/players/schedules/wipes/
|
||||||
|
// plugins + the 9 plugin-config modules) can inject InstancesService to route
|
||||||
|
// commands at the now-only Rust agent without each importing this module.
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([GameInstance])],
|
||||||
|
controllers: [InstancesController],
|
||||||
|
providers: [InstancesService, NatsService],
|
||||||
|
exports: [InstancesService],
|
||||||
|
})
|
||||||
|
export class InstancesModule {}
|
||||||
223
backend-nest/src/modules/instances/instances.service.ts
Normal file
223
backend-nest/src/modules/instances/instances.service.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
import { GameInstance } from '../../entities/game-instance.entity';
|
||||||
|
|
||||||
|
/** Lifecycle funcs the agent's {instance}.cmd handler accepts. */
|
||||||
|
const LIFECYCLE_FUNCS = ['start', 'stop', 'restart', 'status', 'steam_update'] as const;
|
||||||
|
export type LifecycleFunc = (typeof LIFECYCLE_FUNCS)[number];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InstancesService {
|
||||||
|
private readonly logger = new Logger(InstancesService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly nats: NatsService,
|
||||||
|
@InjectRepository(GameInstance)
|
||||||
|
private readonly instanceRepo: Repository<GameInstance>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Resolve an instance the caller's license actually owns (tenant guard). */
|
||||||
|
private async resolveInstance(licenseId: string, instanceId: string): Promise<GameInstance> {
|
||||||
|
const inst = await this.instanceRepo.findOne({
|
||||||
|
where: { id: instanceId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!inst) throw new NotFoundException('Instance not found');
|
||||||
|
return inst;
|
||||||
|
}
|
||||||
|
|
||||||
|
async lifecycle(licenseId: string, instanceId: string, func: LifecycleFunc): Promise<unknown> {
|
||||||
|
if (!LIFECYCLE_FUNCS.includes(func)) {
|
||||||
|
throw new BadRequestException(`Unsupported action '${func}'`);
|
||||||
|
}
|
||||||
|
const inst = await this.resolveInstance(licenseId, instanceId);
|
||||||
|
const subject = `corrosion.${licenseId}.${inst.agent_instance_id}.cmd`;
|
||||||
|
this.logger.log(`instance ${inst.agent_instance_id}: ${func}`);
|
||||||
|
return this.nats.requestScoped(licenseId, subject, { func });
|
||||||
|
}
|
||||||
|
|
||||||
|
async rcon(licenseId: string, instanceId: string, command: string): Promise<unknown> {
|
||||||
|
if (!command || !command.trim()) {
|
||||||
|
throw new BadRequestException('command is required');
|
||||||
|
}
|
||||||
|
const inst = await this.resolveInstance(licenseId, instanceId);
|
||||||
|
const subject = `corrosion.${licenseId}.${inst.agent_instance_id}.cmd`;
|
||||||
|
// RCON can take longer than a lifecycle ack — give it more headroom.
|
||||||
|
return this.nats.requestScoped(licenseId, subject, { func: 'rcon', command }, 12_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// File access — jailed to the instance root by the agent's file manager.
|
||||||
|
// The agent protocol (corrosion-host-agent/src/filemanager.rs):
|
||||||
|
// { op: list|read|write|delete|rename|mkdir|mkfile|move|copy, path, ... }
|
||||||
|
// reply: { status: 'success'|'error', data?, message? }
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private filesSubject(inst: GameInstance, licenseId: string): string {
|
||||||
|
return `corrosion.${licenseId}.${inst.agent_instance_id}.files.cmd`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fileOp(
|
||||||
|
licenseId: string,
|
||||||
|
instanceId: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<{ status: string; data?: unknown; message?: string }> {
|
||||||
|
const inst = await this.resolveInstance(licenseId, instanceId);
|
||||||
|
const res = await this.nats.requestScoped<{ status: string; data?: unknown; message?: string }>(
|
||||||
|
licenseId,
|
||||||
|
this.filesSubject(inst, licenseId),
|
||||||
|
payload,
|
||||||
|
12_000,
|
||||||
|
);
|
||||||
|
if (res?.status === 'error') {
|
||||||
|
throw new BadRequestException(res.message ?? 'File operation failed');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listFiles(licenseId: string, instanceId: string, path = ''): Promise<unknown> {
|
||||||
|
const res = await this.fileOp(licenseId, instanceId, { op: 'list', path });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(licenseId: string, instanceId: string, path: string): Promise<unknown> {
|
||||||
|
if (!path) throw new BadRequestException('path is required');
|
||||||
|
const res = await this.fileOp(licenseId, instanceId, { op: 'read', path });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(
|
||||||
|
licenseId: string,
|
||||||
|
instanceId: string,
|
||||||
|
path: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (!path) throw new BadRequestException('path is required');
|
||||||
|
const res = await this.fileOp(licenseId, instanceId, { op: 'write', path, content });
|
||||||
|
return res.data ?? { status: 'success' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(licenseId: string, instanceId: string, path: string): Promise<unknown> {
|
||||||
|
if (!path) throw new BadRequestException('path is required');
|
||||||
|
return (await this.fileOp(licenseId, instanceId, { op: 'delete', path })).data ?? { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameFile(
|
||||||
|
licenseId: string,
|
||||||
|
instanceId: string,
|
||||||
|
path: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (!path || !name) throw new BadRequestException('path and name are required');
|
||||||
|
return (await this.fileOp(licenseId, instanceId, { op: 'rename', path, name })).data ?? { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async mkdir(licenseId: string, instanceId: string, path: string): Promise<unknown> {
|
||||||
|
if (!path) throw new BadRequestException('path is required');
|
||||||
|
return (await this.fileOp(licenseId, instanceId, { op: 'mkdir', path })).data ?? { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async mkfile(licenseId: string, instanceId: string, path: string): Promise<unknown> {
|
||||||
|
if (!path) throw new BadRequestException('path is required');
|
||||||
|
return (await this.fileOp(licenseId, instanceId, { op: 'mkfile', path })).data ?? { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveFile(
|
||||||
|
licenseId: string,
|
||||||
|
instanceId: string,
|
||||||
|
path: string,
|
||||||
|
dest: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (!path || !dest) throw new BadRequestException('path and dest are required');
|
||||||
|
return (await this.fileOp(licenseId, instanceId, { op: 'move', path, dest })).data ?? { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyFile(
|
||||||
|
licenseId: string,
|
||||||
|
instanceId: string,
|
||||||
|
path: string,
|
||||||
|
dest: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (!path || !dest) throw new BadRequestException('path and dest are required');
|
||||||
|
return (await this.fileOp(licenseId, instanceId, { op: 'copy', path, dest })).data ?? { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wipe an instance's game data via the agent's jailed wipe handler: stop →
|
||||||
|
* delete files per wipe_type (map/blueprint/full) → restart. Long timeout
|
||||||
|
* because the agent does all three steps before replying.
|
||||||
|
*/
|
||||||
|
async wipe(
|
||||||
|
licenseId: string,
|
||||||
|
instanceId: string,
|
||||||
|
wipeType: 'map' | 'blueprint' | 'full',
|
||||||
|
backup = true,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const inst = await this.resolveInstance(licenseId, instanceId);
|
||||||
|
const subject = `corrosion.${licenseId}.${inst.agent_instance_id}.cmd`;
|
||||||
|
this.logger.log(`instance ${inst.agent_instance_id}: wipe (${wipeType})`);
|
||||||
|
return this.nats.requestScoped(
|
||||||
|
licenseId,
|
||||||
|
subject,
|
||||||
|
{ func: 'wipe', wipe_type: wipeType, backup },
|
||||||
|
120_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// License-scoped convenience wrappers. Legacy single-server services
|
||||||
|
// (servers/players/schedules/wipes/plugins + the 9 plugin-config modules)
|
||||||
|
// predate the instance model and carry only a licenseId. These resolve the
|
||||||
|
// license's primary instance, then dispatch to the agent — replacing the old
|
||||||
|
// publishes to the now-defunct `cmd.server` subject.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The license's primary (oldest) instance. Throws if none is connected. */
|
||||||
|
async resolveDefaultInstance(licenseId: string): Promise<GameInstance> {
|
||||||
|
const inst = await this.instanceRepo.findOne({
|
||||||
|
where: { license_id: licenseId },
|
||||||
|
order: { created_at: 'ASC' },
|
||||||
|
});
|
||||||
|
if (!inst) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
'No game instance is connected for this license yet — install and start the host agent first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return inst;
|
||||||
|
}
|
||||||
|
|
||||||
|
async lifecycleForLicense(licenseId: string, func: LifecycleFunc): Promise<unknown> {
|
||||||
|
const inst = await this.resolveDefaultInstance(licenseId);
|
||||||
|
return this.lifecycle(licenseId, inst.id, func);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rconForLicense(licenseId: string, command: string): Promise<unknown> {
|
||||||
|
const inst = await this.resolveDefaultInstance(licenseId);
|
||||||
|
return this.rcon(licenseId, inst.id, command);
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFileForLicense(licenseId: string, path: string, content: string): Promise<unknown> {
|
||||||
|
const inst = await this.resolveDefaultInstance(licenseId);
|
||||||
|
return this.writeFile(licenseId, inst.id, path, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFileForLicense(licenseId: string, path: string): Promise<unknown> {
|
||||||
|
const inst = await this.resolveDefaultInstance(licenseId);
|
||||||
|
return this.readFile(licenseId, inst.id, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFileForLicense(licenseId: string, path: string): Promise<unknown> {
|
||||||
|
const inst = await this.resolveDefaultInstance(licenseId);
|
||||||
|
return this.deleteFile(licenseId, inst.id, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async wipeForLicense(
|
||||||
|
licenseId: string,
|
||||||
|
wipeType: 'map' | 'blueprint' | 'full',
|
||||||
|
backup = true,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const inst = await this.resolveDefaultInstance(licenseId);
|
||||||
|
return this.wipe(licenseId, inst.id, wipeType, backup);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend-nest/src/modules/kits/dto/create-kits-config.dto.ts
Normal file
19
backend-nest/src/modules/kits/dto/create-kits-config.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateKitsConfigDto {
|
||||||
|
@ApiProperty({ example: 'Default Kits' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Standard kit configuration' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
config_data?: Record<string, any>;
|
||||||
|
}
|
||||||
14
backend-nest/src/modules/kits/dto/import-kits-config.dto.ts
Normal file
14
backend-nest/src/modules/kits/dto/import-kits-config.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ImportKitsConfigDto {
|
||||||
|
@ApiProperty({ example: 'Server Import' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Imported from live server' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
25
backend-nest/src/modules/kits/dto/update-kits-config.dto.ts
Normal file
25
backend-nest/src/modules/kits/dto/update-kits-config.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class UpdateKitsConfigDto {
|
||||||
|
@ApiPropertyOptional({ example: 'Updated Kits' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
@IsOptional()
|
||||||
|
config_name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Updated description' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
config_data?: Record<string, any>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
80
backend-nest/src/modules/kits/kits.controller.ts
Normal file
80
backend-nest/src/modules/kits/kits.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { KitsService } from './kits.service';
|
||||||
|
import { CreateKitsConfigDto } from './dto/create-kits-config.dto';
|
||||||
|
import { UpdateKitsConfigDto } from './dto/update-kits-config.dto';
|
||||||
|
import { ImportKitsConfigDto } from './dto/import-kits-config.dto';
|
||||||
|
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||||
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||||
|
|
||||||
|
@ApiTags('kits')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('kits')
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||||
|
export class KitsController {
|
||||||
|
constructor(private readonly kitsService: KitsService) {}
|
||||||
|
|
||||||
|
@Get('configs')
|
||||||
|
@RequirePermission('kits.view')
|
||||||
|
@ApiOperation({ summary: 'List kits configs (summaries)' })
|
||||||
|
getConfigs(@CurrentTenant() licenseId: string) {
|
||||||
|
return this.kitsService.getConfigs(licenseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('configs/:id')
|
||||||
|
@RequirePermission('kits.view')
|
||||||
|
@ApiOperation({ summary: 'Get full kits config with data' })
|
||||||
|
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.kitsService.getConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs')
|
||||||
|
@RequirePermission('kits.manage')
|
||||||
|
@ApiOperation({ summary: 'Create kits config' })
|
||||||
|
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateKitsConfigDto) {
|
||||||
|
return this.kitsService.createConfig(licenseId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('configs/:id')
|
||||||
|
@RequirePermission('kits.manage')
|
||||||
|
@ApiOperation({ summary: 'Update kits config' })
|
||||||
|
updateConfig(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateKitsConfigDto,
|
||||||
|
) {
|
||||||
|
return this.kitsService.updateConfig(licenseId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('configs/:id')
|
||||||
|
@RequirePermission('kits.manage')
|
||||||
|
@ApiOperation({ summary: 'Delete kits config' })
|
||||||
|
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.kitsService.deleteConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs/:id/apply')
|
||||||
|
@RequirePermission('kits.manage')
|
||||||
|
@ApiOperation({ summary: 'Deploy kits config to server' })
|
||||||
|
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.kitsService.applyToServer(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('import-from-server')
|
||||||
|
@RequirePermission('kits.manage')
|
||||||
|
@ApiOperation({ summary: 'Import Kits.json from server via NATS' })
|
||||||
|
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportKitsConfigDto) {
|
||||||
|
return this.kitsService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend-nest/src/modules/kits/kits.module.ts
Normal file
14
backend-nest/src/modules/kits/kits.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { KitsController } from './kits.controller';
|
||||||
|
import { KitsService } from './kits.service';
|
||||||
|
import { KitsConfig } from '../../entities/kits-config.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([KitsConfig])],
|
||||||
|
controllers: [KitsController],
|
||||||
|
providers: [KitsService, NatsService],
|
||||||
|
exports: [KitsService],
|
||||||
|
})
|
||||||
|
export class KitsModule {}
|
||||||
165
backend-nest/src/modules/kits/kits.service.ts
Normal file
165
backend-nest/src/modules/kits/kits.service.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { KitsConfig } from '../../entities/kits-config.entity';
|
||||||
|
import { InstancesService } from '../instances/instances.service';
|
||||||
|
import { CreateKitsConfigDto } from './dto/create-kits-config.dto';
|
||||||
|
import { UpdateKitsConfigDto } from './dto/update-kits-config.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class KitsService {
|
||||||
|
private readonly logger = new Logger(KitsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(KitsConfig)
|
||||||
|
private readonly kitsRepo: Repository<KitsConfig>,
|
||||||
|
private readonly instancesService: InstancesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** List configs for a license (summaries — no JSONB) */
|
||||||
|
async getConfigs(licenseId: string) {
|
||||||
|
const configs = await this.kitsRepo.find({
|
||||||
|
where: { license_id: licenseId },
|
||||||
|
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
|
||||||
|
order: { created_at: 'DESC' },
|
||||||
|
});
|
||||||
|
return { configs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get full config with JSONB data */
|
||||||
|
async getConfig(licenseId: string, configId: string) {
|
||||||
|
const config = await this.kitsRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('Kits config not found');
|
||||||
|
return { config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new config */
|
||||||
|
async createConfig(licenseId: string, dto: CreateKitsConfigDto) {
|
||||||
|
const config = this.kitsRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: dto.config_name,
|
||||||
|
description: dto.description || null,
|
||||||
|
config_data: dto.config_data || {},
|
||||||
|
});
|
||||||
|
const saved = await this.kitsRepo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update an existing config */
|
||||||
|
async updateConfig(licenseId: string, configId: string, dto: UpdateKitsConfigDto) {
|
||||||
|
const config = await this.kitsRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('Kits config not found');
|
||||||
|
|
||||||
|
if (dto.config_name !== undefined) config.config_name = dto.config_name;
|
||||||
|
if (dto.description !== undefined) config.description = dto.description;
|
||||||
|
if (dto.config_data !== undefined) config.config_data = dto.config_data;
|
||||||
|
if (dto.is_active !== undefined) config.is_active = dto.is_active;
|
||||||
|
config.updated_at = new Date();
|
||||||
|
|
||||||
|
const saved = await this.kitsRepo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a config */
|
||||||
|
async deleteConfig(licenseId: string, configId: string) {
|
||||||
|
const result = await this.kitsRepo.delete({ id: configId, license_id: licenseId });
|
||||||
|
if (result.affected === 0) throw new NotFoundException('Kits config not found');
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deploy config to game server via NATS */
|
||||||
|
async applyToServer(licenseId: string, configId: string) {
|
||||||
|
const config = await this.kitsRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('Kits config not found');
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Write Kits.json via Rust agent
|
||||||
|
await this.instancesService.writeFileForLicense(
|
||||||
|
licenseId,
|
||||||
|
'oxide/config/Kits.json',
|
||||||
|
jsonString,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reload Kits plugin via RCON
|
||||||
|
await this.instancesService.rconForLicense(licenseId, 'oxide.reload Kits');
|
||||||
|
|
||||||
|
// Mark this config as active, deactivate others
|
||||||
|
await this.kitsRepo.update({ license_id: licenseId }, { is_active: false });
|
||||||
|
await this.kitsRepo.update(
|
||||||
|
{ id: configId, license_id: licenseId },
|
||||||
|
{ is_active: true, updated_at: new Date() },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Config "${config.config_name}" deployed to server`,
|
||||||
|
config_name: config.config_name,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to deploy kits config: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to deploy kits config — agent may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import Kits.json from game server via NATS */
|
||||||
|
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||||
|
try {
|
||||||
|
// Read Kits.json from server via Rust agent
|
||||||
|
const result = await this.instancesService.readFileForLicense(
|
||||||
|
licenseId,
|
||||||
|
'oxide/config/Kits.json',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new HttpException(
|
||||||
|
'No response from agent — it may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the response content as JSON
|
||||||
|
const responseData = (result as any).content;
|
||||||
|
let configData: Record<string, any>;
|
||||||
|
|
||||||
|
if (typeof responseData === 'string') {
|
||||||
|
configData = JSON.parse(responseData);
|
||||||
|
} else if (typeof responseData === 'object') {
|
||||||
|
configData = responseData;
|
||||||
|
} else {
|
||||||
|
throw new HttpException(
|
||||||
|
'Unexpected response format from agent',
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new kits config row
|
||||||
|
const config = this.kitsRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: configName,
|
||||||
|
description: description || 'Imported from server',
|
||||||
|
config_data: configData,
|
||||||
|
});
|
||||||
|
const saved = await this.kitsRepo.save(config);
|
||||||
|
|
||||||
|
return { config: saved };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
this.logger.error(`Failed to import kits config from server: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to import kits config — agent may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
backend-nest/src/modules/loot/data/rust-containers.ts
Normal file
39
backend-nest/src/modules/loot/data/rust-containers.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export interface RustContainerInfo {
|
||||||
|
prefab: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RUST_CONTAINERS: RustContainerInfo[] = [
|
||||||
|
// Crates
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal.prefab', name: 'Crate', category: 'crates' },
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal_2.prefab', name: 'Crate (Small)', category: 'crates' },
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/crate_elite.prefab', name: 'Elite Crate', category: 'crates' },
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/crate_tools.prefab', name: 'Tool Crate', category: 'crates' },
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/crate_food_1.prefab', name: 'Food Crate (Small)', category: 'crates' },
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/crate_food_2.prefab', name: 'Food Crate', category: 'crates' },
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/crate_medical.prefab', name: 'Medical Crate', category: 'crates' },
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/crate_mine.prefab', name: 'Mine Crate', category: 'crates' },
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/crate_basic.prefab', name: 'Basic Crate', category: 'crates' },
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/crate_underwater_basic.prefab', name: 'Underwater Basic Crate', category: 'crates' },
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/crate_underwater_advanced.prefab', name: 'Underwater Advanced Crate', category: 'crates' },
|
||||||
|
// Barrels
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/loot_barrel_1.prefab', name: 'Barrel', category: 'barrels' },
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/loot_barrel_2.prefab', name: 'Barrel 2', category: 'barrels' },
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/oil_barrel.prefab', name: 'Oil Barrel', category: 'barrels' },
|
||||||
|
// Military
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/crate_helicopter.prefab', name: 'Helicopter Crate', category: 'military' },
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/bradley_crate.prefab', name: 'Bradley Crate', category: 'military' },
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/supply_drop.prefab', name: 'Supply Drop', category: 'military' },
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/crate_locked.prefab', name: 'Locked Crate', category: 'military' },
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/crate_hackable.prefab', name: 'Hackable Crate', category: 'military' },
|
||||||
|
// NPCs
|
||||||
|
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_full_any.prefab', name: 'Scientist', category: 'npcs' },
|
||||||
|
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_heavy.prefab', name: 'Heavy Scientist', category: 'npcs' },
|
||||||
|
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_oilrig.prefab', name: 'Oil Rig Scientist', category: 'npcs' },
|
||||||
|
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_cargo.prefab', name: 'Cargo Ship Scientist', category: 'npcs' },
|
||||||
|
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/tunneldweller/npc_tunneldweller.prefab', name: 'Tunnel Dweller', category: 'npcs' },
|
||||||
|
// Other
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/minecart.prefab', name: 'Minecart', category: 'other' },
|
||||||
|
{ prefab: 'assets/bundled/prefabs/radtown/vehicle_parts.prefab', name: 'Vehicle Parts', category: 'other' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { IsNumber, IsIn } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ApplyLootProfileDto {
|
||||||
|
@ApiProperty({ example: 1, description: 'Loot multiplier', enum: [1, 2, 5, 10] })
|
||||||
|
@IsNumber()
|
||||||
|
@IsIn([1, 2, 5, 10])
|
||||||
|
multiplier: number;
|
||||||
|
}
|
||||||
24
backend-nest/src/modules/loot/dto/create-loot-profile.dto.ts
Normal file
24
backend-nest/src/modules/loot/dto/create-loot-profile.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateLootProfileDto {
|
||||||
|
@ApiProperty({ example: 'Vanilla 2x' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
profile_name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Standard 2x loot table' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
loot_table?: Record<string, any>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
loot_groups?: Record<string, any>;
|
||||||
|
}
|
||||||
23
backend-nest/src/modules/loot/dto/import-loot-profile.dto.ts
Normal file
23
backend-nest/src/modules/loot/dto/import-loot-profile.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { IsString, IsObject, IsOptional, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ImportLootProfileDto {
|
||||||
|
@ApiProperty({ example: 'Imported from Looty' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
profile_name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'BetterLoot LootTables.json content' })
|
||||||
|
@IsObject()
|
||||||
|
loot_table: Record<string, any>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'BetterLoot LootGroups.json content' })
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
loot_groups?: Record<string, any>;
|
||||||
|
}
|
||||||
30
backend-nest/src/modules/loot/dto/update-loot-profile.dto.ts
Normal file
30
backend-nest/src/modules/loot/dto/update-loot-profile.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class UpdateLootProfileDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
@IsOptional()
|
||||||
|
profile_name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
loot_table?: Record<string, any>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
loot_groups?: Record<string, any>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
112
backend-nest/src/modules/loot/loot.controller.ts
Normal file
112
backend-nest/src/modules/loot/loot.controller.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||||
|
import { LootService } from './loot.service';
|
||||||
|
import { CreateLootProfileDto } from './dto/create-loot-profile.dto';
|
||||||
|
import { UpdateLootProfileDto } from './dto/update-loot-profile.dto';
|
||||||
|
import { ApplyLootProfileDto } from './dto/apply-loot-profile.dto';
|
||||||
|
import { ImportLootProfileDto } from './dto/import-loot-profile.dto';
|
||||||
|
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||||
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||||
|
|
||||||
|
@ApiTags('loot')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('loot')
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||||
|
export class LootController {
|
||||||
|
constructor(private readonly lootService: LootService) {}
|
||||||
|
|
||||||
|
@Get('profiles')
|
||||||
|
@RequirePermission('loot.view')
|
||||||
|
@ApiOperation({ summary: 'List loot profiles (summaries)' })
|
||||||
|
getProfiles(@CurrentTenant() licenseId: string) {
|
||||||
|
return this.lootService.getProfiles(licenseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('profiles/:id')
|
||||||
|
@RequirePermission('loot.view')
|
||||||
|
@ApiOperation({ summary: 'Get full loot profile with data' })
|
||||||
|
getProfile(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.lootService.getProfile(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('profiles')
|
||||||
|
@RequirePermission('loot.manage')
|
||||||
|
@ApiOperation({ summary: 'Create loot profile' })
|
||||||
|
createProfile(@CurrentTenant() licenseId: string, @Body() dto: CreateLootProfileDto) {
|
||||||
|
return this.lootService.createProfile(licenseId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('profiles/:id')
|
||||||
|
@RequirePermission('loot.manage')
|
||||||
|
@ApiOperation({ summary: 'Update loot profile' })
|
||||||
|
updateProfile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateLootProfileDto,
|
||||||
|
) {
|
||||||
|
return this.lootService.updateProfile(licenseId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('profiles/:id')
|
||||||
|
@RequirePermission('loot.manage')
|
||||||
|
@ApiOperation({ summary: 'Delete loot profile' })
|
||||||
|
deleteProfile(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.lootService.deleteProfile(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('profiles/:id/duplicate')
|
||||||
|
@RequirePermission('loot.manage')
|
||||||
|
@ApiOperation({ summary: 'Duplicate loot profile' })
|
||||||
|
duplicateProfile(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.lootService.duplicateProfile(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('profiles/:id/apply')
|
||||||
|
@RequirePermission('loot.manage')
|
||||||
|
@ApiOperation({ summary: 'Apply loot profile to server with multiplier' })
|
||||||
|
applyToServer(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: ApplyLootProfileDto,
|
||||||
|
) {
|
||||||
|
return this.lootService.applyToServer(licenseId, id, dto.multiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('import')
|
||||||
|
@RequirePermission('loot.manage')
|
||||||
|
@ApiOperation({ summary: 'Import BetterLoot/Looty JSON as new profile' })
|
||||||
|
importProfile(@CurrentTenant() licenseId: string, @Body() dto: ImportLootProfileDto) {
|
||||||
|
return this.lootService.importProfile(licenseId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('export/:id')
|
||||||
|
@RequirePermission('loot.view')
|
||||||
|
@ApiOperation({ summary: 'Export loot profile as BetterLoot JSON' })
|
||||||
|
@ApiQuery({ name: 'multiplier', required: false, example: 1 })
|
||||||
|
exportProfile(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('multiplier') multiplier: string,
|
||||||
|
) {
|
||||||
|
return this.lootService.exportProfile(licenseId, id, multiplier ? parseInt(multiplier, 10) : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('containers')
|
||||||
|
@RequirePermission('loot.view')
|
||||||
|
@ApiOperation({ summary: 'Get list of Rust container prefabs' })
|
||||||
|
getContainers() {
|
||||||
|
return this.lootService.getContainers();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend-nest/src/modules/loot/loot.module.ts
Normal file
14
backend-nest/src/modules/loot/loot.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { LootController } from './loot.controller';
|
||||||
|
import { LootService } from './loot.service';
|
||||||
|
import { LootProfile } from '../../entities/loot-profile.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([LootProfile])],
|
||||||
|
controllers: [LootController],
|
||||||
|
providers: [LootService, NatsService],
|
||||||
|
exports: [LootService],
|
||||||
|
})
|
||||||
|
export class LootModule {}
|
||||||
243
backend-nest/src/modules/loot/loot.service.ts
Normal file
243
backend-nest/src/modules/loot/loot.service.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { LootProfile } from '../../entities/loot-profile.entity';
|
||||||
|
import { InstancesService } from '../instances/instances.service';
|
||||||
|
import { CreateLootProfileDto } from './dto/create-loot-profile.dto';
|
||||||
|
import { UpdateLootProfileDto } from './dto/update-loot-profile.dto';
|
||||||
|
import { ImportLootProfileDto } from './dto/import-loot-profile.dto';
|
||||||
|
import { RUST_CONTAINERS } from './data/rust-containers';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LootService {
|
||||||
|
private readonly logger = new Logger(LootService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(LootProfile)
|
||||||
|
private readonly lootRepo: Repository<LootProfile>,
|
||||||
|
private readonly instancesService: InstancesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** List profiles for a license (summaries — no JSONB) */
|
||||||
|
async getProfiles(licenseId: string) {
|
||||||
|
const profiles = await this.lootRepo.find({
|
||||||
|
where: { license_id: licenseId },
|
||||||
|
select: ['id', 'profile_name', 'description', 'is_active', 'created_at', 'updated_at'],
|
||||||
|
order: { created_at: 'DESC' },
|
||||||
|
});
|
||||||
|
return { profiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get full profile with JSONB data */
|
||||||
|
async getProfile(licenseId: string, profileId: string) {
|
||||||
|
const profile = await this.lootRepo.findOne({
|
||||||
|
where: { id: profileId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!profile) throw new NotFoundException('Loot profile not found');
|
||||||
|
return { profile };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new profile */
|
||||||
|
async createProfile(licenseId: string, dto: CreateLootProfileDto) {
|
||||||
|
const profile = this.lootRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
profile_name: dto.profile_name,
|
||||||
|
description: dto.description || null,
|
||||||
|
loot_table: dto.loot_table || {},
|
||||||
|
loot_groups: dto.loot_groups || {},
|
||||||
|
});
|
||||||
|
const saved = await this.lootRepo.save(profile);
|
||||||
|
return { profile: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update an existing profile */
|
||||||
|
async updateProfile(licenseId: string, profileId: string, dto: UpdateLootProfileDto) {
|
||||||
|
const profile = await this.lootRepo.findOne({
|
||||||
|
where: { id: profileId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!profile) throw new NotFoundException('Loot profile not found');
|
||||||
|
|
||||||
|
if (dto.profile_name !== undefined) profile.profile_name = dto.profile_name;
|
||||||
|
if (dto.description !== undefined) profile.description = dto.description;
|
||||||
|
if (dto.loot_table !== undefined) profile.loot_table = dto.loot_table;
|
||||||
|
if (dto.loot_groups !== undefined) profile.loot_groups = dto.loot_groups;
|
||||||
|
if (dto.is_active !== undefined) profile.is_active = dto.is_active;
|
||||||
|
profile.updated_at = new Date();
|
||||||
|
|
||||||
|
const saved = await this.lootRepo.save(profile);
|
||||||
|
return { profile: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a profile */
|
||||||
|
async deleteProfile(licenseId: string, profileId: string) {
|
||||||
|
const result = await this.lootRepo.delete({ id: profileId, license_id: licenseId });
|
||||||
|
if (result.affected === 0) throw new NotFoundException('Loot profile not found');
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Duplicate a profile */
|
||||||
|
async duplicateProfile(licenseId: string, profileId: string) {
|
||||||
|
const source = await this.lootRepo.findOne({
|
||||||
|
where: { id: profileId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!source) throw new NotFoundException('Loot profile not found');
|
||||||
|
|
||||||
|
const copy = this.lootRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
profile_name: `${source.profile_name} (Copy)`,
|
||||||
|
description: source.description,
|
||||||
|
loot_table: JSON.parse(JSON.stringify(source.loot_table)),
|
||||||
|
loot_groups: JSON.parse(JSON.stringify(source.loot_groups)),
|
||||||
|
is_active: false,
|
||||||
|
});
|
||||||
|
const saved = await this.lootRepo.save(copy);
|
||||||
|
return { profile: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply profile to server with multiplier */
|
||||||
|
async applyToServer(licenseId: string, profileId: string, multiplier: number) {
|
||||||
|
const profile = await this.lootRepo.findOne({
|
||||||
|
where: { id: profileId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!profile) throw new NotFoundException('Loot profile not found');
|
||||||
|
|
||||||
|
// Deep clone and apply multiplier
|
||||||
|
const scaledTable = JSON.parse(JSON.stringify(profile.loot_table));
|
||||||
|
const scaledGroups = JSON.parse(JSON.stringify(profile.loot_groups));
|
||||||
|
|
||||||
|
if (multiplier !== 1) {
|
||||||
|
this.applyMultiplierToTable(scaledTable, multiplier);
|
||||||
|
this.applyMultiplierToGroups(scaledGroups, multiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lootTablesJson = JSON.stringify(scaledTable, null, 2);
|
||||||
|
const lootGroupsJson = JSON.stringify(scaledGroups, null, 2);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Write LootTables.json via Rust agent
|
||||||
|
await this.instancesService.writeFileForLicense(
|
||||||
|
licenseId,
|
||||||
|
'oxide/data/BetterLoot/LootTables.json',
|
||||||
|
lootTablesJson,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Write LootGroups.json via Rust agent
|
||||||
|
await this.instancesService.writeFileForLicense(
|
||||||
|
licenseId,
|
||||||
|
'oxide/data/BetterLoot/LootGroups.json',
|
||||||
|
lootGroupsJson,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reload BetterLoot plugin via RCON
|
||||||
|
await this.instancesService.rconForLicense(licenseId, 'oxide.reload BetterLoot');
|
||||||
|
|
||||||
|
// Mark this profile as active, deactivate others
|
||||||
|
await this.lootRepo.update({ license_id: licenseId }, { is_active: false });
|
||||||
|
await this.lootRepo.update(
|
||||||
|
{ id: profileId, license_id: licenseId },
|
||||||
|
{ is_active: true, updated_at: new Date() },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Profile "${profile.profile_name}" applied with ${multiplier}x multiplier`,
|
||||||
|
profile_name: profile.profile_name,
|
||||||
|
multiplier,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to apply loot profile: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to apply loot profile — agent may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import BetterLoot/Looty JSON as a new profile */
|
||||||
|
async importProfile(licenseId: string, dto: ImportLootProfileDto) {
|
||||||
|
const profile = this.lootRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
profile_name: dto.profile_name,
|
||||||
|
description: dto.description || 'Imported profile',
|
||||||
|
loot_table: dto.loot_table,
|
||||||
|
loot_groups: dto.loot_groups || {},
|
||||||
|
});
|
||||||
|
const saved = await this.lootRepo.save(profile);
|
||||||
|
return { profile: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export profile as BetterLoot-compatible JSON with optional multiplier */
|
||||||
|
async exportProfile(licenseId: string, profileId: string, multiplier: number) {
|
||||||
|
const profile = await this.lootRepo.findOne({
|
||||||
|
where: { id: profileId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!profile) throw new NotFoundException('Loot profile not found');
|
||||||
|
|
||||||
|
const exportTable = JSON.parse(JSON.stringify(profile.loot_table));
|
||||||
|
const exportGroups = JSON.parse(JSON.stringify(profile.loot_groups));
|
||||||
|
|
||||||
|
if (multiplier && multiplier !== 1) {
|
||||||
|
this.applyMultiplierToTable(exportTable, multiplier);
|
||||||
|
this.applyMultiplierToGroups(exportGroups, multiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile_name: profile.profile_name,
|
||||||
|
multiplier: multiplier || 1,
|
||||||
|
loot_table: exportTable,
|
||||||
|
loot_groups: exportGroups,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get static list of Rust container prefabs */
|
||||||
|
getContainers() {
|
||||||
|
return { containers: RUST_CONTAINERS };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Multiplier helpers ---
|
||||||
|
|
||||||
|
private applyMultiplierToTable(table: Record<string, any>, multiplier: number) {
|
||||||
|
for (const prefab of Object.values(table)) {
|
||||||
|
if (prefab?.ItemSettings) {
|
||||||
|
this.scaleField(prefab.ItemSettings, 'ItemsMin', multiplier);
|
||||||
|
this.scaleField(prefab.ItemSettings, 'ItemsMax', multiplier);
|
||||||
|
this.scaleField(prefab.ItemSettings, 'MinScrap', multiplier);
|
||||||
|
this.scaleField(prefab.ItemSettings, 'MaxScrap', multiplier);
|
||||||
|
}
|
||||||
|
if (prefab?.GuaranteedItems) {
|
||||||
|
this.scaleItems(prefab.GuaranteedItems, multiplier);
|
||||||
|
}
|
||||||
|
if (prefab?.UngroupedItems) {
|
||||||
|
this.scaleItems(prefab.UngroupedItems, multiplier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyMultiplierToGroups(groups: Record<string, any>, multiplier: number) {
|
||||||
|
for (const group of Object.values(groups)) {
|
||||||
|
if (group?.GuaranteedItems) {
|
||||||
|
this.scaleItems(group.GuaranteedItems, multiplier);
|
||||||
|
}
|
||||||
|
if (group?.ItemList) {
|
||||||
|
this.scaleItems(group.ItemList, multiplier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scaleItems(items: Record<string, any>, multiplier: number) {
|
||||||
|
for (const item of Object.values(items)) {
|
||||||
|
this.scaleField(item, 'Min', multiplier);
|
||||||
|
this.scaleField(item, 'Max', multiplier);
|
||||||
|
// Recursively scale bonus items
|
||||||
|
if (item?.BonusItems) {
|
||||||
|
this.scaleItems(item.BonusItems, multiplier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scaleField(obj: Record<string, any>, field: string, multiplier: number) {
|
||||||
|
if (typeof obj[field] === 'number') {
|
||||||
|
obj[field] = Math.round(obj[field] * multiplier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
import { mkdirSync, writeFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
import { MapLibrary } from '../../entities/map-library.entity';
|
import { MapLibrary } from '../../entities/map-library.entity';
|
||||||
import { MapRotation } from '../../entities/map-rotation.entity';
|
import { MapRotation } from '../../entities/map-rotation.entity';
|
||||||
import { UpdateRotationDto } from './dto/update-rotation.dto';
|
import { UpdateRotationDto } from './dto/update-rotation.dto';
|
||||||
import { UploadMapDto } from './dto/upload-map.dto';
|
import { UploadMapDto } from './dto/upload-map.dto';
|
||||||
|
|
||||||
|
// Docker volume mount point for map storage. Tenant-scoped subdirectory enforces isolation.
|
||||||
|
const MAP_DATA_ROOT = process.env.MAP_DATA_PATH || '/app/map_data';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MapsService {
|
export class MapsService {
|
||||||
|
private readonly logger = new Logger(MapsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(MapLibrary)
|
@InjectRepository(MapLibrary)
|
||||||
private readonly mapLibraryRepo: Repository<MapLibrary>,
|
private readonly mapLibraryRepo: Repository<MapLibrary>,
|
||||||
@@ -22,7 +29,23 @@ export class MapsService {
|
|||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
): Promise<MapLibrary> {
|
): Promise<MapLibrary> {
|
||||||
const checksum = createHash('sha256').update(file.buffer).digest('hex');
|
const checksum = createHash('sha256').update(file.buffer).digest('hex');
|
||||||
const storagePath = `/maps/${licenseId}/${Date.now()}_${file.originalname}`;
|
|
||||||
|
// Build tenant-scoped storage path: /app/map_data/{licenseId}/{timestamp}_{filename}
|
||||||
|
const filename = `${Date.now()}_${file.originalname}`;
|
||||||
|
const tenantDir = join(MAP_DATA_ROOT, licenseId);
|
||||||
|
const absolutePath = join(tenantDir, filename);
|
||||||
|
|
||||||
|
// Relative storage path stored in DB — avoids coupling to the absolute mount point
|
||||||
|
const storagePath = `/map_data/${licenseId}/${filename}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
mkdirSync(tenantDir, { recursive: true });
|
||||||
|
writeFileSync(absolutePath, file.buffer);
|
||||||
|
this.logger.log(`Map uploaded: ${absolutePath} (${file.size} bytes)`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to write map file to disk: ${absolutePath}`, err);
|
||||||
|
throw new InternalServerErrorException('Failed to save map file to storage');
|
||||||
|
}
|
||||||
|
|
||||||
const map = this.mapLibraryRepo.create({
|
const map = this.mapLibraryRepo.create({
|
||||||
license_id: licenseId,
|
license_id: licenseId,
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { PlayersController } from './players.controller';
|
import { PlayersController } from './players.controller';
|
||||||
import { PlayersService } from './players.service';
|
import { PlayersService } from './players.service';
|
||||||
import { PlayerAction } from '../../entities/player-action.entity';
|
import { PlayerAction } from '../../entities/player-action.entity';
|
||||||
|
import { PlayerSession } from '../../entities/player-session.entity';
|
||||||
import { NatsService } from '../../services/nats.service';
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([PlayerAction])],
|
imports: [TypeOrmModule.forFeature([PlayerAction, PlayerSession])],
|
||||||
controllers: [PlayersController],
|
controllers: [PlayersController],
|
||||||
providers: [PlayersService, NatsService],
|
providers: [PlayersService, NatsService],
|
||||||
exports: [PlayersService],
|
exports: [PlayersService],
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user