Compare commits

..

26 Commits

Author SHA1 Message Date
forust 726b3ee544 fix(edu): run redis as statefulset
ci / deploy-userbot-panel (push) Has been skipped
ci / lint-ruff (push) Successful in 5s
ci / lint-yaml (push) Successful in 7s
ci / build (push) Successful in 3s
ci / lint-prettier (push) Successful in 11s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 5s
Keep the existing Redis PVC and data while migrating the edu-master workload from Deployment to StatefulSet.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-10 23:35:59 +02:00
forust 13309b26e0 fix: add checkmk agent entrypoint
ci / lint-dockerfiles (push) Successful in 5s
ci / validate (push) Successful in 5s
ci / deploy-userbot-panel (push) Has been skipped
ci / lint-prettier (push) Successful in 10s
ci / lint-ruff (push) Successful in 5s
ci / lint-yaml (push) Successful in 7s
ci / build (push) Successful in 2s
update traefik to v3.7.13
2026-09-10 14:52:49 +02:00
forust eddc256bed chore: remove esp32/ingress.yaml from main
ci / lint-prettier (push) Successful in 7s
ci / lint-ruff (push) Successful in 4s
ci / deploy-userbot-panel (push) Has been skipped
ci / lint-yaml (push) Successful in 7s
ci / lint-dockerfiles (push) Successful in 5s
ci / validate (push) Successful in 5s
ci / build (push) Successful in 3s
2026-09-10 12:12:54 +02:00
forust 52f821cbba Merge branch 'main' of ssh://gitssh.forust.xyz:2221/forust/homelab
ci / lint-prettier (push) Failing after 8s
ci / lint-ruff (push) Successful in 4s
ci / lint-yaml (push) Failing after 7s
ci / lint-dockerfiles (push) Successful in 5s
ci / validate (push) Successful in 5s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
2026-09-10 11:47:25 +02:00
forust 0dbc2fff13 Merge branch 'sidetree' 2026-09-10 11:47:25 +02:00
forust 91ec83bc1c Merge branch 'main' of ssh://gitssh.forust.xyz:2221/forust/homelab
ci / lint-ruff (push) Successful in 4s
ci / lint-prettier (push) Failing after 8s
ci / lint-yaml (push) Failing after 7s
ci / lint-dockerfiles (push) Successful in 5s
ci / validate (push) Successful in 6s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
2026-09-10 11:47:20 +02:00
forust 9fec1dae39 Merge branch 'sidetree' 2026-09-10 11:47:15 +02:00
forust e5626b7b2d chore: remove untracked README.md
ci / lint-prettier (push) Successful in 7s
ci / lint-ruff (push) Successful in 5s
ci / lint-yaml (push) Successful in 6s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 4s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
2026-09-10 11:45:00 +02:00
forust 8fb12a2176 chore: remove untracked README.md
ci / lint-prettier (push) Successful in 7s
ci / lint-ruff (push) Successful in 4s
ci / lint-yaml (push) Successful in 6s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 5s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
2026-09-10 11:44:23 +02:00
forust fe0c7067b6 Merge branch 'main' of ssh://gitssh.forust.xyz:2221/forust/homelab
ci / validate (push) Successful in 4s
ci / build (push) Has been skipped
ci / lint-prettier (push) Failing after 8s
ci / lint-ruff (push) Successful in 7s
ci / lint-yaml (push) Failing after 6s
ci / lint-dockerfiles (push) Successful in 4s
ci / deploy-userbot-panel (push) Has been skipped
2026-09-10 11:42:17 +02:00
forust d0f0843774 Merge branch 'sidetree' 2026-09-10 11:41:35 +02:00
forust 81a5b207ac Merge branch 'main' of ssh://gitssh.forust.xyz:2221/forust/homelab
ci / lint-prettier (push) Failing after 8s
ci / lint-ruff (push) Successful in 5s
ci / lint-yaml (push) Failing after 7s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 5s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
2026-09-10 11:41:31 +02:00
forust e3d5970ae3 chore: remove untracked README.md
ci / lint-prettier (push) Failing after 11s
ci / lint-ruff (push) Successful in 5s
ci / lint-yaml (push) Failing after 7s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 5s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
2026-09-10 11:39:20 +02:00
forust 85f05c26cb feat(edu): activate k8s management for edu-master 2026-09-10 00:55:22 +02:00
forust 68630eb773 fix(edu): read diary event times directly from DOM, drop 25 AJAX clicks
ci / lint-prettier (push) Successful in 9s
ci / lint-ruff (push) Successful in 6s
ci / lint-yaml (push) Successful in 10s
ci / lint-dockerfiles (push) Successful in 7s
ci / validate (push) Successful in 6s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
_collect_event_times re-clicked every a.event-link and waited ~3s per
event for a visible span.data, but the calendar embeds all times in
div.event-full-info[data-event-full-info-id] span.data already. The old
loop took ~109s for 25 events and collected 0 (original divs stay
sf-hidden), effectively hanging /diary. Now a single evaluate reads all
times (~3.7s), parsing HH:MM from p.date span.data.
2026-09-10 00:31:04 +02:00
forust dad9cf2104 feat(edu): parse event times in /diary and drop weekend days
ci / lint-prettier (push) Successful in 9s
ci / lint-ruff (push) Successful in 4s
ci / lint-yaml (push) Successful in 6s
ci / lint-dockerfiles (push) Successful in 5s
ci / validate (push) Successful in 6s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
- fetch each event's time via Playwright (click event-link, read span.data, close fancybox) and render as 'title (HH:MM)'
- '08:00' placeholder renders as localized 'unknown' (time_unknown key in ru/uk/en)
- diary week view shows Mon-Fri only (title ends at Friday)
- diary month view skips Sat/Sun by weekday_idx with name-based fallback
- schedule keyboard drops Sat/Sun day buttons
2026-09-10 00:05:56 +02:00
forust 60886e8be2 style(edu): ruff-format webinar-checker/checker.py
ci / lint-prettier (push) Successful in 13s
ci / lint-ruff (push) Successful in 4s
ci / lint-yaml (push) Successful in 7s
ci / lint-dockerfiles (push) Successful in 5s
ci / validate (push) Successful in 6s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
2026-09-09 23:47:04 +02:00
forust 4528321225 fix(edu): add TZ to .env.example 2026-09-09 11:46:35 +02:00
forust b246a3dea1 fix(edu): review findings for checker.py i18n — group chat language via resolve_lang/chat: keys (default uk), weekday normalization with _norm_day + logging, drop dead translation keys, html.escape, single lang lookup, distinct whitelist emoji 2026-09-09 11:44:17 +02:00
forust 4c59a2d2fe lang(edu): translate k8s manifest comments to English only
- restore-seed-job.yaml.example: translate runbook to English
- secrets.yaml.example: translate section headers to English
- webinar-checker.yaml: translate initContainer dependency-order comments to English

Co-authored-by: assistant
2026-09-09 10:46:36 +02:00
forust fcc7b0d611 ci(deploy): gate redeploy behind manual workflow_dispatch
ci / lint-prettier (push) Successful in 8s
ci / lint-ruff (push) Successful in 4s
ci / lint-yaml (push) Successful in 7s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 5s
ci / build (push) Successful in 2s
ci / deploy-userbot-panel (push) Has been skipped
removes automatic redeploy on push to main; deploy now runs only on
explicit manual trigger
2026-09-06 20:55:40 +02:00
forust 9633fe3a10 style(ci): add trailing newline to deploy workflows
ci / lint-prettier (push) Successful in 7s
ci / lint-ruff (push) Successful in 5s
ci / lint-yaml (push) Successful in 7s
ci / lint-dockerfiles (push) Successful in 5s
ci / validate (push) Successful in 5s
deploy / redeploy (push) Failing after 1s
ci / build (push) Successful in 2s
ci / deploy-userbot-panel (push) Has been skipped
2026-09-06 20:45:30 +02:00
forust d9f1c8325a feat(userbot): prereqs at startup, SPA path guard, provision lock
ci / lint-prettier (push) Failing after 8s
ci / lint-ruff (push) Successful in 4s
ci / lint-yaml (push) Failing after 7s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 5s
deploy / redeploy (push) Failing after 0s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
- ensure_prerequisites runs on startup, not per-request; kube config
  errors surface as 503 PanelError
- serialize provisioning with a lock; drop per-endpoint prereq checks
- guard SPA fallback against path traversal (relative_to)
- add backend tests for auth flow, k8s service, spa routing; ci comment
  for legacy userbot deployments
2026-09-06 20:39:12 +02:00
forust 861d89d36a ci(deploy): split runtime by k8s/active marker
services marked k8s/active are applied via kubectl; the rest via docker
compose. inactive services with k8s/ keep only routing manifests
(external Services, EndpointSlices, Ingresses) to reach docker backends.
headscale/nextcloud routing moved to k8s/routing/.

validations: compose config --quiet + kubectl apply --dry-run=client.
namespace manifests applied first. pull_policy:build stacks get
build+push before up so the registry image stays fresh.
2026-09-06 20:39:12 +02:00
forust 71cddd6a91 feat(userbot): add Kubernetes control panel
Manage Telegram instances through Kubernetes with legacy adoption for forust and anna. Build and deploy the panel image alongside the runtime.
2026-09-06 20:39:12 +02:00
forust 30e6f05584 fix(edu_master): satisfy ruff in schedule scraper
- rename ambiguous loop var, merge nested if (E741, SIM102)
- use tempfile.gettempdir() for debug dump (S108)
- drop unused total_lessons assignment (F841)
2026-09-06 20:37:18 +02:00
80 changed files with 9282 additions and 187 deletions
+41 -8
View File
@@ -124,6 +124,8 @@ jobs:
needs: [lint-prettier, lint-ruff, lint-yaml, lint-dockerfiles, validate]
if: github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'dev')
runs-on: [self-hosted, linux, arch, homelab]
outputs:
services: ${{ steps.services.outputs.services }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -243,7 +245,6 @@ jobs:
done
;;
userbot)
image="${REGISTRY}/forust/userbot"
tags=("latest")
case "${GITHUB_REF_NAME}" in
main)
@@ -253,13 +254,25 @@ jobs:
tags+=("dev")
;;
esac
build_args=()
for tag in "${tags[@]}"; do
build_args+=(-t "${image}:${tag}")
done
docker build "${build_args[@]}" userbot
for tag in "${tags[@]}"; do
docker push "${image}:${tag}"
for target in runtime panel; do
case "$target" in
runtime)
context="userbot"
image="${REGISTRY}/forust/userbot"
;;
panel)
context="userbot/panel"
image="${REGISTRY}/forust/userbot-panel"
;;
esac
build_args=()
for tag in "${tags[@]}"; do
build_args+=(-t "${image}:${tag}")
done
docker build "${build_args[@]}" "$context"
for tag in "${tags[@]}"; do
docker push "${image}:${tag}"
done
done
;;
homepages)
@@ -324,3 +337,23 @@ jobs:
;;
esac
done
deploy-userbot-panel:
needs: build
if: github.ref_name == 'main' && contains(needs.build.outputs.services, 'userbot')
runs-on: [self-hosted, linux, arch, homelab, prod]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Apply and roll out userbot panel
shell: bash
run: |
kubectl apply -f userbot/k8s/base/panel.yaml
kubectl get secret userbot-common-secrets -n default -o json \
| jq 'del(.metadata.annotations,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.uid,.metadata.managedFields) | .metadata.namespace = "userbot"' \
| kubectl apply -f -
# Keep legacy deployments (forust/anna) in sync with manifests; they have no replicas field, so apply leaves scaling to the user manager only.
kubectl apply -f userbot/k8s/base/userbots.yaml
kubectl rollout restart deployment/userbot-panel -n userbot
kubectl rollout status deployment/userbot-panel -n userbot --timeout=180s
+152
View File
@@ -0,0 +1,152 @@
name: deploy
on:
workflow_dispatch:
concurrency:
group: deploy-main
cancel-in-progress: false
jobs:
redeploy:
runs-on: [self-hosted, linux, arch, homelab, prod]
steps:
- name: Redeploy workstation
shell: bash
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
# Set APPLY_PRUNE=true to enable kubectl apply --prune. Requires every
# manifest to carry label app.kubernetes.io/managed-by=homelab-deploy,
# otherwise previously applied resources get deleted on the next run.
APPLY_PRUNE: ${{ vars.APPLY_PRUNE }}
run: |
set -euo pipefail
: "${DEPLOY_HOST:?missing DEPLOY_HOST}"
: "${DEPLOY_USER:?missing DEPLOY_USER}"
: "${DEPLOY_KEY:?missing DEPLOY_SSH_KEY}"
deploy_port="${DEPLOY_PORT:-22}"
deploy_path="${DEPLOY_PATH:-/srv/homelab}"
ssh_key="$RUNNER_TEMP/deploy_key"
mkdir -p "$RUNNER_TEMP"
printf '%s\n' "$DEPLOY_KEY" > "$ssh_key"
chmod 600 "$ssh_key"
ssh_opts=(
-i "$ssh_key"
-p "$deploy_port"
-o BatchMode=yes
-o StrictHostKeyChecking=accept-new
)
ssh "${ssh_opts[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
"DEPLOY_PATH=$(printf '%q' \"$deploy_path\") APPLY_PRUNE=$(printf '%q' \"${APPLY_PRUNE:-false}\") bash -se" <<'EOF'
set -euo pipefail
repo="${DEPLOY_PATH:-/srv/homelab}"
if [ ! -d "$repo/.git" ]; then
echo "Repository not found at $repo"
exit 1
fi
git -C "$repo" fetch origin main
git -C "$repo" reset --hard origin/main
# Runtime selection: a service is k8s-managed when $SERVICE/k8s/active
# exists. Otherwise it is compose-managed, and only k8s/routing/*
# manifests (external Services / EndpointSlices / ServersTransport /
# Ingresses that route to docker backends) are applied.
# migrate: touch SERVICE/k8s/active (+ move routing files up)
# rollback: rm SERVICE/k8s/active
collect_k8s() {
find "$1" -type f \( -name '*.yaml' -o -name '*.yml' \) \
! -path '*/routing/*' ! -path '*/overlays/*' \
! -name 'kustomization.y*ml' ! -name '*.example.y*ml' \
! -name '*values.y*ml' ! -name 'patch-*.y*ml' \
| sort
}
collect_k8s_inactive() {
find "$1" -type f \( -name '*.yaml' -o -name '*.yml' \) \
\( -name 'namespace.y*ml' -o -path '*/routing/*' \) \
! -path '*/overlays/*' ! -name '*.example.y*ml' \
| sort
}
mapfile -t compose_stacks < <(
find "$repo" -type f \( -name 'compose.yaml' -o -name 'compose.yml' \) | sort
)
mapfile -t k8s_manifests < <(
for kd in $(find "$repo" -type d -name k8s ! -path '*/.git/*' | sort); do
if [ -f "$kd/active" ]; then
collect_k8s "$kd"
else
collect_k8s_inactive "$kd"
fi
done
)
echo "== Validate compose stacks =="
for cf in "${compose_stacks[@]}"; do
dir=$(dirname "$cf")
if [ -f "$dir/k8s/active" ]; then
echo " skip (k8s-managed): $dir"
continue
fi
echo " config: $cf"
docker compose -f "$cf" config --quiet
done
echo "== Validate k8s manifests (kubectl dry-run) =="
for m in "${k8s_manifests[@]}"; do
echo " apply --dry-run=client $m"
kubectl apply --dry-run=client -f "$m" >/dev/null
done
echo "== Applying Kubernetes manifests =="
ns_files=()
other_files=()
for m in "${k8s_manifests[@]}"; do
case "$m" in
*/namespace.y?ml) ns_files+=("$m") ;;
*) other_files+=("$m") ;;
esac
done
prune_opts=()
if [ "${APPLY_PRUNE:-false}" = "true" ]; then
prune_opts=(--prune -l app.kubernetes.io/managed-by=homelab-deploy)
fi
if [ "${#ns_files[@]}" -gt 0 ]; then
echo " namespaces first: ${ns_files[*]}"
kubectl apply -f "${ns_files[@]}"
fi
if [ "${#other_files[@]}" -gt 0 ]; then
echo " resources: ${other_files[*]}"
kubectl apply "${prune_opts[@]}" -f "${other_files[@]}"
fi
echo "== Redeploying docker compose stacks =="
for cf in "${compose_stacks[@]}"; do
dir=$(dirname "$cf")
if [ -f "$dir/k8s/active" ]; then
echo " skip (k8s-managed): $dir"
continue
fi
echo " compose: $dir"
if grep -Eq '^\s+pull_policy:\s*build\b' "$cf"; then
docker compose -f "$cf" build
docker compose -f "$cf" push
fi
docker compose -f "$cf" up -d --pull always --remove-orphans
done
EOF
+41 -8
View File
@@ -124,6 +124,8 @@ jobs:
needs: [lint-prettier, lint-ruff, lint-yaml, lint-dockerfiles, validate]
if: github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'dev')
runs-on: [self-hosted, linux, arch, homelab]
outputs:
services: ${{ steps.services.outputs.services }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -243,7 +245,6 @@ jobs:
done
;;
userbot)
image="${REGISTRY}/forust/userbot"
tags=("latest")
case "${GITHUB_REF_NAME}" in
main)
@@ -253,13 +254,25 @@ jobs:
tags+=("dev")
;;
esac
build_args=()
for tag in "${tags[@]}"; do
build_args+=(-t "${image}:${tag}")
done
docker build "${build_args[@]}" userbot
for tag in "${tags[@]}"; do
docker push "${image}:${tag}"
for target in runtime panel; do
case "$target" in
runtime)
context="userbot"
image="${REGISTRY}/forust/userbot"
;;
panel)
context="userbot/panel"
image="${REGISTRY}/forust/userbot-panel"
;;
esac
build_args=()
for tag in "${tags[@]}"; do
build_args+=(-t "${image}:${tag}")
done
docker build "${build_args[@]}" "$context"
for tag in "${tags[@]}"; do
docker push "${image}:${tag}"
done
done
;;
homepages)
@@ -324,3 +337,23 @@ jobs:
;;
esac
done
deploy-userbot-panel:
needs: build
if: github.ref_name == 'main' && contains(needs.build.outputs.services, 'userbot')
runs-on: [self-hosted, linux, arch, homelab, prod]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Apply and roll out userbot panel
shell: bash
run: |
kubectl apply -f userbot/k8s/base/panel.yaml
kubectl get secret userbot-common-secrets -n default -o json \
| jq 'del(.metadata.annotations,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.uid,.metadata.managedFields) | .metadata.namespace = "userbot"' \
| kubectl apply -f -
# Keep legacy deployments (forust/anna) in sync with manifests; they have no replicas field, so apply leaves scaling to the user manager only.
kubectl apply -f userbot/k8s/base/userbots.yaml
kubectl rollout restart deployment/userbot-panel -n userbot
kubectl rollout status deployment/userbot-panel -n userbot --timeout=180s
+152
View File
@@ -0,0 +1,152 @@
name: deploy
on:
workflow_dispatch:
concurrency:
group: deploy-main
cancel-in-progress: false
jobs:
redeploy:
runs-on: [self-hosted, linux, arch, homelab, prod]
steps:
- name: Redeploy workstation
shell: bash
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
# Set APPLY_PRUNE=true to enable kubectl apply --prune. Requires every
# manifest to carry label app.kubernetes.io/managed-by=homelab-deploy,
# otherwise previously applied resources get deleted on the next run.
APPLY_PRUNE: ${{ vars.APPLY_PRUNE }}
run: |
set -euo pipefail
: "${DEPLOY_HOST:?missing DEPLOY_HOST}"
: "${DEPLOY_USER:?missing DEPLOY_USER}"
: "${DEPLOY_KEY:?missing DEPLOY_SSH_KEY}"
deploy_port="${DEPLOY_PORT:-22}"
deploy_path="${DEPLOY_PATH:-/srv/homelab}"
ssh_key="$RUNNER_TEMP/deploy_key"
mkdir -p "$RUNNER_TEMP"
printf '%s\n' "$DEPLOY_KEY" > "$ssh_key"
chmod 600 "$ssh_key"
ssh_opts=(
-i "$ssh_key"
-p "$deploy_port"
-o BatchMode=yes
-o StrictHostKeyChecking=accept-new
)
ssh "${ssh_opts[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
"DEPLOY_PATH=$(printf '%q' \"$deploy_path\") APPLY_PRUNE=$(printf '%q' \"${APPLY_PRUNE:-false}\") bash -se" <<'EOF'
set -euo pipefail
repo="${DEPLOY_PATH:-/srv/homelab}"
if [ ! -d "$repo/.git" ]; then
echo "Repository not found at $repo"
exit 1
fi
git -C "$repo" fetch origin main
git -C "$repo" reset --hard origin/main
# Runtime selection: a service is k8s-managed when $SERVICE/k8s/active
# exists. Otherwise it is compose-managed, and only k8s/routing/*
# manifests (external Services / EndpointSlices / ServersTransport /
# Ingresses that route to docker backends) are applied.
# migrate: touch SERVICE/k8s/active (+ move routing files up)
# rollback: rm SERVICE/k8s/active
collect_k8s() {
find "$1" -type f \( -name '*.yaml' -o -name '*.yml' \) \
! -path '*/routing/*' ! -path '*/overlays/*' \
! -name 'kustomization.y*ml' ! -name '*.example.y*ml' \
! -name '*values.y*ml' ! -name 'patch-*.y*ml' \
| sort
}
collect_k8s_inactive() {
find "$1" -type f \( -name '*.yaml' -o -name '*.yml' \) \
\( -name 'namespace.y*ml' -o -path '*/routing/*' \) \
! -path '*/overlays/*' ! -name '*.example.y*ml' \
| sort
}
mapfile -t compose_stacks < <(
find "$repo" -type f \( -name 'compose.yaml' -o -name 'compose.yml' \) | sort
)
mapfile -t k8s_manifests < <(
for kd in $(find "$repo" -type d -name k8s ! -path '*/.git/*' | sort); do
if [ -f "$kd/active" ]; then
collect_k8s "$kd"
else
collect_k8s_inactive "$kd"
fi
done
)
echo "== Validate compose stacks =="
for cf in "${compose_stacks[@]}"; do
dir=$(dirname "$cf")
if [ -f "$dir/k8s/active" ]; then
echo " skip (k8s-managed): $dir"
continue
fi
echo " config: $cf"
docker compose -f "$cf" config --quiet
done
echo "== Validate k8s manifests (kubectl dry-run) =="
for m in "${k8s_manifests[@]}"; do
echo " apply --dry-run=client $m"
kubectl apply --dry-run=client -f "$m" >/dev/null
done
echo "== Applying Kubernetes manifests =="
ns_files=()
other_files=()
for m in "${k8s_manifests[@]}"; do
case "$m" in
*/namespace.y?ml) ns_files+=("$m") ;;
*) other_files+=("$m") ;;
esac
done
prune_opts=()
if [ "${APPLY_PRUNE:-false}" = "true" ]; then
prune_opts=(--prune -l app.kubernetes.io/managed-by=homelab-deploy)
fi
if [ "${#ns_files[@]}" -gt 0 ]; then
echo " namespaces first: ${ns_files[*]}"
kubectl apply -f "${ns_files[@]}"
fi
if [ "${#other_files[@]}" -gt 0 ]; then
echo " resources: ${other_files[*]}"
kubectl apply "${prune_opts[@]}" -f "${other_files[@]}"
fi
echo "== Redeploying docker compose stacks =="
for cf in "${compose_stacks[@]}"; do
dir=$(dirname "$cf")
if [ -f "$dir/k8s/active" ]; then
echo " skip (k8s-managed): $dir"
continue
fi
echo " compose: $dir"
if grep -Eq '^\s+pull_policy:\s*build\b' "$cf"; then
docker compose -f "$cf" build
docker compose -f "$cf" push
fi
docker compose -f "$cf" up -d --pull always --remove-orphans
done
EOF
+1
View File
@@ -41,6 +41,7 @@ traefik/dynamic/fileservers.yml
traefik/dynamic/*.local.y*ml.*
traefik/dynamic/*.external.y*ml
traefik/k8s/fileservers.y*ml
traefik/k8s/aliasHeadersStrategy.md
traefik/logs/*
View File
View File
View File
View File
+9 -2
View File
@@ -7,8 +7,12 @@ spec:
selector:
app: checkmk
ports:
- port: 5000
- name: web
port: 5000
targetPort: 5000
- name: agent-receiver
port: 8000
targetPort: 8000
---
apiVersion: apps/v1
kind: Deployment
@@ -34,7 +38,10 @@ spec:
- configMapRef:
name: checkmk-config
ports:
- containerPort: 5000
- name: web
containerPort: 5000
- name: agent-receiver
containerPort: 8000
volumeMounts:
- name: sites
mountPath: /omd/sites
+16
View File
@@ -16,6 +16,22 @@ spec:
certResolver: letsencrypt
---
apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata:
name: checkmk-agent-receiver
namespace: checkmk
spec:
entryPoints:
- checkmk-agent
routes:
- match: HostSNI(`*`)
services:
- name: checkmk-service
port: 8000
tls:
passthrough: true
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: checkmk-local
View File
View File
+1
View File
@@ -9,5 +9,6 @@ WEBINAR_CHECK_INTERVAL=60
REDIS_HOST=redis
REDIS_PORT=6379
PLAYWRIGHT_WS=ws://playwright-service:3000/ws
TZ=Europe/Kyiv
WEBINAR_TELEGRAM_TOKEN=your_telegram_bot_token_here
WEBINAR_ADMIN_ID=123456789
View File
+2 -1
View File
@@ -1,11 +1,12 @@
apiVersion: apps/v1
kind: Deployment
kind: StatefulSet
metadata:
name: redis
namespace: edu-master
labels:
app: edu-master-redis
spec:
serviceName: redis
replicas: 1
selector:
matchLabels:
+6 -6
View File
@@ -1,17 +1,17 @@
# Разовый Job для переноса состояния redis из docker compose в k8s (maintenance window).
# Файл .example -> deploy-пайплайн его не применяет (маска *.example.yaml).
# One-time Job to migrate redis state from docker compose to k8s (maintenance window).
# The .example file is not applied by the deploy pipeline (mask *.example.yaml).
#
# Runbook:
# 1. docker compose -f <repo>/edu_master/compose.yaml stop # SIGTERM -> redis сбросит dump.rdb
# 1. docker compose -f <repo>/edu_master/compose.yaml stop # SIGTERM -> redis will flush dump.rdb
# 2. docker run --rm -v edu_master_redis-data:/data \
# -v /tmp/edu-master-backup:/backup \
# redis:alpine sh -c "cp /data/dump.rdb /backup/ && ls -la /backup"
# 3. kubectl apply -f edu_master/k8s/namespace.yaml
# 4. kubectl apply -f <только PVC из redis.yaml> # seed строго ДО старта redis-пода
# 4. kubectl apply -f <only the PVC from redis.yaml> # seed must come BEFORE redis pod starts
# 5. kubectl apply -f edu_master/k8s/restore-seed-job.yaml.example
# kubectl wait --for=condition=complete job/redis-restore-seed -n edu-master --timeout=120s
# 6. kubectl delete job redis-restore-seed -n edu-master
# 7. kubectl apply -f edu_master/k8s/ -R # остальные манифесты
# 7. kubectl apply -f edu_master/k8s/ -R # apply remaining manifests
apiVersion: batch/v1
kind: Job
metadata:
@@ -47,4 +47,4 @@ spec:
- name: backup
hostPath:
path: /tmp/edu-master-backup
type: DirectoryOrCreate
type: DirectoryOrCreate
+3 -2
View File
@@ -5,7 +5,7 @@ metadata:
namespace: edu-master
type: Opaque
stringData:
# PHPSESSID keeper
# Session keeper credentials
KEEPER_LOGIN: ""
KEEPER_PASSWORD: ""
KEEPER_INTERVAL: "10"
@@ -23,4 +23,5 @@ stringData:
WEBINAR_CHECK_INTERVAL: "60"
# Database
REDIS_HOST: "redis"
REDIS_PORT: "6379"
REDIS_PORT: "6379"
TZ: "Europe/Kyiv"
+5 -2
View File
@@ -15,9 +15,9 @@ spec:
labels:
app: edu-master-webinar-checker
spec:
# Enforces dependency order like compose depends_on:
# redis healthy -> session-keeper healthy (EXISTS EDU_PHPSESSID) -> playwright started
initContainers:
# Держит порядок зависимостей как в compose depends_on:
# redis healthy -> session-keeper healthy (EXISTS EDU_PHPSESSID) -> playwright started
- name: wait-deps
image: redis:alpine
command:
@@ -50,6 +50,9 @@ spec:
envFrom:
- secretRef:
name: edu-master-secrets
env:
- name: TZ
value: "Europe/Kyiv"
resources:
requests:
cpu: 25m
File diff suppressed because it is too large Load Diff
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+25 -7
View File
@@ -3,7 +3,7 @@ hostNetwork: false
image:
registry: docker.io/library
repository: traefik
tag: v3.7.6
tag: v3.7.13
securityContext:
capabilities:
@@ -44,6 +44,7 @@ ports:
web:
port: 80
http:
aliasHeadersStrategy: delete
redirections:
entryPoint:
to: websecure
@@ -52,32 +53,54 @@ ports:
websecure:
port: 443
http:
aliasHeadersStrategy: delete
tls:
enabled: true
traefik:
port: 8080
exposedPort: 8080
protocol: TCP
expose:
default: false
http:
aliasHeadersStrategy: delete
ssh:
port: 2221
exposedPort: 2221
protocol: TCP
expose:
default: true
http:
aliasHeadersStrategy: delete
minecraft-tcp:
port: 25565
exposedPort: 25565
protocol: TCP
expose:
default: true
http:
aliasHeadersStrategy: delete
minecraft-udp:
port: 19132
exposedPort: 19132
protocol: UDP
expose:
default: true
checkmk-agent:
port: 8000
protocol: TCP
expose:
default: true
http:
aliasHeadersStrategy: delete
metrics:
port: 9100
exposedPort: 9100
protocol: TCP
expose:
default: false
http:
aliasHeadersStrategy: delete
metrics:
prometheus:
@@ -122,11 +145,6 @@ additionalArguments:
- "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true"
- "--providers.kubernetesCRD.allowCrossNamespace=true"
- "--providers.kubernetesCRD.safeNaming=false"
- "--entryPoints.websecure.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,192.168.1.1,192.168.1.0/24,192.168.88.0/24,192.168.88.1"
- "--entryPoints.web.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,192.168.1.1,192.168.1.0/24,192.168.88.0/24,192.168.88.1"
log:
level: INFO
accessLog:
enabled: true
format: common
View File
View File
+1 -3
View File
@@ -3,7 +3,5 @@ kind: Kustomization
resources:
- userbots.yaml
- common-secret.yaml
- panel.yaml
- common-config.yaml
- forust-secrets.yaml
- anna-secrets.yaml
+264
View File
@@ -0,0 +1,264 @@
apiVersion: v1
kind: Namespace
metadata:
name: userbot
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: userbot-panel
namespace: userbot
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: userbot-runtime
namespace: userbot
automountServiceAccountToken: false
---
apiVersion: v1
kind: ConfigMap
metadata:
name: userbot-common-config
namespace: userbot
data:
DATABASE_TYPE: ""
DATABASE_NAME: ""
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: userbot-panel
namespace: userbot
rules:
- apiGroups:
- apps
resources:
- deployments
verbs:
- get
- list
- watch
- create
- patch
- delete
- apiGroups:
- apps
resources:
- deployments/scale
verbs:
- get
- patch
- update
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- watch
- apiGroups:
- ""
resources:
- pods/log
verbs:
- get
- apiGroups:
- ""
resources:
- persistentvolumeclaims
verbs:
- get
- list
- watch
- create
- delete
- apiGroups:
- ""
resources:
- secrets
verbs:
- get
- create
- delete
- apiGroups:
- ""
resources:
- configmaps
verbs:
- get
- apiGroups:
- metrics.k8s.io
resources:
- pods
verbs:
- get
- list
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: userbot-panel
namespace: userbot
subjects:
- kind: ServiceAccount
name: userbot-panel
namespace: userbot
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: userbot-panel
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: userbot-panel-legacy
namespace: default
rules:
- apiGroups:
- apps
resources:
- deployments
verbs:
- get
- list
- watch
- patch
- apiGroups:
- apps
resources:
- deployments/scale
verbs:
- get
- patch
- update
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- watch
- apiGroups:
- ""
resources:
- pods/log
verbs:
- get
- apiGroups:
- ""
resources:
- persistentvolumeclaims
verbs:
- get
- list
- apiGroups:
- metrics.k8s.io
resources:
- pods
verbs:
- get
- list
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: userbot-panel-legacy
namespace: default
subjects:
- kind: ServiceAccount
name: userbot-panel
namespace: userbot
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: userbot-panel-legacy
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: userbot-panel
namespace: userbot
labels:
app: userbot-panel
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: userbot-panel
template:
metadata:
labels:
app: userbot-panel
spec:
serviceAccountName: userbot-panel
containers:
- name: userbot-panel
image: gcr.forust.xyz/forust/userbot-panel:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 8080
env:
- name: USERBOT_NAMESPACE
value: userbot
- name: USERBOT_LEGACY_NAMESPACES
value: default
- name: USERBOT_IMAGE
value: gcr.forust.xyz/forust/userbot:latest
- name: USERBOT_STORAGE_CLASS
value: local-path-retain
- name: USERBOT_DOWNLOADS_HOST_PATH
value: /srv/homelab/userbot/Downloads
resources:
requests:
cpu: 20m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 10
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: userbot-panel
namespace: userbot
spec:
type: ClusterIP
selector:
app: userbot-panel
ports:
- name: http
port: 8080
targetPort: http
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: userbot-panel-route
namespace: userbot
spec:
entryPoints:
- websecure
routes:
- match: Host(`userbot.workstation.internal`)
kind: Rule
services:
- name: userbot-panel
port: 8080
+30 -2
View File
@@ -4,8 +4,14 @@ metadata:
name: forust-userbot-deployment
labels:
app: forust-userbot
app.kubernetes.io/name: userbot
app.kubernetes.io/instance: forust
annotations:
userbot.forust.xyz/display-name: forust
userbot.forust.xyz/legacy: "true"
userbot.forust.xyz/credentials-secret: userbot-forust-secrets
userbot.forust.xyz/pvc: forust-pvc
spec:
replicas: 1
selector:
matchLabels:
app: forust-userbot
@@ -13,6 +19,8 @@ spec:
metadata:
labels:
app: forust-userbot
annotations:
kubectl.kubernetes.io/restartedAt: "2026-07-25T20:24:51+02:00"
spec:
containers:
- name: forust-userbot
@@ -33,6 +41,8 @@ spec:
- secretRef:
name: userbot-forust-secrets
volumeMounts:
- name: downloads
mountPath: /app/downloads
- name: forust-storage
mountPath: /app/data
subPath: data
@@ -40,6 +50,10 @@ spec:
mountPath: /app/logs
subPath: logs
volumes:
- name: downloads
hostPath:
path: /srv/homelab/userbot/Downloads
type: DirectoryOrCreate
- name: forust-storage
persistentVolumeClaim:
claimName: forust-pvc
@@ -62,8 +76,14 @@ metadata:
name: anna-userbot-deployment
labels:
app: anna-userbot
app.kubernetes.io/name: userbot
app.kubernetes.io/instance: anna
annotations:
userbot.forust.xyz/display-name: anna
userbot.forust.xyz/legacy: "true"
userbot.forust.xyz/credentials-secret: userbot-anna-secrets
userbot.forust.xyz/pvc: anna-pvc
spec:
replicas: 1
selector:
matchLabels:
app: anna-userbot
@@ -71,6 +91,8 @@ spec:
metadata:
labels:
app: anna-userbot
annotations:
kubectl.kubernetes.io/restartedAt: "2026-07-25T20:24:51+02:00"
spec:
containers:
- name: anna-userbot
@@ -91,6 +113,8 @@ spec:
- secretRef:
name: userbot-anna-secrets
volumeMounts:
- name: downloads
mountPath: /app/downloads
- name: anna-storage
mountPath: /app/data
subPath: data
@@ -98,6 +122,10 @@ spec:
mountPath: /app/logs
subPath: logs
volumes:
- name: downloads
hostPath:
path: /srv/homelab/userbot/Downloads
type: DirectoryOrCreate
- name: anna-storage
persistentVolumeClaim:
claimName: anna-pvc
+7
View File
@@ -0,0 +1,7 @@
backend/.venv
frontend/node_modules
frontend/dist
__pycache__
.pytest_cache
.ruff_cache
*.pyc
+7
View File
@@ -0,0 +1,7 @@
backend/.venv/
frontend/node_modules/
frontend/dist/
.pytest_cache/
.ruff_cache/
__pycache__/
*.pyc
+32
View File
@@ -0,0 +1,32 @@
FROM node:22-alpine AS frontend
WORKDIR /src
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM python:3.13-slim AS python-builder
WORKDIR /build
COPY backend/requirements.txt ./
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& pip install --no-cache-dir --prefix=/install -r requirements.txt \
&& rm -rf /var/lib/apt/lists/*
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PANEL_STATIC_DIR=/app/static
WORKDIR /app
COPY --from=python-builder /install /usr/local
COPY backend/app ./app
COPY --from=frontend /src/dist ./static
USER 65532:65532
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
+43
View File
@@ -0,0 +1,43 @@
# Userbot Control Panel
Локальная панель управляет userbot-инстансами напрямую через Kubernetes API.
Новые аккаунты создаются в namespace `userbot`; существующие `forust` и `anna`
остаются в `default` и принимаются под управление только через metadata labels.
## Первый запуск
Скопировать общий Secret в новый namespace, не печатая его значения:
```bash
kubectl create namespace userbot --dry-run=client -o yaml | kubectl apply -f -
kubectl get secret userbot-common-secrets -n default -o json \
| jq 'del(.metadata.annotations,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.uid,.metadata.managedFields) | .metadata.namespace = "userbot"' \
| kubectl apply -f -
```
Затем применить манифесты:
```bash
kubectl apply -f userbot/k8s/base/panel.yaml
kubectl apply -f userbot/k8s/base/userbots.yaml
kubectl rollout status deployment/userbot-panel -n userbot
```
Панель доступна только через внутренний Traefik route:
`https://userbot.workstation.internal`.
## Локальная разработка
```bash
cd userbot/panel/frontend
npm install
npm run dev
```
Backend использует in-cluster config, а вне кластера — текущий kubeconfig:
```bash
cd userbot/panel/backend
uv run --with-requirements requirements-dev.txt \
uvicorn app.main:app --reload --port 8080
```
+1
View File
@@ -0,0 +1 @@
"""Userbot Kubernetes control panel backend."""
+209
View File
@@ -0,0 +1,209 @@
from __future__ import annotations
import asyncio
import secrets
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pyrogram import Client
from pyrogram.errors import (
ApiIdInvalid,
FloodWait,
PasswordHashInvalid,
PhoneCodeExpired,
PhoneCodeInvalid,
PhoneNumberInvalid,
RPCError,
SessionPasswordNeeded,
Unauthorized,
)
from .errors import PanelError
from .models import AccountBase, PhoneStart, StringSessionStart
@dataclass
class AuthFlow:
flow_id: str
account: PhoneStart
client: Client
phone_code_hash: str
expires_at: datetime
@dataclass
class AuthorizedAccount:
account: AccountBase
session_string: str
class TelegramAuthService:
def __init__(self, ttl_seconds: int = 600, max_flows: int = 50) -> None:
self.ttl = timedelta(seconds=ttl_seconds)
self.max_flows = max_flows
self.flows: dict[str, AuthFlow] = {}
self._lock = asyncio.Lock()
async def start_phone(self, account: PhoneStart) -> str:
await self._cleanup_expired()
async with self._lock:
if len(self.flows) >= self.max_flows:
raise PanelError(
429,
"Too many pending authorization flows; try again later",
)
flow_id = secrets.token_urlsafe(24)
telegram = Client(
f"auth-{flow_id}",
api_id=account.api_id,
api_hash=account.api_hash,
in_memory=True,
no_updates=True,
)
try:
await telegram.connect()
sent = await telegram.send_code(account.phone)
except Exception as exc:
await self._disconnect(telegram)
raise self._translate(exc) from exc
flow = AuthFlow(
flow_id=flow_id,
account=account,
client=telegram,
phone_code_hash=sent.phone_code_hash,
expires_at=datetime.now(UTC) + self.ttl,
)
async with self._lock:
self.flows[flow_id] = flow
return flow_id
async def submit_code(
self,
flow_id: str,
code: str,
) -> AuthorizedAccount | None:
flow = await self._get(flow_id)
try:
await flow.client.sign_in(
flow.account.phone,
flow.phone_code_hash,
code,
)
except SessionPasswordNeeded:
return None
except PhoneCodeExpired as exc:
await self._discard(flow)
raise self._translate(exc) from exc
except Exception as exc:
raise self._translate(exc) from exc
return await self._finish(flow)
async def submit_password(
self,
flow_id: str,
password: str,
) -> AuthorizedAccount:
flow = await self._get(flow_id)
try:
await flow.client.check_password(password)
except Exception as exc:
raise self._translate(exc) from exc
return await self._finish(flow)
async def validate_string_session(
self,
account: StringSessionStart,
) -> AuthorizedAccount:
telegram = Client(
f"validate-{secrets.token_urlsafe(12)}",
api_id=account.api_id,
api_hash=account.api_hash,
session_string=account.session_string,
in_memory=True,
no_updates=True,
)
try:
await telegram.connect()
await telegram.get_me()
session_string = await telegram.export_session_string()
except Exception as exc:
raise self._translate(exc, session=True) from exc
finally:
await self._disconnect(telegram)
return AuthorizedAccount(account=account, session_string=session_string)
async def close(self) -> None:
async with self._lock:
flows = list(self.flows.values())
self.flows.clear()
await asyncio.gather(
*(self._disconnect(flow.client) for flow in flows),
return_exceptions=True,
)
async def _get(self, flow_id: str) -> AuthFlow:
await self._cleanup_expired()
async with self._lock:
flow = self.flows.get(flow_id)
if flow is None:
raise PanelError(410, "Authorization flow expired; start again")
return flow
async def _finish(self, flow: AuthFlow) -> AuthorizedAccount:
try:
await flow.client.get_me()
session_string = await flow.client.export_session_string()
except Exception as exc:
raise self._translate(exc) from exc
finally:
async with self._lock:
self.flows.pop(flow.flow_id, None)
await self._disconnect(flow.client)
return AuthorizedAccount(account=flow.account, session_string=session_string)
async def _discard(self, flow: AuthFlow) -> None:
async with self._lock:
self.flows.pop(flow.flow_id, None)
await self._disconnect(flow.client)
async def _cleanup_expired(self) -> None:
now = datetime.now(UTC)
async with self._lock:
expired = [
self.flows.pop(flow_id)
for flow_id, flow in list(self.flows.items())
if flow.expires_at <= now
]
if expired:
await asyncio.gather(
*(self._disconnect(flow.client) for flow in expired),
return_exceptions=True,
)
@staticmethod
async def _disconnect(telegram: Client) -> None:
with suppress(Exception):
if telegram.is_connected:
await telegram.disconnect()
@staticmethod
def _translate(exc: Exception, *, session: bool = False) -> PanelError:
if isinstance(exc, FloodWait):
return PanelError(429, f"Telegram rate limit; retry in {exc.value} seconds")
if isinstance(exc, ApiIdInvalid):
return PanelError(422, "Telegram API ID or API Hash is invalid")
if isinstance(exc, PhoneNumberInvalid):
return PanelError(422, "Phone number is invalid")
if isinstance(exc, PhoneCodeInvalid):
return PanelError(422, "Telegram code is invalid")
if isinstance(exc, PhoneCodeExpired):
return PanelError(410, "Telegram code expired; start again")
if isinstance(exc, PasswordHashInvalid):
return PanelError(422, "2FA password is invalid")
if session and isinstance(exc, (Unauthorized, RPCError)):
return PanelError(422, "StringSession is invalid or expired")
if isinstance(exc, RPCError):
return PanelError(422, "Telegram rejected the authorization request")
return PanelError(503, "Telegram authorization is unavailable")
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Settings:
namespace: str = os.environ.get("USERBOT_NAMESPACE", "userbot")
legacy_namespaces: tuple[str, ...] = tuple(
value.strip()
for value in os.environ.get("USERBOT_LEGACY_NAMESPACES", "default").split(",")
if value.strip()
)
image: str = os.environ.get(
"USERBOT_IMAGE",
"gcr.forust.xyz/forust/userbot:latest",
)
common_secret: str = os.environ.get(
"USERBOT_COMMON_SECRET",
"userbot-common-secrets",
)
common_config: str = os.environ.get(
"USERBOT_COMMON_CONFIG",
"userbot-common-config",
)
storage_class: str = os.environ.get(
"USERBOT_STORAGE_CLASS",
"local-path-retain",
)
downloads_host_path: str = os.environ.get(
"USERBOT_DOWNLOADS_HOST_PATH",
"/srv/homelab/userbot/Downloads",
)
static_dir: Path = Path(os.environ.get("PANEL_STATIC_DIR", "/app/static"))
auth_ttl_seconds: int = int(os.environ.get("PANEL_AUTH_TTL_SECONDS", "600"))
default_storage: str = os.environ.get("USERBOT_DEFAULT_STORAGE", "1Gi")
default_cpu_limit: str = os.environ.get("USERBOT_DEFAULT_CPU_LIMIT", "300m")
default_memory_limit: str = os.environ.get(
"USERBOT_DEFAULT_MEMORY_LIMIT",
"1536Mi",
)
settings = Settings()
+5
View File
@@ -0,0 +1,5 @@
class PanelError(Exception):
def __init__(self, status_code: int, detail: str) -> None:
super().__init__(detail)
self.status_code = status_code
self.detail = detail
@@ -0,0 +1,544 @@
from __future__ import annotations
import logging
from datetime import UTC, datetime
from threading import Lock
from typing import Any
from kubernetes import client, config
from kubernetes.client.exceptions import ApiException
from .config import Settings
from .errors import PanelError
from .models import AccountBase, InstanceSummary
logger = logging.getLogger(__name__)
MANAGED_LABEL = "app.kubernetes.io/name=userbot"
INSTANCE_LABEL = "app.kubernetes.io/instance"
MANAGED_BY_LABEL = "app.kubernetes.io/managed-by"
DISPLAY_ANNOTATION = "userbot.forust.xyz/display-name"
LEGACY_ANNOTATION = "userbot.forust.xyz/legacy"
CREDENTIALS_ANNOTATION = "userbot.forust.xyz/credentials-secret"
PVC_ANNOTATION = "userbot.forust.xyz/pvc"
RESTART_ANNOTATION = "userbot.forust.xyz/restarted-at"
def _selector(labels: dict[str, str] | None) -> str:
return ",".join(f"{key}={value}" for key, value in (labels or {}).items())
def _as_datetime(value: Any) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
return value
return getattr(value, "replace", lambda **_: None)(tzinfo=UTC)
class KubernetesService:
def __init__(
self,
settings: Settings,
*,
core: client.CoreV1Api | None = None,
apps: client.AppsV1Api | None = None,
custom: client.CustomObjectsApi | None = None,
) -> None:
self.settings = settings
if core is None or apps is None:
try:
config.load_incluster_config()
except config.ConfigException:
try:
config.load_kube_config()
except Exception as exc:
raise PanelError(
503,
"No in-cluster or kubeconfig configuration is available",
) from exc
self.core = core or client.CoreV1Api()
self.apps = apps or client.AppsV1Api()
self.custom = custom or client.CustomObjectsApi()
self._provision_lock = Lock()
def ensure_prerequisites(self) -> None:
try:
self.core.read_namespaced_secret(
self.settings.common_secret,
self.settings.namespace,
)
self.core.read_namespaced_config_map(
self.settings.common_config,
self.settings.namespace,
)
except ApiException as exc:
if exc.status == 404:
raise PanelError(
503,
"Userbot common Secret or ConfigMap is missing in the userbot namespace",
) from exc
raise self._api_error(exc, "Could not verify userbot prerequisites") from exc
def list_instances(
self,
query: str = "",
status: str = "",
) -> list[InstanceSummary]:
instances: list[InstanceSummary] = []
for namespace in (self.settings.namespace, *self.settings.legacy_namespaces):
try:
deployments = self.apps.list_namespaced_deployment(
namespace,
label_selector=MANAGED_LABEL,
).items
except ApiException as exc:
raise self._api_error(exc, f"Could not list Deployments in {namespace}") from exc
instances.extend(self._summarize(namespace, deployment) for deployment in deployments)
query = query.strip().lower()
if query:
instances = [
item
for item in instances
if query in item.instance_id.lower()
or query in item.display_name.lower()
or query in (item.pod or "").lower()
]
if status:
instances = [item for item in instances if item.status == status]
return sorted(instances, key=lambda item: (item.legacy, item.instance_id))
def get_instance(self, instance_id: str) -> InstanceSummary:
namespace, deployment = self._find_deployment(instance_id)
return self._summarize(namespace, deployment)
def assert_available(self, instance_id: str) -> None:
names = self._resource_names(instance_id)
checks = (
(self.apps.read_namespaced_deployment, names["deployment"], "Deployment"),
(self.core.read_namespaced_secret, names["secret"], "Secret"),
(self.core.read_namespaced_persistent_volume_claim, names["pvc"], "PVC"),
)
for read, name, kind in checks:
try:
read(name, self.settings.namespace)
except ApiException as exc:
if exc.status == 404:
continue
raise self._api_error(exc, f"Could not check {kind} {name}") from exc
raise PanelError(409, f"{kind} {name} already exists")
def provision(self, account: AccountBase, session_string: str) -> InstanceSummary:
with self._provision_lock:
self.assert_available(account.instance_id)
names = self._resource_names(account.instance_id)
created: list[tuple[str, str]] = []
try:
self.core.create_namespaced_secret(
self.settings.namespace,
self._secret(account, session_string, names),
)
created.append(("secret", names["secret"]))
self.core.create_namespaced_persistent_volume_claim(
self.settings.namespace,
self._pvc(account, names),
)
created.append(("pvc", names["pvc"]))
self.apps.create_namespaced_deployment(
self.settings.namespace,
self._deployment(account, names),
)
created.append(("deployment", names["deployment"]))
except ApiException as exc:
self._rollback(created)
if exc.status == 409:
raise PanelError(
409,
f"Instance {account.instance_id} already exists",
) from exc
raise self._api_error(exc, "Could not create userbot instance") from exc
return self.get_instance(account.instance_id)
def scale(self, instance_id: str, replicas: int) -> InstanceSummary:
namespace, deployment = self._find_deployment(instance_id)
try:
self.apps.patch_namespaced_deployment_scale(
deployment.metadata.name,
namespace,
{"spec": {"replicas": replicas}},
)
except ApiException as exc:
raise self._api_error(exc, "Could not scale userbot instance") from exc
return self.get_instance(instance_id)
def restart(self, instance_id: str) -> InstanceSummary:
namespace, deployment = self._find_deployment(instance_id)
if (deployment.spec.replicas or 0) == 0:
raise PanelError(409, "Stopped instance cannot be restarted")
timestamp = datetime.now(UTC).isoformat()
try:
self.apps.patch_namespaced_deployment(
deployment.metadata.name,
namespace,
{
"spec": {
"template": {
"metadata": {
"annotations": {RESTART_ANNOTATION: timestamp},
}
}
}
},
)
except ApiException as exc:
raise self._api_error(exc, "Could not restart userbot instance") from exc
return self.get_instance(instance_id)
def logs(self, instance_id: str, tail: int = 250) -> str:
namespace, deployment = self._find_deployment(instance_id)
pods = self._pods_for_deployment(namespace, deployment)
if not pods:
raise PanelError(409, "Userbot Pod is not running")
pod = pods[0]
container = deployment.spec.template.spec.containers[0].name
try:
return self.core.read_namespaced_pod_log(
pod.metadata.name,
namespace,
container=container,
tail_lines=max(1, min(tail, 1000)),
timestamps=True,
)
except ApiException as exc:
raise self._api_error(exc, "Could not read userbot logs") from exc
def delete(self, instance_id: str, *, delete_data: bool) -> None:
namespace, deployment = self._find_deployment(instance_id)
annotations = deployment.metadata.annotations or {}
if annotations.get(LEGACY_ANNOTATION) == "true" or namespace != self.settings.namespace:
raise PanelError(409, "Legacy instances cannot be deleted from the panel")
names = self._resource_names(instance_id)
secret_name = annotations.get(CREDENTIALS_ANNOTATION, names["secret"])
pvc_name = annotations.get(PVC_ANNOTATION, names["pvc"])
operations = [
(
self.apps.delete_namespaced_deployment,
(deployment.metadata.name, namespace),
{"propagation_policy": "Foreground"},
),
(self.core.delete_namespaced_secret, (secret_name, namespace), {}),
]
if delete_data:
operations.append(
(
self.core.delete_namespaced_persistent_volume_claim,
(pvc_name, namespace),
{},
)
)
for delete_resource, args, kwargs in operations:
try:
delete_resource(*args, **kwargs)
except ApiException as exc:
if exc.status != 404:
raise self._api_error(exc, "Could not delete userbot instance") from exc
def _find_deployment(self, instance_id: str) -> tuple[str, Any]:
selector = f"{MANAGED_LABEL},{INSTANCE_LABEL}={instance_id}"
for namespace in (self.settings.namespace, *self.settings.legacy_namespaces):
try:
items = self.apps.list_namespaced_deployment(
namespace,
label_selector=selector,
).items
except ApiException as exc:
raise self._api_error(exc, "Could not find userbot instance") from exc
if items:
return namespace, items[0]
raise PanelError(404, f"Instance {instance_id} does not exist")
def _summarize(self, namespace: str, deployment: Any) -> InstanceSummary:
labels = deployment.metadata.labels or {}
annotations = deployment.metadata.annotations or {}
instance_id = labels.get(INSTANCE_LABEL, deployment.metadata.name)
legacy = annotations.get(LEGACY_ANNOTATION) == "true"
pods = self._pods_for_deployment(namespace, deployment)
pod = pods[0] if pods else None
desired = deployment.spec.replicas or 0
ready = False
reason: str | None = None
restarts = 0
updated_at = deployment.metadata.creation_timestamp
if pod is not None:
statuses = pod.status.container_statuses or []
ready = bool(statuses) and all(item.ready for item in statuses)
restarts = sum(item.restart_count for item in statuses)
for item in statuses:
state = item.state
if state and state.waiting and state.waiting.reason:
reason = state.waiting.reason
break
if state and state.terminated and state.terminated.reason:
reason = state.terminated.reason
break
updated_at = pod.status.start_time or pod.metadata.creation_timestamp
if desired == 0:
status = "stopped"
elif reason in {
"CrashLoopBackOff",
"Error",
"ImagePullBackOff",
"ErrImagePull",
"CreateContainerConfigError",
"RunContainerError",
} or (pod is not None and pod.status.phase == "Failed"):
status = "error"
elif ready and (deployment.status.available_replicas or 0) > 0:
status = "running"
else:
status = "pending"
reason = reason or (pod.status.phase if pod is not None else "Scheduling")
container_spec = deployment.spec.template.spec.containers[0]
limits = (container_spec.resources.limits or {}) if container_spec.resources else {}
pvc_name = annotations.get(PVC_ANNOTATION) or self._deployment_pvc(deployment)
storage = self._pvc_storage(namespace, pvc_name)
cpu_usage, memory_usage = self._pod_metrics(namespace, pod.metadata.name if pod else None)
return InstanceSummary(
instance_id=instance_id,
display_name=annotations.get(DISPLAY_ANNOTATION, instance_id),
namespace=namespace,
deployment=deployment.metadata.name,
pod=pod.metadata.name if pod else None,
status=status,
reason=reason,
ready=ready,
restarts=restarts,
pvc=pvc_name,
storage=storage,
image=container_spec.image,
cpu_limit=limits.get("cpu"),
memory_limit=limits.get("memory"),
cpu_usage=cpu_usage,
memory_usage=memory_usage,
updated_at=_as_datetime(updated_at),
legacy=legacy,
deletable=not legacy and namespace == self.settings.namespace,
)
def _pods_for_deployment(self, namespace: str, deployment: Any) -> list[Any]:
selector = _selector(deployment.spec.selector.match_labels)
try:
pods = self.core.list_namespaced_pod(
namespace,
label_selector=selector,
).items
except ApiException as exc:
raise self._api_error(exc, "Could not list userbot Pods") from exc
return sorted(
pods,
key=lambda pod: pod.metadata.creation_timestamp or datetime.min.replace(tzinfo=UTC),
reverse=True,
)
def _pod_metrics(self, namespace: str, pod_name: str | None) -> tuple[str | None, str | None]:
if not pod_name:
return None, None
try:
metrics = self.custom.get_namespaced_custom_object(
"metrics.k8s.io",
"v1beta1",
namespace,
"pods",
pod_name,
)
except (ApiException, AttributeError):
return None, None
containers = metrics.get("containers", [])
cpu = containers[0].get("usage", {}).get("cpu") if containers else None
memory = containers[0].get("usage", {}).get("memory") if containers else None
return cpu, memory
def _pvc_storage(self, namespace: str, pvc_name: str | None) -> str | None:
if not pvc_name:
return None
try:
pvc = self.core.read_namespaced_persistent_volume_claim(pvc_name, namespace)
except ApiException:
return None
requests = pvc.spec.resources.requests or {}
return requests.get("storage")
@staticmethod
def _deployment_pvc(deployment: Any) -> str | None:
for volume in deployment.spec.template.spec.volumes or []:
if volume.persistent_volume_claim:
return volume.persistent_volume_claim.claim_name
return None
@staticmethod
def _resource_names(instance_id: str) -> dict[str, str]:
return {
"deployment": f"userbot-{instance_id}",
"secret": f"userbot-{instance_id}-credentials",
"pvc": f"userbot-{instance_id}-data",
}
def _metadata(
self,
account: AccountBase,
names: dict[str, str],
resource_name: str,
) -> client.V1ObjectMeta:
return client.V1ObjectMeta(
name=resource_name,
namespace=self.settings.namespace,
labels={
"app.kubernetes.io/name": "userbot",
INSTANCE_LABEL: account.instance_id,
MANAGED_BY_LABEL: "userbot-panel",
},
annotations={
DISPLAY_ANNOTATION: account.display_name,
CREDENTIALS_ANNOTATION: names["secret"],
PVC_ANNOTATION: names["pvc"],
},
)
def _secret(
self,
account: AccountBase,
session_string: str,
names: dict[str, str],
) -> client.V1Secret:
return client.V1Secret(
metadata=self._metadata(account, names, names["secret"]),
type="Opaque",
string_data={
"API_ID": str(account.api_id),
"API_HASH": account.api_hash,
"STRINGSESSION": session_string,
},
)
def _pvc(
self,
account: AccountBase,
names: dict[str, str],
) -> client.V1PersistentVolumeClaim:
return client.V1PersistentVolumeClaim(
metadata=self._metadata(account, names, names["pvc"]),
spec=client.V1PersistentVolumeClaimSpec(
access_modes=["ReadWriteOnce"],
storage_class_name=self.settings.storage_class,
resources=client.V1VolumeResourceRequirements(
requests={"storage": account.resources.storage},
),
),
)
def _deployment(
self,
account: AccountBase,
names: dict[str, str],
) -> client.V1Deployment:
pod_labels = {
"app.kubernetes.io/name": "userbot",
INSTANCE_LABEL: account.instance_id,
MANAGED_BY_LABEL: "userbot-panel",
}
container = client.V1Container(
name="userbot",
image=self.settings.image,
image_pull_policy="Always",
env_from=[
client.V1EnvFromSource(
secret_ref=client.V1SecretEnvSource(name=self.settings.common_secret)
),
client.V1EnvFromSource(
config_map_ref=client.V1ConfigMapEnvSource(name=self.settings.common_config)
),
client.V1EnvFromSource(
secret_ref=client.V1SecretEnvSource(name=names["secret"])
),
],
resources=client.V1ResourceRequirements(
requests={"cpu": "80m", "memory": "512Mi"},
limits={
"cpu": account.resources.cpu_limit,
"memory": account.resources.memory_limit,
},
),
volume_mounts=[
client.V1VolumeMount(name="data", mount_path="/app/data"),
client.V1VolumeMount(name="downloads", mount_path="/app/downloads"),
],
)
pod_spec = client.V1PodSpec(
service_account_name="userbot-runtime",
automount_service_account_token=False,
containers=[container],
termination_grace_period_seconds=30,
volumes=[
client.V1Volume(
name="data",
persistent_volume_claim=client.V1PersistentVolumeClaimVolumeSource(
claim_name=names["pvc"]
),
),
client.V1Volume(
name="downloads",
host_path=client.V1HostPathVolumeSource(
path=self.settings.downloads_host_path,
type="DirectoryOrCreate",
),
),
],
)
return client.V1Deployment(
metadata=self._metadata(account, names, names["deployment"]),
spec=client.V1DeploymentSpec(
replicas=1,
strategy=client.V1DeploymentStrategy(type="Recreate"),
selector=client.V1LabelSelector(match_labels=pod_labels),
template=client.V1PodTemplateSpec(
metadata=client.V1ObjectMeta(labels=pod_labels),
spec=pod_spec,
),
),
)
def _rollback(self, created: list[tuple[str, str]]) -> None:
for kind, name in reversed(created):
try:
if kind == "deployment":
self.apps.delete_namespaced_deployment(name, self.settings.namespace)
elif kind == "pvc":
self.core.delete_namespaced_persistent_volume_claim(
name,
self.settings.namespace,
)
else:
self.core.delete_namespaced_secret(name, self.settings.namespace)
except ApiException as exc:
logger.warning(
"Rollback of %s %s in %s failed: %s",
kind,
name,
self.settings.namespace,
exc,
)
@staticmethod
def _api_error(exc: ApiException, detail: str) -> PanelError:
if exc.status == 403:
return PanelError(503, f"{detail}: Kubernetes RBAC denied the operation")
if exc.status == 409:
return PanelError(409, f"{detail}: resource conflict")
if exc.status == 404:
return PanelError(404, f"{detail}: resource not found")
return PanelError(503, detail)
+205
View File
@@ -0,0 +1,205 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import FastAPI, Query, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from .auth_service import TelegramAuthService
from .config import settings
from .errors import PanelError
from .kubernetes_service import KubernetesService
from .models import (
AuthResult,
CodeSubmit,
DeleteRequest,
InstanceSummary,
PasswordSubmit,
PhoneStart,
StringSessionStart,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.kubernetes = KubernetesService(settings)
app.state.telegram = TelegramAuthService(settings.auth_ttl_seconds)
try:
app.state.kubernetes.ensure_prerequisites()
except PanelError as exc:
print(f"WARNING: userbot prerequisites check failed at startup: {exc.detail}")
yield
await app.state.telegram.close()
app = FastAPI(
title="Userbot Kubernetes Control",
version="1.0.0",
lifespan=lifespan,
)
@app.exception_handler(PanelError)
async def panel_error_handler(_request: Request, exc: PanelError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
@app.exception_handler(RequestValidationError)
async def validation_error_handler(
_request: Request,
exc: RequestValidationError,
) -> JSONResponse:
errors = [
{
"loc": error.get("loc", ()),
"msg": error.get("msg", "Invalid value"),
"type": error.get("type", "value_error"),
}
for error in exc.errors()
]
return JSONResponse(status_code=422, content={"detail": errors})
def kube(request: Request) -> KubernetesService:
return request.app.state.kubernetes
def telegram(request: Request) -> TelegramAuthService:
return request.app.state.telegram
@app.get("/api/health")
def health(request: Request) -> dict[str, object]:
service = kube(request)
try:
service.apps.list_namespaced_deployment(
settings.namespace,
limit=1,
)
except Exception:
return {"ok": False, "kubernetes": False}
return {"ok": True, "kubernetes": True}
@app.get("/api/instances", response_model=list[InstanceSummary])
def list_instances(
request: Request,
query: str = "",
status: str = "",
) -> list[InstanceSummary]:
return kube(request).list_instances(query=query, status=status)
@app.get("/api/instances/{instance_id}", response_model=InstanceSummary)
def get_instance(instance_id: str, request: Request) -> InstanceSummary:
return kube(request).get_instance(instance_id)
@app.get("/api/instances/{instance_id}/logs")
def get_logs(
instance_id: str,
request: Request,
tail: Annotated[int, Query(ge=1, le=1000)] = 250,
) -> dict[str, str]:
return {"logs": kube(request).logs(instance_id, tail)}
@app.post("/api/instances/{instance_id}/start", response_model=InstanceSummary)
def start_instance(instance_id: str, request: Request) -> InstanceSummary:
return kube(request).scale(instance_id, 1)
@app.post("/api/instances/{instance_id}/stop", response_model=InstanceSummary)
def stop_instance(instance_id: str, request: Request) -> InstanceSummary:
return kube(request).scale(instance_id, 0)
@app.post("/api/instances/{instance_id}/restart", response_model=InstanceSummary)
def restart_instance(instance_id: str, request: Request) -> InstanceSummary:
return kube(request).restart(instance_id)
@app.post("/api/instances/{instance_id}/delete", status_code=204)
def delete_instance(
instance_id: str,
payload: DeleteRequest,
request: Request,
) -> Response:
if payload.confirmation != instance_id:
raise PanelError(422, "Type the instance id exactly to confirm deletion")
kube(request).delete(instance_id, delete_data=payload.delete_data)
return Response(status_code=204)
@app.post("/api/auth/phone/start", response_model=AuthResult)
async def auth_phone_start(
payload: PhoneStart,
request: Request,
) -> AuthResult:
service = kube(request)
service.assert_available(payload.instance_id)
flow_id = await telegram(request).start_phone(payload)
return AuthResult(status="code_required", flow_id=flow_id)
@app.post("/api/auth/phone/{flow_id}/code", response_model=AuthResult)
async def auth_phone_code(
flow_id: str,
payload: CodeSubmit,
request: Request,
) -> AuthResult:
authorized = await telegram(request).submit_code(flow_id, payload.code)
if authorized is None:
return AuthResult(status="password_required", flow_id=flow_id)
instance = kube(request).provision(authorized.account, authorized.session_string)
return AuthResult(status="ready", instance=instance)
@app.post("/api/auth/phone/{flow_id}/password", response_model=AuthResult)
async def auth_phone_password(
flow_id: str,
payload: PasswordSubmit,
request: Request,
) -> AuthResult:
authorized = await telegram(request).submit_password(flow_id, payload.password)
instance = kube(request).provision(authorized.account, authorized.session_string)
return AuthResult(status="ready", instance=instance)
@app.post("/api/auth/string-session", response_model=AuthResult)
async def auth_string_session(
payload: StringSessionStart,
request: Request,
) -> AuthResult:
service = kube(request)
service.assert_available(payload.instance_id)
authorized = await telegram(request).validate_string_session(payload)
instance = service.provision(authorized.account, authorized.session_string)
return AuthResult(status="ready", instance=instance)
static_dir = settings.static_dir
assets_dir = static_dir / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/", include_in_schema=False)
def index() -> FileResponse:
return FileResponse(static_dir / "index.html")
@app.get("/{path:path}", include_in_schema=False)
def spa_fallback(path: str) -> FileResponse:
root = static_dir.resolve()
candidate = (static_dir / path).resolve()
try:
candidate.relative_to(root)
except ValueError:
return FileResponse(static_dir / "index.html")
if candidate.is_file():
return FileResponse(candidate)
return FileResponse(static_dir / "index.html")
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, field_validator
INSTANCE_PATTERN = r"^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$"
class Resources(BaseModel):
storage: str = "1Gi"
cpu_limit: str = "300m"
memory_limit: str = "1536Mi"
class AccountBase(BaseModel):
instance_id: str = Field(pattern=INSTANCE_PATTERN, max_length=40)
display_name: str = Field(min_length=1, max_length=80)
api_id: int = Field(gt=0)
api_hash: str = Field(min_length=16, max_length=128)
resources: Resources = Field(default_factory=Resources)
@field_validator("display_name", "api_hash")
@classmethod
def strip_text(cls, value: str) -> str:
return value.strip()
class PhoneStart(AccountBase):
phone: str = Field(min_length=7, max_length=32)
@field_validator("phone")
@classmethod
def normalize_phone(cls, value: str) -> str:
value = value.strip()
if not value.startswith("+"):
raise ValueError("phone must use international format")
return value
class StringSessionStart(AccountBase):
session_string: str = Field(min_length=32)
class CodeSubmit(BaseModel):
code: str = Field(min_length=3, max_length=12)
@field_validator("code")
@classmethod
def normalize_code(cls, value: str) -> str:
return "".join(value.split())
class PasswordSubmit(BaseModel):
password: str = Field(min_length=1, max_length=256)
class DeleteRequest(BaseModel):
confirmation: str
delete_data: bool = False
class InstanceSummary(BaseModel):
instance_id: str
display_name: str
namespace: str
deployment: str
pod: str | None = None
status: Literal["running", "stopped", "pending", "error"]
reason: str | None = None
ready: bool = False
restarts: int = 0
pvc: str | None = None
storage: str | None = None
image: str
cpu_limit: str | None = None
memory_limit: str | None = None
cpu_usage: str | None = None
memory_usage: str | None = None
updated_at: datetime | None = None
legacy: bool = False
deletable: bool = True
class AuthResult(BaseModel):
status: Literal["code_required", "password_required", "ready"]
flow_id: str | None = None
instance: InstanceSummary | None = None
@@ -0,0 +1,4 @@
-r requirements.txt
httpx==0.28.1
pytest==8.4.1
pytest-asyncio==1.1.0
+5
View File
@@ -0,0 +1,5 @@
fastapi==0.115.12
kubernetes==33.1.0
pyrofork==2.3.68
tgcrypto==1.2.5
uvicorn[standard]==0.34.2
@@ -0,0 +1,101 @@
from types import SimpleNamespace
import pytest
from app.auth_service import TelegramAuthService
from app.models import PhoneStart, StringSessionStart
class FakeClient:
instances = []
requires_password = False
def __init__(self, *_args, **kwargs):
self.kwargs = kwargs
self.is_connected = False
self.disconnected = False
self.__class__.instances.append(self)
async def connect(self):
self.is_connected = True
async def disconnect(self):
self.is_connected = False
self.disconnected = True
async def send_code(self, _phone):
return SimpleNamespace(phone_code_hash="hash")
async def sign_in(self, *_args):
if self.requires_password:
from pyrogram.errors import SessionPasswordNeeded
raise SessionPasswordNeeded()
async def check_password(self, _password):
return None
async def get_me(self):
return SimpleNamespace(id=1)
async def export_session_string(self):
return "exported-session"
def phone_payload() -> PhoneStart:
return PhoneStart(
instance_id="test",
display_name="Test",
api_id=123,
api_hash="0123456789abcdef",
phone="+421900000000",
)
@pytest.mark.asyncio
async def test_phone_code_success_closes_and_forgets_client(monkeypatch) -> None:
monkeypatch.setattr("app.auth_service.Client", FakeClient)
auth = TelegramAuthService()
flow_id = await auth.start_phone(phone_payload())
result = await auth.submit_code(flow_id, "12345")
assert result.session_string == "exported-session"
assert flow_id not in auth.flows
assert FakeClient.instances[-1].disconnected is True
assert FakeClient.instances[-1].kwargs["in_memory"] is True
assert FakeClient.instances[-1].kwargs["no_updates"] is True
@pytest.mark.asyncio
async def test_string_session_is_validated_and_closed(monkeypatch) -> None:
monkeypatch.setattr("app.auth_service.Client", FakeClient)
auth = TelegramAuthService()
payload = StringSessionStart(
instance_id="test",
display_name="Test",
api_id=123,
api_hash="0123456789abcdef",
session_string="x" * 64,
)
result = await auth.validate_string_session(payload)
assert result.session_string == "exported-session"
assert FakeClient.instances[-1].disconnected is True
@pytest.mark.asyncio
async def test_start_phone_rejects_overflow(monkeypatch) -> None:
from app.errors import PanelError
monkeypatch.setattr("app.auth_service.Client", FakeClient)
auth = TelegramAuthService(ttl_seconds=3600, max_flows=2)
await auth.start_phone(phone_payload())
await auth.start_phone(phone_payload())
with pytest.raises(PanelError) as error:
await auth.start_phone(phone_payload())
assert error.value.status_code == 429
assert "Too many pending" in error.value.detail
@@ -0,0 +1,200 @@
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from app.config import Settings
from app.errors import PanelError
from app.kubernetes_service import KubernetesService
from app.models import AccountBase
from kubernetes.client.exceptions import ApiException
def service() -> KubernetesService:
core = Mock()
apps = Mock()
custom = Mock()
custom.get_namespaced_custom_object.side_effect = ApiException(status=404)
return KubernetesService(Settings(), core=core, apps=apps, custom=custom)
def account() -> AccountBase:
return AccountBase(
instance_id="test-account",
display_name="Test Account",
api_id=12345,
api_hash="0123456789abcdef0123456789abcdef",
)
def test_renders_managed_resources_without_leaking_credentials() -> None:
kube = service()
names = kube._resource_names("test-account")
secret = kube._secret(account(), "SESSION", names)
pvc = kube._pvc(account(), names)
deployment = kube._deployment(account(), names)
assert secret.string_data == {
"API_ID": "12345",
"API_HASH": "0123456789abcdef0123456789abcdef",
"STRINGSESSION": "SESSION",
}
assert pvc.spec.storage_class_name == "local-path-retain"
assert pvc.spec.resources.requests["storage"] == "1Gi"
assert deployment.spec.strategy.type == "Recreate"
assert deployment.spec.replicas == 1
assert deployment.spec.template.spec.automount_service_account_token is False
assert deployment.spec.template.spec.service_account_name == "userbot-runtime"
assert deployment.spec.template.spec.volumes[1].host_path.path.endswith("/Downloads")
assert deployment.spec.template.spec.containers[0].resources.requests == {
"cpu": "80m",
"memory": "512Mi",
}
def test_collision_is_reported_before_auth() -> None:
kube = service()
kube.apps.read_namespaced_deployment.return_value = object()
with pytest.raises(PanelError) as error:
kube.assert_available("test-account")
assert error.value.status_code == 409
assert "Deployment" in error.value.detail
def test_partial_provision_rolls_back_only_created_resources() -> None:
kube = service()
kube.ensure_prerequisites = Mock()
kube.assert_available = Mock()
kube.core.create_namespaced_persistent_volume_claim.side_effect = ApiException(status=500)
with pytest.raises(PanelError):
kube.provision(account(), "SESSION")
kube.core.delete_namespaced_secret.assert_called_once_with(
"userbot-test-account-credentials",
"userbot",
)
kube.core.delete_namespaced_persistent_volume_claim.assert_not_called()
kube.apps.delete_namespaced_deployment.assert_not_called()
def test_provision_conflict_reports_existing_instance() -> None:
kube = service()
kube.assert_available = Mock()
kube.apps.create_namespaced_deployment.side_effect = ApiException(status=409)
with pytest.raises(PanelError) as error:
kube.provision(account(), "SESSION")
assert error.value.status_code == 409
assert "already exists" in error.value.detail
# Partial resources created before the 409 must be rolled back.
kube.core.delete_namespaced_secret.assert_called_once_with(
"userbot-test-account-credentials",
"userbot",
)
kube.core.delete_namespaced_persistent_volume_claim.assert_called_once_with(
"userbot-test-account-data",
"userbot",
)
def test_delete_retains_pvc_unless_explicitly_requested() -> None:
kube = service()
deployment = SimpleNamespace(
metadata=SimpleNamespace(
name="userbot-test-account",
annotations={
"userbot.forust.xyz/credentials-secret": "credentials",
"userbot.forust.xyz/pvc": "data",
},
)
)
kube._find_deployment = Mock(return_value=("userbot", deployment))
kube.delete("test-account", delete_data=False)
kube.apps.delete_namespaced_deployment.assert_called_once()
kube.core.delete_namespaced_secret.assert_called_once_with("credentials", "userbot")
kube.core.delete_namespaced_persistent_volume_claim.assert_not_called()
kube.delete("test-account", delete_data=True)
kube.core.delete_namespaced_persistent_volume_claim.assert_called_once_with(
"data",
"userbot",
)
def test_legacy_delete_is_blocked() -> None:
kube = service()
deployment = SimpleNamespace(
metadata=SimpleNamespace(
name="forust-userbot-deployment",
annotations={"userbot.forust.xyz/legacy": "true"},
)
)
kube._find_deployment = Mock(return_value=("default", deployment))
with pytest.raises(PanelError) as error:
kube.delete("forust", delete_data=False)
assert error.value.status_code == 409
def pod(*, ready: bool, phase: str = "Running", reason: str | None = None):
waiting = SimpleNamespace(reason=reason) if reason else None
state = SimpleNamespace(waiting=waiting, terminated=None)
status = SimpleNamespace(
container_statuses=[
SimpleNamespace(ready=ready, restart_count=2, state=state),
],
phase=phase,
start_time=datetime.now(UTC),
)
return SimpleNamespace(
metadata=SimpleNamespace(name="pod-1", creation_timestamp=datetime.now(UTC)),
status=status,
)
def deployment(replicas: int = 1):
resources = SimpleNamespace(limits={"cpu": "300m", "memory": "1536Mi"})
container = SimpleNamespace(image="userbot:latest", resources=resources)
template_spec = SimpleNamespace(containers=[container], volumes=[])
return SimpleNamespace(
metadata=SimpleNamespace(
name="userbot-test-account",
labels={"app.kubernetes.io/instance": "test-account"},
annotations={},
creation_timestamp=datetime.now(UTC),
),
spec=SimpleNamespace(
replicas=replicas,
selector=SimpleNamespace(match_labels={"app": "test"}),
template=SimpleNamespace(spec=template_spec),
),
status=SimpleNamespace(available_replicas=1 if replicas else 0),
)
@pytest.mark.parametrize(
("replicas", "pod_value", "expected"),
[
(0, None, "stopped"),
(1, pod(ready=True), "running"),
(1, pod(ready=False, phase="Pending"), "pending"),
(1, pod(ready=False, reason="CrashLoopBackOff"), "error"),
(1, pod(ready=False, phase="Failed"), "error"),
],
)
def test_status_classification(replicas, pod_value, expected) -> None:
kube = service()
kube._pods_for_deployment = Mock(return_value=[pod_value] if pod_value else [])
kube._pvc_storage = Mock(return_value=None)
kube._pod_metrics = Mock(return_value=(None, None))
result = kube._summarize("userbot", deployment(replicas))
assert result.status == expected
@@ -0,0 +1,44 @@
import pytest
from app.main import validation_error_handler
from app.models import AccountBase, DeleteRequest
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError
@pytest.mark.parametrize(
"instance_id",
["Upper", "has_space", "-leading", "trailing-", "x" * 41],
)
def test_invalid_instance_ids(instance_id: str) -> None:
with pytest.raises(ValidationError):
AccountBase(
instance_id=instance_id,
display_name="Test",
api_id=1,
api_hash="0123456789abcdef",
)
def test_delete_data_defaults_to_false() -> None:
request = DeleteRequest(confirmation="test")
assert request.delete_data is False
@pytest.mark.asyncio
async def test_validation_response_does_not_echo_secret_input() -> None:
sensitive_value = "-".join(("very", "private", "string", "session"))
error = RequestValidationError(
[
{
"type": "string_too_short",
"loc": ("body", "session_string"),
"msg": "String should have at least 32 characters",
"input": sensitive_value,
}
]
)
response = await validation_error_handler(None, error)
assert sensitive_value.encode() not in response.body
assert b'"input"' not in response.body
+48
View File
@@ -0,0 +1,48 @@
from pathlib import Path
import pytest
from app.main import spa_fallback
@pytest.fixture
def static_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
static = tmp_path / "static"
static.mkdir()
(static / "index.html").write_text("<html>index</html>", encoding="utf-8")
(static / "app.js").write_text("console.log('app')", encoding="utf-8")
secret = tmp_path / "secret.txt"
secret.write_text("TOP SECRET", encoding="utf-8")
monkeypatch.setattr("app.main.static_dir", static)
return static
def static_index(static_dir: Path) -> Path:
return static_dir / "index.html"
def test_returns_existing_file(static_dir: Path) -> None:
response = spa_fallback("app.js")
assert response.path == static_dir / "app.js"
def test_unknown_path_falls_back_to_index(static_dir: Path) -> None:
response = spa_fallback("does/not/exist.js")
assert response.path == static_index(static_dir)
def test_traversal_does_not_leak_outside_static(static_dir: Path) -> None:
response = spa_fallback("../secret.txt")
assert response.path == static_index(static_dir)
response = spa_fallback("%2e%2e/secret.txt")
assert response.path == static_index(static_dir)
def test_symlink_outside_static_is_blocked(static_dir: Path, tmp_path: Path) -> None:
target = tmp_path / "outside.txt"
target.write_text("secret", encoding="utf-8")
link = static_dir / "leak.txt"
link.symlink_to(target)
response = spa_fallback("leak.txt")
assert response.path == static_index(static_dir)
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#080808" />
<title>Userbot · Kubernetes Control</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "userbot-panel",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite --host 0.0.0.0",
"test": "vitest run"
},
"dependencies": {
"lucide-svelte": "^0.468.0",
"svelte": "^5.16.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/svelte": "^5.2.6",
"@types/node": "^22.10.5",
"jsdom": "^25.0.1",
"svelte-check": "^4.7.3",
"typescript": "~5.7.2",
"vite": "^6.0.7",
"vitest": "^2.1.8"
}
}
+237
View File
@@ -0,0 +1,237 @@
<script lang="ts">
import {
Menu,
Plus,
RefreshCw,
Search,
Server,
} from 'lucide-svelte';
import { onMount } from 'svelte';
import AddAccountDrawer from './components/AddAccountDrawer.svelte';
import DetailsDrawer from './components/DetailsDrawer.svelte';
import InstanceTable from './components/InstanceTable.svelte';
import Sidebar from './components/Sidebar.svelte';
import { api } from './lib/api';
import { filterInstances } from './lib/instances';
import type { Instance } from './lib/types';
let instances: Instance[] = [];
let selected: Instance | null = null;
let query = '';
let status = '';
let loading = true;
let refreshing = false;
let error = '';
let addOpen = false;
let menuOpen = false;
let busyId = '';
let pollTimer: number | null = null;
$: visibleInstances = filterInstances(instances, query, status);
$: running = instances.filter((item) => item.status === 'running').length;
$: stopped = instances.filter((item) => item.status === 'stopped').length;
$: restarts = instances.reduce((total, item) => total + item.restarts, 0);
onMount(() => {
refresh();
startPolling();
document.addEventListener('visibilitychange', handleVisibility);
return () => {
stopPolling();
document.removeEventListener('visibilitychange', handleVisibility);
};
});
function startPolling() {
stopPolling();
pollTimer = window.setInterval(() => refresh(true), 5000);
}
function stopPolling() {
if (pollTimer !== null) {
window.clearInterval(pollTimer);
pollTimer = null;
}
}
function handleVisibility() {
if (document.visibilityState === 'visible') {
refresh(true);
startPolling();
} else {
stopPolling();
}
}
async function refresh(silent = false) {
if (!silent) refreshing = true;
try {
const next = await api.instances();
instances = next;
if (selected) {
selected =
next.find(
(item) =>
item.instance_id === selected?.instance_id && item.namespace === selected?.namespace,
) || null;
}
error = '';
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось загрузить инстансы';
} finally {
loading = false;
refreshing = false;
}
}
async function runAction(
instance: Instance,
action: 'start' | 'stop' | 'restart' | 'logs',
) {
if (action === 'logs') {
selected = instance;
return;
}
busyId = instance.instance_id;
try {
const updated = await api.action(instance.instance_id, action);
instances = instances.map((item) =>
item.instance_id === updated.instance_id && item.namespace === updated.namespace
? updated
: item,
);
if (selected?.instance_id === updated.instance_id) selected = updated;
window.setTimeout(() => refresh(true), 900);
} catch (cause) {
error = cause instanceof Error ? cause.message : `Не удалось выполнить ${action}`;
} finally {
busyId = '';
}
}
async function deleteInstance(instance: Instance, deleteData: boolean) {
busyId = instance.instance_id;
try {
await api.delete(instance.instance_id, deleteData);
selected = null;
await refresh();
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось удалить инстанс';
} finally {
busyId = '';
}
}
function created(instance: Instance) {
addOpen = false;
selected = instance;
refresh();
}
</script>
<main class="app-shell">
<Sidebar
open={menuOpen}
on:add={() => {
addOpen = true;
menuOpen = false;
}}
on:close={() => (menuOpen = false)}
/>
{#if menuOpen}
<button class="mobile-overlay" aria-label="Закрыть меню" on:click={() => (menuOpen = false)}></button>
{/if}
<section class="workspace">
<header class="topbar">
<button class="mobile-menu icon-button" aria-label="Открыть меню" on:click={() => (menuOpen = true)}>
<Menu size={18} />
</button>
<div class="live-state"><span></span>{running} инстанса работают</div>
<div class="topbar-actions">
<span class="namespace"><Server size={14} />userbot</span>
<button class="primary" on:click={() => (addOpen = true)}>
<Plus size={16} />
Добавить аккаунт
</button>
</div>
</header>
<div class="content">
<div class="page-heading">
<div>
<h1>Инстансы</h1>
<p>Telegram-аккаунты и их состояние в Kubernetes</p>
</div>
</div>
<section class="summary-strip" aria-label="Сводка">
<div><span>Всего</span><strong>{instances.length}</strong></div>
<div><span>Работают</span><strong>{running}</strong></div>
<div><span>Остановлены</span><strong>{stopped}</strong></div>
<div><span>Перезапуски</span><strong>{restarts}</strong></div>
</section>
<div class="toolbar">
<label class="search-field">
<Search size={16} />
<input bind:value={query} placeholder="Поиск" aria-label="Поиск инстансов" />
</label>
<select bind:value={status} aria-label="Фильтр статуса">
<option value="">Все статусы</option>
<option value="running">Работают</option>
<option value="stopped">Остановлены</option>
<option value="pending">Запускаются</option>
<option value="error">Ошибки</option>
</select>
<button
class="icon-button refresh"
aria-label="Обновить"
disabled={refreshing}
on:click={() => refresh()}
>
<RefreshCw size={17} class={refreshing ? 'spin' : ''} />
</button>
</div>
{#if error}
<div class="error-banner" role="alert">
<span>{error}</span>
<button on:click={() => refresh()}>Повторить</button>
</div>
{/if}
{#if loading}
<div class="center-state"><RefreshCw class="spin" size={22} />Загружаем инстансы…</div>
{:else if visibleInstances.length}
<InstanceTable
instances={visibleInstances}
{busyId}
on:select={(event) => (selected = event.detail)}
on:action={(event) => runAction(event.detail.instance, event.detail.action)}
/>
{:else}
<div class="empty-state">
<Server size={25} />
<h2>{instances.length ? 'Ничего не найдено' : 'Инстансов пока нет'}</h2>
<p>{instances.length ? 'Измените поиск или фильтр.' : 'Добавьте первый Telegram-аккаунт.'}</p>
{#if !instances.length}
<button class="primary" on:click={() => (addOpen = true)}>
<Plus size={16} />Добавить аккаунт
</button>
{/if}
</div>
{/if}
</div>
</section>
</main>
<AddAccountDrawer open={addOpen} on:close={() => (addOpen = false)} on:created={(event) => created(event.detail)} />
<DetailsDrawer
instance={selected}
busy={busyId === selected?.instance_id}
on:close={() => (selected = null)}
on:action={(event) => runAction(event.detail.instance, event.detail.action)}
on:delete={(event) => deleteInstance(event.detail.instance, event.detail.deleteData)}
/>
@@ -0,0 +1,354 @@
<script lang="ts">
import { ArrowLeft, Check, Eye, EyeOff, LoaderCircle, X } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import { api } from '../lib/api';
import type { AccountDraft, Instance } from '../lib/types';
export let open = false;
type Method = 'phone' | 'session';
type AuthPhase = 'code' | 'password';
const emptyDraft = (): AccountDraft => ({
instance_id: '',
display_name: '',
api_id: '',
api_hash: '',
phone: '',
session_string: '',
resources: {
storage: '1Gi',
cpu_limit: '300m',
memory_limit: '1536Mi',
},
});
let draft = emptyDraft();
let step = 1;
let method: Method = 'phone';
let authPhase: AuthPhase = 'code';
let flowId = '';
let code = '';
let password = '';
let error = '';
let busy = false;
let showHash = false;
let showPassword = false;
const dispatch = createEventDispatcher<{ close: void; created: Instance }>();
function reset() {
draft = emptyDraft();
step = 1;
method = 'phone';
authPhase = 'code';
flowId = '';
code = '';
password = '';
error = '';
busy = false;
showHash = false;
showPassword = false;
}
function close() {
reset();
dispatch('close');
}
function validSettings() {
return (
/^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(draft.instance_id) &&
draft.display_name.trim().length > 0 &&
Number(draft.api_id) > 0 &&
draft.api_hash.trim().length >= 16 &&
(method === 'phone'
? /^\+\d[\d\s()-]{6,}$/.test(draft.phone)
: draft.session_string.trim().length >= 32)
);
}
async function waitUntilReady(initial: Instance) {
let instance = initial;
for (let attempt = 0; attempt < 60 && instance.status === 'pending'; attempt += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 2000));
instance = await api.instance(initial.instance_id);
if (instance.status === 'error') {
throw new Error(`Pod не запустился: ${instance.reason || 'неизвестная ошибка'}`);
}
}
return instance;
}
async function finishProvisioning(instance: Instance) {
step = 3;
const readyInstance = await waitUntilReady(instance);
dispatch('created', readyInstance);
close();
}
async function begin() {
error = '';
if (!validSettings()) {
error = 'Заполните обязательные поля и проверьте формат значений.';
return;
}
busy = true;
try {
if (method === 'session') {
step = 3;
const result = await api.stringSession(draft);
if (result.instance) {
await finishProvisioning(result.instance);
}
return;
}
const result = await api.startPhone(draft);
flowId = result.flow_id || '';
authPhase = 'code';
step = 2;
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось начать авторизацию';
step = 1;
} finally {
busy = false;
}
}
async function submitCode() {
if (!flowId || !code.trim()) return;
error = '';
busy = true;
try {
const result = await api.submitCode(flowId, code);
if (result.status === 'password_required') {
authPhase = 'password';
} else if (result.instance) {
await finishProvisioning(result.instance);
}
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось проверить код';
} finally {
busy = false;
}
}
async function submitPassword() {
if (!flowId || !password) return;
error = '';
busy = true;
try {
const result = await api.submitPassword(flowId, password);
if (result.instance) {
await finishProvisioning(result.instance);
}
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось проверить пароль';
} finally {
busy = false;
}
}
</script>
{#if open}
<div class="drawer-backdrop" on:click={close} aria-hidden="true"></div>
<aside class="drawer add-drawer" aria-label="Добавить аккаунт">
<header class="drawer-header">
<div>
<h2>Добавить аккаунт</h2>
<p>Новый Kubernetes-инстанс userbot</p>
</div>
<button class="icon-button borderless" aria-label="Закрыть" on:click={close}>
<X size={19} />
</button>
</header>
<ol class="steps" aria-label="Шаги создания">
<li class:active={step === 1} class:complete={step > 1}><span>1</span>Настройки</li>
<li class:active={step === 2} class:complete={step > 2}><span>2</span>Авторизация</li>
<li class:active={step === 3}><span>3</span>Создание</li>
</ol>
{#if step === 1}
<form class="drawer-form" on:submit|preventDefault={begin}>
<fieldset>
<legend>Инстанс</legend>
<label>
Имя
<input
bind:value={draft.instance_id}
placeholder="personal"
maxlength="40"
autocomplete="off"
required
/>
<small>Только a-z, 0-9 и дефис</small>
</label>
<label>
Отображаемое имя
<input bind:value={draft.display_name} placeholder="Personal" maxlength="80" required />
</label>
</fieldset>
<fieldset>
<legend>Telegram API</legend>
<div class="two-columns">
<label>
API ID
<input bind:value={draft.api_id} inputmode="numeric" placeholder="12345678" required />
</label>
<label>
API Hash
<span class="input-with-action">
<input
bind:value={draft.api_hash}
type={showHash ? 'text' : 'password'}
placeholder="0123456789abcdef"
required
/>
<button
type="button"
aria-label={showHash ? 'Скрыть API Hash' : 'Показать API Hash'}
on:click={() => (showHash = !showHash)}
>
{#if showHash}<EyeOff size={15} />{:else}<Eye size={15} />{/if}
</button>
</span>
</label>
</div>
</fieldset>
<fieldset>
<legend>Авторизация</legend>
<div class="segmented">
<button type="button" class:active={method === 'phone'} on:click={() => (method = 'phone')}>
По номеру
</button>
<button type="button" class:active={method === 'session'} on:click={() => (method = 'session')}>
StringSession
</button>
</div>
{#if method === 'phone'}
<label>
Номер телефона
<input bind:value={draft.phone} type="tel" placeholder="+421 900 123 456" required />
<small>Код придёт в Telegram. При включённой 2FA потребуется пароль.</small>
</label>
{:else}
<label>
StringSession
<textarea
bind:value={draft.session_string}
rows="3"
placeholder="Вставьте Pyrogram/Pyrofork StringSession"
required
></textarea>
</label>
{/if}
</fieldset>
<fieldset>
<legend>Ресурсы</legend>
<div class="three-columns">
<label>
Хранилище
<select bind:value={draft.resources.storage}>
<option value="1Gi">1 GiB</option>
<option value="2Gi">2 GiB</option>
<option value="5Gi">5 GiB</option>
</select>
</label>
<label>
CPU
<select bind:value={draft.resources.cpu_limit}>
<option value="200m">200m</option>
<option value="300m">300m</option>
<option value="500m">500m</option>
</select>
</label>
<label>
RAM
<select bind:value={draft.resources.memory_limit}>
<option value="1024Mi">1 GiB</option>
<option value="1536Mi">1.5 GiB</option>
<option value="2048Mi">2 GiB</option>
</select>
</label>
</div>
</fieldset>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
<footer class="form-actions">
<button type="button" on:click={close}>Отмена</button>
<button class="primary" type="submit" disabled={busy}>
{#if busy}<LoaderCircle class="spin" size={16} />{/if}
{method === 'phone' ? 'Запросить код' : 'Создать инстанс'}
</button>
</footer>
</form>
{:else if step === 2}
<div class="auth-step">
<button class="back-link" on:click={() => (step = 1)}>
<ArrowLeft size={15} />Назад к настройкам
</button>
{#if authPhase === 'code'}
<h3>Введите код</h3>
<p>Код отправлен в Telegram на {draft.phone}.</p>
<form on:submit|preventDefault={submitCode}>
<label>
Код Telegram
<input
class="code-input"
bind:value={code}
inputmode="numeric"
autocomplete="one-time-code"
placeholder="12345"
/>
</label>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
<button class="primary full" type="submit" disabled={busy || !code.trim()}>
{#if busy}<LoaderCircle class="spin" size={16} />{/if}
Авторизовать
</button>
</form>
{:else}
<h3>Пароль 2FA</h3>
<p>Для этого аккаунта включена двухэтапная аутентификация.</p>
<form on:submit|preventDefault={submitPassword}>
<label>
Пароль
<span class="input-with-action">
<input
bind:value={password}
type={showPassword ? 'text' : 'password'}
autocomplete="current-password"
/>
<button
type="button"
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
on:click={() => (showPassword = !showPassword)}
>
{#if showPassword}<EyeOff size={15} />{:else}<Eye size={15} />{/if}
</button>
</span>
</label>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
<button class="primary full" type="submit" disabled={busy || !password}>
{#if busy}<LoaderCircle class="spin" size={16} />{/if}
Продолжить
</button>
</form>
{/if}
</div>
{:else}
<div class="creating-state">
<LoaderCircle class="spin" size={28} />
<h3>Создаём инстанс</h3>
<p>Secret, PVC и Deployment созданы. Ждём Ready в namespace userbot.</p>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
</div>
{/if}
</aside>
{/if}
@@ -0,0 +1,204 @@
<script lang="ts">
import {
Copy,
FileTerminal,
Play,
RotateCw,
Square,
Trash2,
X,
} from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import { api } from '../lib/api';
import type { Instance } from '../lib/types';
import StatusDot from './StatusDot.svelte';
export let instance: Instance | null = null;
export let busy = false;
let logs = '';
let logsLoading = false;
let logsOpen = false;
let deleteOpen = false;
let deleteData = false;
let confirmation = '';
let activeInstance = '';
const dispatch = createEventDispatcher<{
close: void;
action: { instance: Instance; action: 'start' | 'stop' | 'restart' };
delete: { instance: Instance; deleteData: boolean };
}>();
$: instanceKey = instance ? `${instance.namespace}/${instance.instance_id}` : '';
$: if (instanceKey !== activeInstance) {
activeInstance = instanceKey;
logs = '';
logsOpen = false;
deleteOpen = false;
deleteData = false;
confirmation = '';
}
async function loadLogs() {
if (!instance) return;
logsOpen = true;
logsLoading = true;
try {
logs = (await api.logs(instance.instance_id)).logs;
} catch (error) {
logs = error instanceof Error ? error.message : 'Не удалось загрузить логи';
} finally {
logsLoading = false;
}
}
function copy(value: string | null) {
if (value) navigator.clipboard.writeText(value);
}
</script>
{#if instance}
<div class="drawer-backdrop" on:click={() => dispatch('close')} aria-hidden="true"></div>
<aside class="drawer details-drawer" aria-label={`Инстанс ${instance.display_name}`}>
<header class="drawer-header">
<div>
<h2>{instance.display_name}</h2>
<StatusDot status={instance.status} />
</div>
<button class="icon-button borderless" aria-label="Закрыть" on:click={() => dispatch('close')}>
<X size={19} />
</button>
</header>
<div class="drawer-scroll">
<section>
<h3>Ресурсы</h3>
<div class="resource-grid">
<div class="resource">
<span>CPU</span>
<strong>{instance.cpu_usage || '—'} <small>/ {instance.cpu_limit || '—'}</small></strong>
</div>
<div class="resource">
<span>RAM</span>
<strong>{instance.memory_usage || '—'} <small>/ {instance.memory_limit || '—'}</small></strong>
</div>
<div class="resource">
<span>Рестарты</span>
<strong>{instance.restarts}</strong>
</div>
<div class="resource">
<span>Хранилище</span>
<strong>{instance.storage || '—'}</strong>
</div>
</div>
</section>
<section>
<h3>Kubernetes</h3>
<dl class="metadata-list">
<div>
<dt>Namespace</dt>
<dd>{instance.namespace}</dd>
</div>
<div>
<dt>Deployment</dt>
<dd>
<span>{instance.deployment}</span>
<button class="copy-button" aria-label="Копировать Deployment" on:click={() => copy(instance?.deployment)}>
<Copy size={14} />
</button>
</dd>
</div>
<div>
<dt>Pod</dt>
<dd>
<span>{instance.pod || 'Нет pod'}</span>
{#if instance.pod}
<button class="copy-button" aria-label="Копировать Pod" on:click={() => copy(instance?.pod)}>
<Copy size={14} />
</button>
{/if}
</dd>
</div>
<div>
<dt>PVC</dt>
<dd>{instance.pvc || '—'}</dd>
</div>
<div>
<dt>Image</dt>
<dd class="wrap">{instance.image}</dd>
</div>
</dl>
</section>
{#if logsOpen}
<section class="logs-section">
<div class="section-title">
<h3>Логи</h3>
<button class="text-button" on:click={loadLogs}>Обновить</button>
</div>
<pre>{logsLoading ? 'Загрузка…' : logs}</pre>
</section>
{/if}
{#if deleteOpen}
<section class="danger-zone">
<h3>Удалить инстанс</h3>
<p>Deployment и credentials Secret будут удалены. PVC по умолчанию останется.</p>
<label>
Введите <strong>{instance.instance_id}</strong>
<input bind:value={confirmation} autocomplete="off" />
</label>
<label class="checkbox-row">
<input type="checkbox" bind:checked={deleteData} />
Удалить PVC и все данные
</label>
<button
class="danger-button"
disabled={confirmation !== instance.instance_id || busy}
on:click={() => dispatch('delete', { instance, deleteData })}
>
Подтвердить удаление
</button>
</section>
{/if}
</div>
<footer class="drawer-actions">
<button on:click={loadLogs}><FileTerminal size={16} />Логи</button>
<button
disabled={busy || instance.status === 'stopped'}
on:click={() => dispatch('action', { instance, action: 'restart' })}
>
<RotateCw size={16} />Перезапустить
</button>
{#if instance.status === 'stopped'}
<button
class="primary"
disabled={busy}
on:click={() => dispatch('action', { instance, action: 'start' })}
>
<Play size={16} />Запустить
</button>
{:else}
<button
class="primary"
disabled={busy}
on:click={() => dispatch('action', { instance, action: 'stop' })}
>
<Square size={15} />Остановить
</button>
{/if}
<button
class="delete-trigger"
disabled={!instance.deletable}
title={instance.deletable ? 'Удалить инстанс' : 'Legacy-инстансы удаляются из Git'}
on:click={() => (deleteOpen = !deleteOpen)}
>
<Trash2 size={16} />Удалить
</button>
</footer>
</aside>
{/if}
@@ -0,0 +1,114 @@
<script lang="ts">
import { FileTerminal, MoreVertical, Play, RotateCw, Square } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import { relativeTime } from '../lib/instances';
import type { Instance } from '../lib/types';
import StatusDot from './StatusDot.svelte';
export let instances: Instance[] = [];
export let busyId = '';
const dispatch = createEventDispatcher<{
select: Instance;
action: { instance: Instance; action: 'start' | 'stop' | 'restart' | 'logs' };
}>();
function action(
event: MouseEvent,
instance: Instance,
name: 'start' | 'stop' | 'restart' | 'logs',
) {
event.stopPropagation();
dispatch('action', { instance, action: name });
}
</script>
<div class="table-frame">
<div class="table-head table-grid" aria-hidden="true">
<span>ИНСТАНС</span>
<span>СТАТУС</span>
<span>POD</span>
<span>РЕСТАРТЫ</span>
<span>ХРАНИЛИЩЕ</span>
<span>ОБНОВЛЁН</span>
<span class="right">ДЕЙСТВИЯ</span>
</div>
{#each instances as instance (instance.namespace + instance.instance_id)}
<div
class="instance-row table-grid"
class:error-row={instance.status === 'error'}
on:click={() => dispatch('select', instance)}
on:keydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') dispatch('select', instance);
}}
role="button"
tabindex="0"
aria-label={`Открыть ${instance.display_name}`}
>
<span class="instance-name">
<strong>{instance.display_name}</strong>
<small>{instance.instance_id} · {instance.namespace}</small>
</span>
<StatusDot status={instance.status} />
<span class="pod-name" title={instance.pod || instance.reason || ''}>
{instance.pod || instance.reason || 'Нет pod'}
</span>
<span>{instance.restarts}</span>
<span>{instance.storage || '—'}</span>
<span>{relativeTime(instance.updated_at)}</span>
<span class="row-actions">
<button
class="icon-button"
title="Логи"
aria-label={`Логи ${instance.display_name}`}
on:click={(event) => action(event, instance, 'logs')}
>
<FileTerminal size={16} />
</button>
<button
class="icon-button"
title="Перезапустить"
aria-label={`Перезапустить ${instance.display_name}`}
disabled={busyId === instance.instance_id || instance.status === 'stopped'}
on:click={(event) => action(event, instance, 'restart')}
>
<RotateCw size={16} />
</button>
{#if instance.status === 'stopped'}
<button
class="icon-button"
title="Запустить"
aria-label={`Запустить ${instance.display_name}`}
disabled={busyId === instance.instance_id}
on:click={(event) => action(event, instance, 'start')}
>
<Play size={16} />
</button>
{:else}
<button
class="icon-button"
title="Остановить"
aria-label={`Остановить ${instance.display_name}`}
disabled={busyId === instance.instance_id}
on:click={(event) => action(event, instance, 'stop')}
>
<Square size={15} />
</button>
{/if}
<button
class="icon-button"
title="Подробнее"
aria-label={`Подробнее ${instance.display_name}`}
on:click={(event) => {
event.stopPropagation();
dispatch('select', instance);
}}
>
<MoreVertical size={16} />
</button>
</span>
</div>
{/each}
</div>
@@ -0,0 +1,39 @@
<script lang="ts">
import { Bot, Box, Plus, X } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
export let open = false;
const dispatch = createEventDispatcher<{ add: void; close: void }>();
</script>
<aside class:open aria-label="Навигация">
<div class="brand">
<Bot size={31} strokeWidth={1.8} />
<div>
<strong>USERBOT</strong>
<span>Kubernetes Control</span>
</div>
<button class="mobile-close" aria-label="Закрыть меню" on:click={() => dispatch('close')}>
<X size={18} />
</button>
</div>
<nav>
<button class="nav-item active" on:click={() => dispatch('close')}>
<Box size={18} />
Инстансы
</button>
<button class="nav-item" on:click={() => dispatch('add')}>
<Plus size={19} />
Добавить аккаунт
</button>
</nav>
<div class="cluster-state">
<span class="cluster-dot"></span>
<div>
<strong>Кластер доступен</strong>
<span>namespace: userbot</span>
</div>
</div>
</aside>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { InstanceStatus } from '../lib/types';
export let status: InstanceStatus;
const labels: Record<InstanceStatus, string> = {
running: 'Работает',
stopped: 'Остановлен',
pending: 'Запускается',
error: 'Ошибка',
};
</script>
<span class="status status-{status}">
<span class="status-dot" aria-hidden="true"></span>
{labels[status]}
</span>
+83
View File
@@ -0,0 +1,83 @@
import type { AccountDraft, AuthResult, Instance } from "./types";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, init);
if (!response.ok) {
let message = response.statusText || "Request failed";
try {
const payload = await response.json();
if (payload?.detail) message = typeof payload.detail === "string" ? payload.detail : JSON.stringify(payload.detail);
} catch {
const text = await response.text().catch(() => "");
if (text) message = text;
}
throw new Error(message);
}
if (response.status === 204) return undefined as T;
return response.json();
}
const json = (body: unknown): RequestInit => ({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
export const api = {
instances(query = "", status = "") {
const params = new URLSearchParams();
if (query) params.set("query", query);
if (status) params.set("status", status);
const suffix = params.size ? `?${params}` : "";
return request<Instance[]>(`/api/instances${suffix}`);
},
instance(id: string) {
return request<Instance>(`/api/instances/${encodeURIComponent(id)}`);
},
logs(id: string) {
return request<{ logs: string }>(`/api/instances/${encodeURIComponent(id)}/logs`);
},
action(id: string, action: "start" | "stop" | "restart") {
return request<Instance>(`/api/instances/${encodeURIComponent(id)}/${action}`, { method: "POST" });
},
delete(id: string, deleteData: boolean) {
return request<void>(
`/api/instances/${encodeURIComponent(id)}/delete`,
json({ confirmation: id, delete_data: deleteData }),
);
},
startPhone(draft: AccountDraft) {
return request<AuthResult>(
"/api/auth/phone/start",
json({
...basePayload(draft),
phone: draft.phone,
}),
);
},
submitCode(flowId: string, code: string) {
return request<AuthResult>(`/api/auth/phone/${encodeURIComponent(flowId)}/code`, json({ code }));
},
submitPassword(flowId: string, password: string) {
return request<AuthResult>(`/api/auth/phone/${encodeURIComponent(flowId)}/password`, json({ password }));
},
stringSession(draft: AccountDraft) {
return request<AuthResult>(
"/api/auth/string-session",
json({
...basePayload(draft),
session_string: draft.session_string,
}),
);
},
};
function basePayload(draft: AccountDraft) {
return {
instance_id: draft.instance_id,
display_name: draft.display_name,
api_id: Number(draft.api_id),
api_hash: draft.api_hash,
resources: draft.resources,
};
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { filterInstances, relativeTime } from "./instances";
import type { Instance } from "./types";
const instance = (overrides: Partial<Instance>): Instance => ({
instance_id: "forust",
display_name: "Forust",
namespace: "userbot",
deployment: "userbot-forust",
pod: "userbot-forust-abc",
status: "running",
reason: null,
ready: true,
restarts: 0,
pvc: "userbot-forust-data",
storage: "1Gi",
image: "userbot:latest",
cpu_limit: "300m",
memory_limit: "1536Mi",
cpu_usage: null,
memory_usage: null,
updated_at: null,
legacy: false,
deletable: true,
...overrides,
});
describe("filterInstances", () => {
it("filters by status and searchable fields", () => {
const items = [
instance({ instance_id: "forust" }),
instance({ instance_id: "anna", display_name: "Anna", status: "stopped" }),
];
expect(filterInstances(items, "ann", "stopped").map((item) => item.instance_id)).toEqual(["anna"]);
});
});
describe("relativeTime", () => {
it("formats recent timestamps", () => {
expect(relativeTime("2026-01-01T00:00:00Z", Date.parse("2026-01-01T00:00:45Z"))).toBe("45 сек назад");
});
});
@@ -0,0 +1,24 @@
import type { Instance } from "./types";
export function filterInstances(instances: Instance[], query: string, status: string): Instance[] {
const normalized = query.trim().toLowerCase();
return instances.filter((instance) => {
const matchesStatus = !status || instance.status === status;
const matchesQuery =
!normalized ||
instance.instance_id.toLowerCase().includes(normalized) ||
instance.display_name.toLowerCase().includes(normalized) ||
(instance.pod || "").toLowerCase().includes(normalized);
return matchesStatus && matchesQuery;
});
}
export function relativeTime(value: string | null, now = Date.now()): string {
if (!value) return "—";
const seconds = Math.max(0, Math.round((now - new Date(value).getTime()) / 1000));
if (seconds < 60) return `${seconds} сек назад`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} мин назад`;
const hours = Math.floor(minutes / 60);
return `${hours} ч назад`;
}
+45
View File
@@ -0,0 +1,45 @@
export type InstanceStatus = "running" | "stopped" | "pending" | "error";
export interface Instance {
instance_id: string;
display_name: string;
namespace: string;
deployment: string;
pod: string | null;
status: InstanceStatus;
reason: string | null;
ready: boolean;
restarts: number;
pvc: string | null;
storage: string | null;
image: string;
cpu_limit: string | null;
memory_limit: string | null;
cpu_usage: string | null;
memory_usage: string | null;
updated_at: string | null;
legacy: boolean;
deletable: boolean;
}
export interface Resources {
storage: string;
cpu_limit: string;
memory_limit: string;
}
export interface AccountDraft {
instance_id: string;
display_name: string;
api_id: string;
api_hash: string;
phone: string;
session_string: string;
resources: Resources;
}
export interface AuthResult {
status: "code_required" | "password_required" | "ready";
flow_id?: string;
instance?: Instance;
}
+8
View File
@@ -0,0 +1,8 @@
import { mount } from "svelte";
import App from "./App.svelte";
import "./styles.css";
mount(App, {
target: document.getElementById("app")!,
});
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess(),
};
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "ES2022",
"module": "ESNext",
"types": ["svelte", "vite/client", "vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [svelte()],
build: {
outDir: "dist",
emptyOutDir: true,
},
server: {
proxy: {
"/api": "http://127.0.0.1:8080",
},
},
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
});
View File