diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..035ec05 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +** +!Dockerfile +!requirements.txt +!app.wsgi +!gunicorn.conf.py +!scheduler_runner.py +!operations_runner.py +!runtime_health.py +!rotate_credential_secret.py +!favicon.ico +!LICENSE +!NOTICE +!LICENSING.md +!app/ +!app/** +!config_other/ +!config_other/** +!container/ +!container/** +**/__pycache__ +**/*.pyc +app/tools/ +app/scripts/ansible/inventory/ +app/scripts/ansible/artifacts/ +app/scripts/ansible/env/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dd0369c --- /dev/null +++ b/.env.example @@ -0,0 +1,49 @@ +# Copy to .env in your deployment directory and restrict access to that file. +# Use image references available for your installation; a digest pins a version. +RMON_WEB_IMAGE=ghcr.io/roxy-wi/rmon/rmon-web:1.4.0 +RMON_PROXY_IMAGE=rmon-proxy:local +RMON_SERVER_IMAGE=ghcr.io/roxy-wi/rmon/rmon-server:7.0 +RMON_AGENT_IMAGE=ghcr.io/roxy-wi/rmon/rmon-agent:2.0 + +# Replace with the address users and agents can reach. +RMON_PUBLIC_URL=https://192.0.2.10 +RMON_TLS_NAME=192.0.2.10 +RMON_WEB_PORT=443 +RMON_BIND_IP=0.0.0.0 +RMON_PROXY_SCHEME=https +RMON_SELF_SIGNED=1 + +# Keep these defaults for a new installation. For existing data, use absolute +# host directory paths. Both web and server use the same config/data sources. +RMON_CONFIG_SOURCE=rmon-config +RMON_DATA_SOURCE=rmon-data +RMON_LOG_SOURCE=rmon-logs +RMON_TLS_SOURCE=rmon-tls +# RMON_CONFIG_SOURCE=/etc/rmon +# RMON_DATA_SOURCE=/var/lib/rmon +# RMON_LOG_SOURCE=/var/log/rmon +# RMON_TLS_SOURCE=/srv/rmon/tls + +# Used only with compose.server.yaml. The port is local-only by default. +# COMPOSE_FILE=compose.yaml:compose.server.yaml +RMON_SERVER_TOKEN_FILE=./secrets/server-token +RMON_SERVER_BIND_IP=127.0.0.1 +RMON_SERVER_PORT=5100 + +# Existing application secrets: preserve their exact values or mounted paths. +# Single-quote literal values containing $ or #. Do not replace existing keys. +# RMON_SECRET_PHRASE='existing-credential-key' +# RMON_SECRET_KEY_FILE=/var/lib/rmon/keys/flask-secret +# RMON_JWT_PRIVATE_KEY_FILE=/var/lib/rmon/keys/rmon-key +# RMON_JWT_PUBLIC_KEY_FILE=/var/lib/rmon/keys/rmon-key.pub + +# External server diagnostics, used with compose.yaml alone. Files must be +# mounted in web; paths below /etc/rmon are included by the config mount. +# RMON_SERVER_INTERNAL_URL=https://receiver.example.net:5100 +# RMON_SERVER_INTERNAL_TOKEN_FILE=/etc/rmon/secrets/server-token +# RMON_SERVER_CA_FILE=/etc/rmon/tls/ca.crt +# RMON_SERVER_CLIENT_CERT_FILE=/etc/rmon/tls/web.crt +# RMON_SERVER_CLIENT_KEY_FILE=/etc/rmon/tls/web.key + +# Override only when agents cannot reach RMON_PUBLIC_URL. +# RMON_AGENT_CONTROL_URL=https://rmon-agent-access.example.net diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..16fa685 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +## Summary + +Describe the change and why it is needed. + +## Testing + +Describe the tests or checks you ran. + +## Contributor License Agreement + +After opening this pull request, comment exactly: + +`/sign-cla` + +You only need to sign once per CLA version. If you already accepted the current `CLA.md`, the `CLA` status check will pass automatically. diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 0000000..5b1fa3a --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,468 @@ +name: CLA + +on: + pull_request_target: + branches: + - main + - "release/**" + types: + - opened + - synchronize + - reopened + - ready_for_review + issue_comment: + types: + - created + push: + paths: + - CLA.md + workflow_dispatch: + +permissions: {} + +concurrency: + group: rmon-cla-registry + cancel-in-progress: false + +jobs: + evaluate: + name: Update CLA status + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + statuses: write + + steps: + - name: Evaluate contributor acceptance + uses: actions/github-script@v7 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const pr = context.payload.pull_request; + const registryTitle = "[CLA] Signature Registry"; + const markerPrefix = "", jsonStart); + if (jsonEnd === -1) { + continue; + } + + try { + const record = JSON.parse(body.slice(jsonStart, jsonEnd)); + if ( + record.version === 1 && + Number(record.github_id) === Number(githubId) && + record.cla_sha === claSha + ) { + return true; + } + } catch (error) { + core.warning(`Ignoring malformed CLA registry record in comment ${comment.id}`); + } + } + } + + return false; + } + + const { sha: claSha, defaultBranch } = await currentClaSha(); + const login = pr.user.login; + const isExempt = exemptLogins.has(login); + const accepted = isExempt || await hasSignature(pr.user.id, claSha); + + await github.rest.repos.createCommitStatus({ + owner, + repo, + sha: pr.head.sha, + state: accepted ? "success" : "failure", + context: "CLA", + description: isExempt + ? "Automated dependency account is exempt from CLA" + : accepted + ? "Contributor accepted the current CLA" + : "Comment /sign-cla on the PR to accept the current CLA", + target_url: `${context.serverUrl}/${owner}/${repo}/blob/${defaultBranch}/CLA.md`, + }); + + if (!accepted) { + core.notice(`@${login} has not accepted CLA ${claSha}. Comment /sign-cla on PR #${pr.number}.`); + } + + sign: + name: Record CLA acceptance + if: >- + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.comment.body == '/sign-cla' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: read + statuses: write + + steps: + - name: Record acceptance + uses: actions/github-script@v7 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const issueNumber = context.payload.issue.number; + const commenter = context.payload.comment.user; + const registryTitle = "[CLA] Signature Registry"; + const markerPrefix = "", jsonStart); + if (jsonEnd === -1) { + continue; + } + + try { + const record = JSON.parse(body.slice(jsonStart, jsonEnd)); + if ( + record.version === 1 && + Number(record.github_id) === Number(commenter.id) && + record.cla_sha === claSha + ) { + return true; + } + } catch (error) { + core.warning(`Ignoring malformed CLA registry record in comment ${comment.id}`); + } + } + } + + return false; + } + + let registries = await findRegistryIssues(); + const accepted = await alreadyAccepted(registries); + + if (!accepted) { + let registry = registries.find((issue) => issue.state === "open"); + + if (!registry) { + const created = await github.rest.issues.create({ + owner, + repo, + title: registryTitle, + body: [ + "This issue is an automated public registry of RMON CLA acceptances.", + "", + "Do not edit or manually add signature records. Only records posted by `github-actions[bot]` are trusted by the CLA workflow.", + "", + "Each record is bound to the contributor's numeric GitHub user ID and the Git blob SHA of the accepted `CLA.md` version.", + ].join("\n"), + }); + registry = created.data; + } + + const record = { + version: 1, + github_id: commenter.id, + github_login: commenter.login, + cla_sha: claSha, + accepted_at: context.payload.comment.created_at, + source_pr: issueNumber, + source_comment_id: context.payload.comment.id, + command: "/sign-cla", + }; + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: registry.number, + body: [ + `${markerPrefix}${JSON.stringify(record)} -->`, + `✅ @${commenter.login} accepted RMON CLA \`${claSha}\` on ${context.payload.comment.created_at}.`, + `Source: #${issueNumber}, comment ${context.payload.comment.id}.`, + ].join("\n"), + }); + } + + await github.rest.repos.createCommitStatus({ + owner, + repo, + sha: pr.head.sha, + state: "success", + context: "CLA", + description: "Contributor accepted the current CLA", + target_url: `${context.serverUrl}/${owner}/${repo}/blob/${defaultBranch}/CLA.md`, + }); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: accepted + ? `✅ @${commenter.login}, you already accepted the current RMON CLA (\`${claSha}\`).` + : `✅ @${commenter.login}, your RMON CLA acceptance has been recorded for version \`${claSha}\`.`, + }); + + refresh: + name: Refresh open PR CLA statuses + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + statuses: write + + steps: + - name: Refresh statuses + uses: actions/github-script@v7 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const registryTitle = "[CLA] Signature Registry"; + const markerPrefix = "", jsonStart); + if (jsonEnd === -1) { + continue; + } + + try { + const record = JSON.parse(body.slice(jsonStart, jsonEnd)); + if (record.version === 1 && record.cla_sha === claSha) { + acceptedIds.add(Number(record.github_id)); + } + } catch (error) { + core.warning(`Ignoring malformed CLA registry record in comment ${comment.id}`); + } + } + } + + const pulls = await github.paginate( + github.rest.pulls.list, + { + owner, + repo, + state: "open", + per_page: 100, + } + ); + + for (const pr of pulls) { + const accepted = + exemptLogins.has(pr.user.login) || + acceptedIds.has(Number(pr.user.id)); + + await github.rest.repos.createCommitStatus({ + owner, + repo, + sha: pr.head.sha, + state: accepted ? "success" : "failure", + context: "CLA", + description: exemptLogins.has(pr.user.login) + ? "Automated dependency account is exempt from CLA" + : accepted + ? "Contributor accepted the current CLA" + : "Comment /sign-cla on the PR to accept the current CLA", + target_url: `${context.serverUrl}/${owner}/${repo}/blob/${defaultBranch}/CLA.md`, + }); + } + + core.notice(`Refreshed CLA status for ${pulls.length} open pull request(s).`); diff --git a/.github/workflows/container-tests.yml b/.github/workflows/container-tests.yml new file mode 100644 index 0000000..6b3b83b --- /dev/null +++ b/.github/workflows/container-tests.yml @@ -0,0 +1,26 @@ +name: Container tests + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + container-smoke: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + - name: Build the web image (no registry publication) + run: docker build --tag rmon-web:test . + - name: Build the Nginx proxy image + run: docker build --tag rmon-proxy:test container/nginx + - name: Validate Compose + run: | + docker compose config --quiet + docker compose -f compose.yaml -f compose.server.yaml config --quiet + - name: Test HTTP, HTTPS, bootstrap, persistence and database readiness + run: python3 tests/container/image_smoke.py + - name: Test database operation queue on PostgreSQL and MariaDB + run: python3 tests/operations/database_smoke.py diff --git a/.github/workflows/helm-oci.yml b/.github/workflows/helm-oci.yml new file mode 100644 index 0000000..568e96c --- /dev/null +++ b/.github/workflows/helm-oci.yml @@ -0,0 +1,170 @@ +name: Docker image and Helm chart + +on: + workflow_dispatch: + push: + branches: [main, release/1.4.0] + tags: ['helm-*'] + paths: + - 'helm/**' + - 'container/**' + - 'tests/helm/**' + - 'Dockerfile' + - '.github/workflows/helm-oci.yml' + pull_request: + branches: [main, release/1.4.0] + +permissions: + contents: read + +concurrency: + group: helm-oci-${{ github.ref }} + cancel-in-progress: false + +env: + IMAGE: ghcr.io/roxy-wi/rmon/rmon + WEB_IMAGE: ghcr.io/roxy-wi/rmon/rmon-web + CHART_REGISTRY: oci://ghcr.io/roxy-wi/rmon + +jobs: + helm-check: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + helm: [v3.21.3, v4.2.3] + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: azure/setup-helm@v5.0.1 + with: + version: ${{ matrix.helm }} + - run: python3 -m pip install PyYAML==6.0.2 + - run: helm lint helm/rmon --set publicURL=https://rmon.example.test --set existingConfigSecret=settings --set server.existingTokenSecret=receiver + - run: python3 tests/helm/validate.py + + image-and-chart: + needs: helm-check + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: azure/setup-helm@v5.0.1 + with: + version: v3.21.3 + - run: python3 -m pip install PyYAML==6.0.2 + - name: Verify anonymous access to Server and Agent + shell: bash + run: | + export DOCKER_CONFIG="$(mktemp -d)" + docker pull ghcr.io/roxy-wi/rmon/rmon-server:7.0 + docker manifest inspect ghcr.io/roxy-wi/rmon/rmon-agent:2.0 > /dev/null + - name: Validate release metadata + id: release + shell: bash + run: | + python3 - <<'PY' + import os, re, runpy, yaml + chart = yaml.safe_load(open('helm/rmon/Chart.yaml')) + assert chart['name'] == 'rmon-chart' + version = chart['version'] + assert re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', version) + assert chart['appVersion'] == runpy.run_path('app/version.py')['get_service_version']() + if os.environ['GITHUB_REF_TYPE'] == 'tag': + assert os.environ['GITHUB_REF_NAME'] == 'helm-' + version + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write(f'version={version}\napp-version={chart["appVersion"]}\n') + PY + - uses: docker/setup-buildx-action@v3 + - name: Build image for Kubernetes validation + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + load: true + platforms: linux/amd64 + tags: rmon-web-test:ci + labels: | + org.opencontainers.image.source=https://github.com/roxy-wi/RMON + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ steps.release.outputs.app-version }} + org.opencontainers.image.licenses=Elastic-2.0 + cache-from: type=gha,scope=rmon-web + cache-to: type=gha,mode=max,scope=rmon-web + - name: Set up disposable Kubernetes cluster + shell: bash + run: | + curl -fsSLO https://github.com/kubernetes-sigs/kind/releases/download/v0.33.0/kind-linux-amd64 + curl -fsSLO https://github.com/kubernetes-sigs/kind/releases/download/v0.33.0/kind-linux-amd64.sha256sum + sha256sum --check kind-linux-amd64.sha256sum + chmod +x kind-linux-amd64 + sudo install kind-linux-amd64 /usr/local/bin/kind + kind create cluster --name rmon-chart-test --wait 120s + kind load docker-image rmon-web-test:ci --name rmon-chart-test + kind load docker-image ghcr.io/roxy-wi/rmon/rmon-server:7.0 --name rmon-chart-test + - name: Verify fresh install, persistence, maintenance, HTTPS and mTLS + run: python3 tests/helm/cluster_smoke.py + - name: Kubernetes diagnostics on failure + if: failure() + run: | + kubectl -n rmon-helm-test get pods,deployments,jobs,pvc || true + kubectl -n rmon-helm-test get events --sort-by=.lastTimestamp | tail -60 || true + kubectl -n rmon-helm-test logs -l app.kubernetes.io/instance=smoke --all-containers --tail=80 --prefix || true + - name: Remove test cluster + if: always() + run: kind delete cluster --name rmon-chart-test + - uses: docker/login-action@v3 + if: startsWith(github.ref, 'refs/tags/helm-') + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Publish the tested image and OCI chart + if: startsWith(github.ref, 'refs/tags/helm-') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APP_VERSION: ${{ steps.release.outputs.app-version }} + CHART_VERSION: ${{ steps.release.outputs.version }} + shell: bash + run: | + docker tag rmon-web-test:ci "${IMAGE}:${APP_VERSION}" + docker tag rmon-web-test:ci "${IMAGE}:sha-${GITHUB_SHA::12}" + docker push "${IMAGE}:${APP_VERSION}" + docker push "${IMAGE}:sha-${GITHUB_SHA::12}" + docker tag rmon-web-test:ci "${WEB_IMAGE}:${APP_VERSION}" + docker tag rmon-web-test:ci "${WEB_IMAGE}:sha-${GITHUB_SHA::12}" + docker push "${WEB_IMAGE}:${APP_VERSION}" + docker push "${WEB_IMAGE}:sha-${GITHUB_SHA::12}" + IMAGE_DIGEST=$(docker buildx imagetools inspect "${IMAGE}:${APP_VERSION}" --format '{{json .Manifest}}' | jq -r .digest) + export IMAGE_DIGEST + web_digest=$(docker buildx imagetools inspect "${WEB_IMAGE}:${APP_VERSION}" --format '{{json .Manifest}}' | jq -r .digest) + test "$web_digest" = "$IMAGE_DIGEST" + server_ref=$(docker image inspect ghcr.io/roxy-wi/rmon/rmon-server:7.0 --format '{{index .RepoDigests 0}}') + export SERVER_DIGEST="${server_ref#*@}" + python3 - <<'PY' + import os, re, yaml + digest = os.environ['IMAGE_DIGEST'] + assert re.fullmatch(r'sha256:[a-f0-9]{64}', digest), 'Missing pushed image digest' + server_digest = os.environ['SERVER_DIGEST'] + assert re.fullmatch(r'sha256:[a-f0-9]{64}', server_digest), 'Missing tested server digest' + path = 'helm/rmon/values.yaml' + values = yaml.safe_load(open(path)) + values['image']['digest'] = digest + values['server']['image']['digest'] = server_digest + with open(path, 'w') as output: + yaml.safe_dump(values, output, sort_keys=False) + PY + helm package helm/rmon --destination dist + printf '%s' "$GH_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + helm push "dist/rmon-chart-${CHART_VERSION}.tgz" "$CHART_REGISTRY" + mkdir -p pulled + helm pull "${CHART_REGISTRY}/rmon-chart" --version "$CHART_VERSION" --destination pulled + cmp "dist/rmon-chart-${CHART_VERSION}.tgz" "pulled/rmon-chart-${CHART_VERSION}.tgz" diff --git a/.github/workflows/native-packages.yml b/.github/workflows/native-packages.yml new file mode 100644 index 0000000..d7fdf9c --- /dev/null +++ b/.github/workflows/native-packages.yml @@ -0,0 +1,172 @@ +name: RMON packages + +on: + push: + tags: ['v*'] + +permissions: + contents: read + +concurrency: + group: rmon-native-repository + cancel-in-progress: false + +jobs: + metadata: + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.release.outputs.version }} + tooling-revision: ${{ steps.tooling.outputs.revision }} + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Check out release tooling + uses: actions/checkout@v5 + with: + repository: ${{ secrets.REPO_PACKAGING_REPOSITORY }} + ref: ${{ vars.REPO_PACKAGING_REF }} + ssh-key: ${{ secrets.REPO_PACKAGING_SSH_KEY }} + path: build-support + persist-credentials: false + - id: tooling + run: echo "revision=$(git -C build-support rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - id: release + run: python3 build-support/package_tools/rmon/build.py metadata + + tests: + needs: metadata + uses: ./.github/workflows/tests.yml + + build: + needs: [metadata, tests] + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Check out release tooling + uses: actions/checkout@v5 + with: + repository: ${{ secrets.REPO_PACKAGING_REPOSITORY }} + ref: ${{ needs.metadata.outputs.tooling-revision }} + ssh-key: ${{ secrets.REPO_PACKAGING_SSH_KEY }} + path: build-support + persist-credentials: false + - run: docker build -t rmon-package-tools build-support/package_tools/rmon + - name: Test package publication and reruns + run: docker run --rm -v "$PWD:/src:ro" rmon-package-tools python3 -m pytest build-support/package_tools/rmon/tests -q -p no:cacheprovider + - name: Build DEB and RPM from the tagged checkout + run: | + mkdir -p dist + docker run --rm -v "$PWD:/src:ro" -v "$PWD/dist:/output" rmon-package-tools python3 build-support/package_tools/rmon/build.py deb --output /output + docker run --rm -v "$PWD:/src:ro" -v "$PWD/dist:/output" rmon-package-tools python3 build-support/package_tools/rmon/build.py el9 --output /output + docker run --rm -v "$PWD:/src:ro" -v "$PWD/dist:/output" rmon-package-tools python3 build-support/package_tools/rmon/build.py el10 --output /output + - name: Verify installation on Ubuntu and Rocky Linux + run: | + docker run --rm -v "$PWD/dist:/packages:ro" -v "$PWD/build-support/package_tools/rmon/tests:/checks:ro" ubuntu:24.04 bash /checks/install.sh deb + docker run --rm -v "$PWD/dist:/packages:ro" -v "$PWD/build-support/package_tools/rmon/tests:/checks:ro" rockylinux:9 bash /checks/install.sh el9 + docker run --rm -v "$PWD/dist:/packages:ro" -v "$PWD/build-support/package_tools/rmon/tests:/checks:ro" rockylinux/rockylinux:10 bash /checks/install.sh el10 + - uses: actions/upload-artifact@v4 + with: + name: rmon-native-packages + path: | + dist/*.deb + dist/*.rpm + if-no-files-found: error + retention-days: 7 + overwrite: true + + publish: + needs: [metadata, build] + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + REPO_S3_ENDPOINT: ${{ vars.REPO_S3_ENDPOINT }} + REPO_S3_BUCKET: ${{ vars.REPO_S3_BUCKET }} + REPO_S3_REGION: ${{ vars.REPO_S3_REGION }} + AWS_ACCESS_KEY_ID: ${{ secrets.REPO_S3_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.REPO_S3_SECRET_ACCESS_KEY }} + REPO_GPG_KEY_ID: ${{ vars.REPO_GPG_KEY_ID }} + REPO_GPG_PRIVATE_KEY: ${{ secrets.REPO_GPG_PRIVATE_KEY }} + REPO_GPG_PASSPHRASE: ${{ secrets.REPO_GPG_PASSPHRASE }} + RELEASE_VERSION: ${{ needs.metadata.outputs.version }} + RELEASE_REVISION: ${{ github.sha }} + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Check out release tooling + uses: actions/checkout@v5 + with: + repository: ${{ secrets.REPO_PACKAGING_REPOSITORY }} + ref: ${{ needs.metadata.outputs.tooling-revision }} + ssh-key: ${{ secrets.REPO_PACKAGING_SSH_KEY }} + path: build-support + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + name: rmon-native-packages + path: dist + - name: Check publication configuration + run: | + python3 - <<'PY' + import os + required = ['REPO_S3_ENDPOINT','REPO_S3_BUCKET','AWS_ACCESS_KEY_ID','AWS_SECRET_ACCESS_KEY','REPO_GPG_KEY_ID','REPO_GPG_PRIVATE_KEY'] + missing = [name for name in required if not os.environ.get(name)] + if missing: + raise SystemExit('Configure Actions settings: ' + ', '.join(missing)) + PY + - run: docker build -t rmon-package-tools build-support/package_tools/rmon + - name: Sign packages and replace this release in S3 + run: | + docker run --rm -v "$PWD:/src:ro" \ + --env GITHUB_REPOSITORY --env GITHUB_RUN_ID \ + --env REPO_S3_ENDPOINT --env REPO_S3_BUCKET --env REPO_S3_REGION \ + --env AWS_ACCESS_KEY_ID --env AWS_SECRET_ACCESS_KEY \ + --env REPO_GPG_KEY_ID --env REPO_GPG_PRIVATE_KEY --env REPO_GPG_PASSPHRASE \ + rmon-package-tools python3 build-support/package_tools/rmon/repository.py \ + --artifacts /src/dist --version "$RELEASE_VERSION" --revision "$RELEASE_REVISION" + + verify: + needs: [metadata, publish] + runs-on: ubuntu-24.04 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - image: ubuntu:24.04 + format: deb + - image: rockylinux:9 + format: el9 + - image: rockylinux/rockylinux:10 + format: el10 + env: + RMON_PACKAGE_REPOSITORY: https://repo.rmon.io + RMON_PACKAGE_VERSION: ${{ needs.metadata.outputs.version }} + REPO_GPG_KEY_ID: ${{ vars.REPO_GPG_KEY_ID }} + TEST_IMAGE: ${{ matrix.image }} + TEST_FORMAT: ${{ matrix.format }} + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Check out release tooling + uses: actions/checkout@v5 + with: + repository: ${{ secrets.REPO_PACKAGING_REPOSITORY }} + ref: ${{ needs.metadata.outputs.tooling-revision }} + ssh-key: ${{ secrets.REPO_PACKAGING_SSH_KEY }} + path: build-support + persist-credentials: false + - name: Install the published release from the public repository with APT or DNF + run: | + docker run --rm -v "$PWD/build-support/package_tools/rmon/tests:/checks:ro" \ + --env RMON_PACKAGE_REPOSITORY --env RMON_PACKAGE_VERSION --env REPO_GPG_KEY_ID \ + "$TEST_IMAGE" bash /checks/install.sh "$TEST_FORMAT" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e697757..8079b97 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,11 +6,28 @@ on: pull_request: branches: [main] workflow_dispatch: + workflow_call: permissions: contents: read jobs: + frontend-tests: + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: tests/frontend + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: tests/frontend/package-lock.json + - run: npm ci --ignore-scripts + - run: npm test + unit-tests: runs-on: ubuntu-latest timeout-minutes: 20 @@ -35,7 +52,7 @@ jobs: python -m pip install -r requirements.txt -r requirements-dev.txt - name: Compile Python sources - run: python -m compileall -q app tests scheduler_runner.py rotate_credential_secret.py + run: python -m compileall -q app tests container gunicorn.conf.py scheduler_runner.py rotate_credential_secret.py - name: Smoke test application startup run: python -m pytest tests/smoke -q diff --git a/.gitignore b/.gitignore index 1c0bc40..5313acb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,11 @@ __pycache__/ /.pytest_cache/ /.coverage /.idea/ +/tests/frontend/node_modules/ +/.env +/.env.* +!/.env.example +/secrets/ +/backups/ +/compose.override.yaml +/compose.rollback.yaml diff --git a/CLA.md b/CLA.md new file mode 100644 index 0000000..f4dce2d --- /dev/null +++ b/CLA.md @@ -0,0 +1,128 @@ +# RMON Contributor License Agreement + +This Contributor License Agreement ("Agreement") applies to each contribution +that you submit to RMON after this Agreement is adopted by the project. + +By expressly accepting this Agreement in connection with a pull request or +other contribution, you agree to the terms below. + +## 1. Definitions + +"Contribution" means any original work of authorship, including source code, +documentation, tests, designs, or other material, that you intentionally submit +to the RMON project for inclusion in the project. + +"Project" means RMON and the repositories maintained as part of the +RMON project. + +"Project Owner" means the copyright holder or entity authorized to maintain +and license RMON. + +## 2. Your ownership + +You retain ownership of the copyright in your Contribution. This Agreement is a +license grant; it is not an assignment of your copyright. + +## 3. Copyright license + +You grant the Project Owner and recipients of software distributed by the +Project Owner a perpetual, worldwide, non-exclusive, royalty-free, +irrevocable copyright license to reproduce, prepare derivative works of, +publicly display, publicly perform, distribute, and otherwise use your +Contribution as part of RMON. + +You also grant the Project Owner the right to sublicense and relicense your +Contribution, including as part of RMON, under source-available, +open-source, proprietary, commercial, or other software-license terms chosen by +the Project Owner. + +This includes the right to offer RMON containing your Contribution +under both the project's public source license and separate commercial license +terms. + +## 4. Patent license + +To the extent you can grant patent rights, you grant the Project Owner and +recipients of RMON a perpetual, worldwide, non-exclusive, +royalty-free patent license under patent claims that you control and that are +necessarily infringed by your Contribution alone or by combination of your +Contribution with the Project as submitted. + +If a recipient institutes patent litigation alleging that the Contribution or +RMON infringes a patent, that recipient's patent license granted under +this section terminates to the extent permitted by applicable law. + +## 5. Authority to contribute + +You represent that: + +- you are legally entitled to grant the rights in this Agreement; +- the Contribution is your original work, or you have sufficient rights to + submit it under this Agreement; +- to your knowledge, the Contribution does not intentionally include material + that cannot legally be contributed to the Project; +- if your employer or another organization owns rights in the Contribution, + you have obtained authorization to submit it and grant the rights in this + Agreement. + +If you are contributing on behalf of a company or other organization, you +represent that you are authorized to accept this Agreement on its behalf, or +that an authorized representative has separately approved the contribution. + +## 6. Third-party material + +If a Contribution includes third-party material, identify that material and its +license clearly in the pull request or accompanying documentation. Do not submit +third-party code whose license is incompatible with RMON's licensing +model. + +## 7. No obligation + +The Project Owner is not required to use, merge, distribute, support, or retain +any Contribution. + +## 8. No warranties + +Unless required by applicable law or agreed separately in writing, your +Contribution is provided without warranties or conditions of any kind. + +## 9. Public licensing and commercial licensing + +You understand that RMON may be distributed under the Elastic License +2.0 or another public license selected by the Project Owner and may also be +licensed to other parties under separate commercial or proprietary terms. + +No revenue share, royalty, fee, or other payment is owed to you merely because +your Contribution is included in a commercially licensed version of +RMON. + +## 10. How to accept + +For a GitHub pull request, accept this Agreement by posting the following +comment on the pull request: + +`/sign-cla` + +The project's GitHub workflow records the acceptance together with your +numeric GitHub user ID, the accepted CLA version, the date of acceptance, +and the originating pull request/comment. + +You only need to accept a particular version of this Agreement once. If this +Agreement changes, the Project may require you to accept the new version before +additional Contributions are merged. + +A Contribution must not be merged unless the contributor has expressly +accepted the applicable version of this Agreement. + +## 11. Existing contributions + +This Agreement does not retroactively change the license under which a +Contribution was previously submitted and accepted before the Project adopted +this CLA. Existing contributions remain subject to the rights already granted +for those contributions unless the contributor separately agrees otherwise. + +## 12. Entire contributor agreement + +This Agreement governs the rights granted for Contributions submitted after its +adoption unless the contributor and Project Owner enter into a separate written +agreement covering those Contributions. diff --git a/COMMERCIAL_LICENSE.md b/COMMERCIAL_LICENSE.md new file mode 100644 index 0000000..b1a35c4 --- /dev/null +++ b/COMMERCIAL_LICENSE.md @@ -0,0 +1,74 @@ +# RMON commercial licensing + +RMON is available under the Elastic License 2.0 (ELv2) for uses +permitted by that license. Separate commercial licensing is available for +organizations that need additional rights. + +## Hosted and managed services + +If your organization wants to provide RMON, or a service exposing a +substantial set of RMON features or functionality, to third parties as +a hosted or managed service, obtain a commercial license before launching that +offering. + +Commercial licensing can cover use cases such as: + +- SaaS and RMON-as-a-Service; +- managed or multi-tenant RMON hosting; +- white-label hosted services; +- OEM or embedded commercial distribution; +- negotiated redistribution rights; +- commercial branding arrangements; +- enterprise support, warranty, indemnity, or other negotiated terms. + +## No commercial rights are granted by this file + +This document describes the availability of commercial licensing. It is not a +commercial license grant and does not itself give any organization additional +rights beyond the license that already applies to the software. + +Additional commercial rights exist only after the copyright holder and the +customer enter into a separate written agreement that expressly grants those +rights. + +## Expected commercial agreement + +A signed commercial agreement should normally identify at least: + +- the licensor's legal name and address; +- the customer's legal name and address; +- the RMON software and versions covered; +- whether SaaS, hosted, managed, OEM, white-label, or redistribution rights are + granted; +- permitted production environments, customers, tenants, or usage limits, if + applicable; +- fees and payment terms; +- agreement term and renewal terms; +- support and service obligations, if any; +- confidentiality terms, if any; +- intellectual-property ownership; +- warranty, liability, and indemnity terms; +- termination and post-termination rights; +- governing law and dispute-resolution terms. + +## Requesting a license + +Contact the RMON copyright holder through the project repository with: + +1. your organization's legal name; +2. a short description of the product or service; +3. whether customers will access the RMON UI, APIs, or monitoring checks; +4. whether the service is single-tenant or multi-tenant; +5. expected number of customers or deployments; +6. whether you need white-label, OEM, redistribution, support, or indemnity + rights. + +The copyright holder can then provide commercial terms appropriate for that +use case. + +## Legal identity + +A repository username or project name should not be used as a substitute for +the licensor's legal identity in a signed commercial agreement. Before signing +a commercial agreement, replace project-only identifiers with the correct legal +name, address, and other required contracting details. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1639130 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# Contributing to RMON + +## Backend changes policy + +Any backend code change must be covered by tests. + +When changing backend logic: + +- add new tests for new behavior; +- update existing tests for changed behavior; +- do not remove failing tests unless the tested behavior was intentionally removed; +- if a backend change does not need tests, explain why in the pull request. + +## Contributor License Agreement + +RMON uses a Contributor License Agreement so that contributions can be +distributed under the project's public source license and, where applicable, +under separate commercial terms. + +Before a pull request can be merged, read [CLA.md](CLA.md) and accept it by +posting this exact comment on your pull request: + +`/sign-cla` + +Acceptance is recorded against your numeric GitHub user ID and the version of +`CLA.md` on the repository's default branch. You only need to sign once per CLA +version. A checked box or a statement in the pull request description does not +replace the signing comment. + +The `CLA` status check verifies the pull request author's acceptance. If a pull +request contains contributions from other people, maintainers must also ensure +that those contributors have accepted the applicable CLA. Identify third-party +material and its license in the pull request. + +Do not merge a contribution until the applicable contributors have expressly +accepted the CLA. + +## Maintainer setup + +The signing workflow reads `CLA.md` from the default branch. Publish the CLA and +`.github/workflows/cla.yml` there before relying on the signing workflow for +pull requests targeting `main` or `release/**`. + +Configure the repository's branch protection or rulesets to require the `CLA` +status check. Adding the workflow alone does not enforce a merge restriction. + +The workflow maintains a public `[CLA] Signature Registry` issue. Only signature +records written by the workflow's GitHub Actions bot are accepted. Changes to +the CLA on the default branch refresh open pull request statuses and require +contributors to accept the new version. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3a1c162 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 python3-venv python3-ldap python3-pkg-resources \ + ca-certificates openssl openssh-client sshpass git util-linux \ + iputils-ping iputils-tracepath dnsutils netcat-openbsd nmap \ + && rm -rf /var/lib/apt/lists/* \ + && python3 -m venv --system-site-packages /opt/rmon-venv + +ENV PATH="/opt/rmon-venv/bin:${PATH}" \ + PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 \ + RMON_CONTAINER=1 RMON_PROXY_MODE=1 RMON_PROMETHEUS_MULTIPROC_DIR=/tmp/rmon-prometheus \ + ANSIBLE_HOME=/tmp/ansible-local ANSIBLE_LOCAL_TEMP=/tmp/ansible-local ANSIBLE_REMOTE_TEMP=/tmp/ansible-remote + +WORKDIR /var/www/rmon +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY app/scripts/ansible/requirements.yml /tmp/ansible-requirements.yml +RUN ANSIBLE_HOME=/tmp/ansible-build ANSIBLE_LOCAL_TEMP=/tmp/ansible-build ansible-galaxy collection install \ + -r /tmp/ansible-requirements.yml -p /usr/share/ansible/collections +COPY app ./app +COPY config_other ./config_other +COPY container ./container +COPY app.wsgi gunicorn.conf.py scheduler_runner.py operations_runner.py runtime_health.py rotate_credential_secret.py favicon.ico LICENSE NOTICE LICENSING.md ./ +RUN install -d /etc/rmon /var/lib/rmon /var/log/rmon \ + && install -d -o www-data -g www-data \ + app/scripts/ansible/inventory app/scripts/ansible/artifacts app/scripts/ansible/env /tmp/ansible-local \ + && chmod -R go-w /var/www/rmon + +EXPOSE 8080 +STOPSIGNAL SIGTERM +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD ["/opt/rmon-venv/bin/python", "-m", "container.runtime", "healthcheck"] +ENTRYPOINT ["/opt/rmon-venv/bin/python", "-m", "container.runtime"] +CMD ["serve"] diff --git a/LICENSE b/LICENSE index 3d84448..cdba7ad 100644 --- a/LICENSE +++ b/LICENSE @@ -1,203 +1,94 @@ -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - 10. Modification and distribution for commercial purposes is prohibited without the consent of the author. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [2024] [Pavel Loginov] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +Elastic License 2.0 + +URL: https://www.elastic.co/licensing/elastic-license + +Acceptance + +By using the software, you agree to all of the terms and conditions below. + +Copyright License + +The licensor grants you a non-exclusive, royalty-free, worldwide, +non-sublicensable, non-transferable license to use, copy, distribute, make +available, and prepare derivative works of the software, in each case subject +to the limitations and conditions below. + +Limitations + +You may not provide the software to third parties as a hosted or managed +service, where the service provides users with access to any substantial set of +the features or functionality of the software. + +You may not move, change, disable, or circumvent the license key functionality +in the software, and you may not remove or obscure any functionality in the +software that is protected by the license key. + +You may not alter, remove, or obscure any licensing, copyright, or other notices +of the licensor in the software. Any use of the licensor's trademarks is +subject to applicable law. + +Patents + +The licensor grants you a license, under any patent claims the licensor can +license, or becomes able to license, to make, have made, use, sell, offer for +sale, import and have imported the software, in each case subject to the +limitations and conditions in this license. This license does not cover any +patent claims that you cause to be infringed by modifications or additions to +the software. + +If you or your company make any written claim that the software infringes or +contributes to infringement of any patent, your patent license for the software +granted under these terms ends immediately. If your company makes such a claim, +your patent license ends immediately for work on behalf of your company. + +Notices + +You must ensure that anyone who gets a copy of any part of the software from +you also gets a copy of these terms. + +If you modify the software, you must include in any modified copies of the +software prominent notices stating that you have modified the software. + +No Other Rights + +These terms do not imply any licenses other than those expressly granted in +these terms. + +Termination + +If you use the software in violation of these terms, such use is not licensed, +and your licenses will automatically terminate. If the licensor provides you +with a notice of your violation, and you cease all violation of this license no +later than 30 days after you receive that notice, your licenses will be +reinstated retroactively. However, if you violate these terms after such +reinstatement, any additional violation of these terms will cause your licenses +to terminate automatically and permanently. + +No Liability + +As far as the law allows, the software comes as is, without any warranty or +condition, and the licensor will not be liable to you for any damages arising +out of these terms or the use or nature of the software, under any kind of legal +claim. + +Definitions + +The licensor is the entity offering these terms, and the software is the +software the licensor makes available under these terms, including any portion +of it. + +you refers to the individual or entity agreeing to these terms. + +your company is any legal entity, sole proprietorship, or other kind of +organization that you work for, plus all organizations that have control over, +are under the control of, or are under common control with that organization. +control means ownership of substantially all the assets of an entity, or the +power to direct its management and policies by vote, contract, or otherwise. +Control can be direct or indirect. + +your licenses are all the licenses granted to you for the software under these +terms. + +use means anything you do with the software requiring one of your licenses. + +trademark means trademarks, service marks, and similar rights. diff --git a/LICENSING.md b/LICENSING.md new file mode 100644 index 0000000..6f370e4 --- /dev/null +++ b/LICENSING.md @@ -0,0 +1,110 @@ +# RMON licensing + +RMON uses a source-available licensing model designed to keep +self-hosting broadly available while reserving hosted/managed RMON +services for commercial licensing. + +## Community license + +Starting with the 1.4.0 release line, the RMON source distribution is offered +under the Elastic License 2.0 (ELv2), SPDX identifier `Elastic-2.0`. + +The canonical ELv2 text is published by Elastic at: +https://www.elastic.co/licensing/elastic-license + +An SPDX reference is available at: +https://spdx.org/licenses/Elastic-2.0.html + +ELv2 is source-available, but it is not an OSI-approved Open Source license. + +## Typical permitted uses + +Subject to the complete ELv2 terms, typical permitted uses include: + +- running RMON internally in your own organization; +- modifying RMON for your own internal use; +- evaluating, testing, and developing against RMON; +- redistributing RMON subject to ELv2 requirements; +- a consultant installing or operating RMON for a customer where the + customer is using it internally and the consultant is not offering + RMON itself as a hosted product to multiple third parties. + +## When a commercial license is required + +A separate commercial agreement is required when you want rights that are not +granted by ELv2. The primary example is offering RMON, or a service +that exposes a substantial set of RMON features or functionality, to +third parties as a hosted or managed service. + +Examples include: + +- RMON-as-a-Service; +- a hosted monitoring product that substantially exposes the + RMON UI, APIs, monitoring checks, or feature set to customers; +- a managed multi-tenant RMON offering; +- a white-label hosted RMON service; +- another hosted or managed offering where commercial rights beyond ELv2 are + needed. + +A commercial license may also be used for negotiated OEM, redistribution, +branding, support, indemnity, warranty, or other enterprise rights. + +See `COMMERCIAL_LICENSE.md` for the commercial licensing process. + +## What is not automatically a hosted RMON service + +Using RMON as an internal component of a larger service does not by +itself mean that the larger service is prohibited. The controlling terms are +the ELv2 terms, including whether third-party users are given access to a +substantial set of RMON features or functionality. + +When a proposed deployment is close to that boundary, obtain written licensing +confirmation before launching the service. + +## Historical releases + +RMON versions and copies distributed before this licensing change remain +subject to the terms that accompanied those copies. This change does not revoke +previously granted rights or retroactively apply ELv2 to earlier copies. + +Before this change, the repository's `LICENSE` contained the Apache License 2.0 +text with an additional clause restricting modification and distribution for +commercial purposes without the author's consent. The exact previous text is +preserved in [licenses/legacy/LICENSE-before-1.4.0.txt](licenses/legacy/LICENSE-before-1.4.0.txt) +for historical reference. It is not an alternative license for the current +source distribution. + +Consult the license and notices shipped with the particular historical copy; +the transition does not relabel those terms as MIT or unmodified Apache 2.0. + +## Contributions + +New contributions are accepted only under the contributor terms in `CLA.md`. +Those terms are intended to ensure that RMON can continue to be +published under ELv2 while also being offered under separate commercial terms. + +Contributors retain ownership of their contributions but grant the project the +rights described in the CLA, including the rights needed for sublicensing and +commercial licensing. + +## Third-party dependencies + +Third-party dependencies and vendored components retain their own licenses. +Nothing in RMON's licensing files changes the license of third-party +software. + +## Trademarks + +A software license does not automatically grant rights to project names, +logos, service marks, or other trademarks except to the extent required by +applicable law or expressly stated in a separate agreement. + +## Questions and commercial licensing + +For a proposed SaaS, hosted, managed, OEM, white-label, or other commercial use, +contact the RMON copyright holder through the project repository before +launching the offering. + +A signed commercial agreement should identify the licensor and licensee by +legal name and should state the exact rights granted, fees, term, support (if +any), liability terms, governing law, and termination terms. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..fc636f9 --- /dev/null +++ b/NOTICE @@ -0,0 +1,6 @@ +RMON +Copyright 2024 Pavel Loginov + +Third-party dependencies and vendored components retain their own copyright +notices and licenses. See LICENSING.md for the current licensing model and +historical release terms. diff --git a/README.md b/README.md index b315b76..2092f44 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,47 @@ -# ![alt text](https://rmon.io/static/images/logo/orange_640.png "Logo") -RMON (remote monitoring) is an easy to understand and use geo-distributed monitoring. +# RMON -# Get involved -* [Telegram Channel](https://t.me/roxy_wi_channel) about RMON, talks and questions are welcome +RMON monitors service availability from multiple locations, records results and alerts your team. It supports HTTP(S), TCP, Ping, DNS, SMTP and RabbitMQ checks, notification channels and public status pages. -![alt text](https://rmon.io/static//images/rmon_history_dashboard.png "RMON check history") +![RMON Dashboard](https://rmon.io/static/images/docs/dashboard.jpg) -# Features: -1. Checking ping availability -2. Checking DNS records availability -3. Checking the availability of TCP and UDP ports -4. Checking HTTP statuses -5. Checking the BODY of HTTP(s) responses -6. Checking the SSL expiration date -7. Checking SMTP service -8. Checking RabbitMQ service -9. Sending Telegram, Slack, PagerDuty and Email notifications -10. Real-time alerting via RMON web interface -11. Checking network connectivity -12. Providing information upon response time -13. Providing information upon servers uptime and downtime -14. Storing the alarm history -15. Storing the history of events for each host -16. Status pages -17. RMON Agents -18. Network tools +## Get started -# Install +Follow the [installation guide](https://rmon.io/installation) for native installations or Docker. For a new Docker deployment, prepare `compose.yaml` and `.env` as described in the [Docker guide](https://rmon.io/installation#docker), make the selected images available on the host, then run: -For installation on EL and Ubuntu read this [guide](https://rmon.io/installation) +```sh +docker compose config --quiet +docker compose run --rm --no-deps web init +docker compose up -d --no-build +``` -## Security configuration +Choose the administrator password when prompted and open your RMON HTTPS address. For an existing installation, follow the [migration guide](https://rmon.io/installation#docker-existing-rmon-installation) and preserve the database and application keys; do not run `init`. -RMON no longer ships reusable application or credential-encryption secrets. Set these values in the service environment before starting RMON: +Add [hosts and SSH credentials](https://rmon.io/howto/setup), [install agents](https://rmon.io/howto/manage-agents), then [create a check](https://rmon.io/howto/assign-checks). The website covers [HTTPS and mTLS settings](https://rmon.io/settings#agent-connections), [notifications](https://rmon.io/howto/notifications), [status pages](https://rmon.io/howto/status-pages) and [updates and backups](https://rmon.io/update-guide). -- `RMON_SECRET_KEY`: a random value of at least 32 characters for Flask sessions. -- `RMON_SECRET_PHRASE`: a Fernet key for stored SSH passwords, passphrases, and private keys. Generate one with `python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`. -- `RMON_JWT_PRIVATE_KEY_FILE` and `RMON_JWT_PUBLIC_KEY_FILE`: optional overrides for the default JWT key paths in `/var/lib/rmon/keys`. +For Kubernetes, prepare the Secrets, storage and `values.yaml` using the [Kubernetes guide](https://rmon.io/installation#kubernetes), then install: -The Flask secret may instead be stored in `RMON_SECRET_KEY_FILE`; when neither setting exists, RMON creates `/var/lib/rmon/keys/flask-secret` with mode `0600`. The scheduler itself remains enabled by default, but its unauthenticated REST API is disabled. Set `RMON_SCHEDULER_ENABLED=0` when a separate scheduler process is used. +```sh +helm upgrade --install rmon oci://ghcr.io/roxy-wi/rmon/rmon-chart \ + --version 1.4.2 --namespace rmon --create-namespace \ + --values values.yaml --wait --timeout 10m +``` -To rotate an existing credential key, back up the database and run `rotate_credential_secret.py` with both `RMON_OLD_SECRET_PHRASE` and the new `RMON_SECRET_PHRASE` in the environment. The update is transactional and can safely skip values that were already rotated. +## Plans and support -## OpenID Connect +RMON is a commercial product. See [documentation](https://rmon.io/howto) and [support contacts](https://rmon.io/contacts). -Super administrators can configure one or more OIDC providers under **Admin area → OIDC**. RMON supports discovery metadata, signed ID-token validation through JWKS, optional UserInfo claims, verified-email and domain policies, automatic user creation or email linking, and external-group mappings to RMON groups and roles. Local and LDAP login remain available. +## License -Set `RMON_PUBLIC_URL=https://rmon.example.com` when RMON is behind a reverse proxy so the generated callback URL uses the public origin. Register the displayed `/oidc//callback` URL in the identity provider and include at least the `openid` scope. OIDC client secrets are encrypted with `RMON_SECRET_PHRASE` and are included in `rotate_credential_secret.py` rotations. +Starting with the 1.4.0 release line, RMON is source-available under the +[Elastic License 2.0](LICENSE) (`Elastic-2.0`). Self-hosted and internal use is +available subject to ELv2. Offering RMON, or a service exposing a substantial +set of its functionality, to third parties as a hosted or managed service +requires rights permitted by ELv2 or a separate commercial license. -## Subscription feature catalog +See [LICENSING.md](LICENSING.md) for usage examples and historical release +terms, and [COMMERCIAL_LICENSE.md](COMMERCIAL_LICENSE.md) for commercial +licensing. Contributions are subject to [CLA.md](CLA.md); see +[CONTRIBUTING.md](CONTRIBUTING.md) for the signing process. -Subscription checks are centralized in `app/modules/subscription/access.py`, following the Roxy-WI entitlement model. The catalog covers OIDC, action history, monitoring history, alert history, alerting channels, status pages, monitoring check and agent limits, and service control. - -All cataloged features are currently free. `SUBSCRIPTION_ENFORCEMENT_ENABLED` is intentionally set to `False` in the subscription module, so stored license status and plan values do not restrict access. When paid enforcement is ready, change that code constant to `True`; the existing feature policies, route guards, business-service checks, UI visibility rules, and plan limits will become active together. - -![alt text](https://rmon.io/static//images/rmon_checks.png "RMON checks") +Historical versions and copies retain the terms that accompanied them. +Third-party dependencies and vendored components retain their own licenses. diff --git a/app/__init__.py b/app/__init__.py index 2f623aa..05e574e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -7,9 +7,12 @@ from prometheus_client import multiprocess from prometheus_client.core import CollectorRegistry from prometheus_flask_exporter import PrometheusMetrics +from app.version import get_service_version app = Flask(__name__) app.config.from_object('app.config.Configuration') +from app.modules.common.proxy import configure_trusted_proxy +configure_trusted_proxy(app) app.jinja_env.add_extension('jinja2.ext.do') app.jinja_env.add_extension('jinja2.ext.loopcontrols') @@ -27,7 +30,7 @@ multiprocess.MultiProcessCollector(registry, path=os.getenv('RMON_PROMETHEUS_MULTIPROC_DIR', '/tmp')) metrics = PrometheusMetrics(app, registry=registry) -metrics.info('rmon', 'RMON app', version='1.2.12') +metrics.info('rmon', 'RMON app', version=get_service_version()) from app.api.v1.routes.main import bp as main_api_v1_0_bp from app.api.v1.routes.user import bp as user_api_v1_0_bp diff --git a/app/api/v1/routes/main/routes.py b/app/api/v1/routes/main/routes.py index fdfac4a..2528608 100644 --- a/app/api/v1/routes/main/routes.py +++ b/app/api/v1/routes/main/routes.py @@ -45,7 +45,7 @@ def register_api_with_group(view, endpoint, url_beg, pk='receiver', pk_type='int ) bp.add_url_rule( - '/settings/', + '/settings/', view_func=SettingsView.as_view('settings_section'), methods=['GET', 'POST'] ) diff --git a/app/create_db.py b/app/create_db.py index 0c4e14b..7b72cd4 100644 --- a/app/create_db.py +++ b/app/create_db.py @@ -48,14 +48,6 @@ def default_values(): {'param': 'use_victoria_metrics', 'value': '0', 'section': 'smon', 'desc': '', 'group_id': '1'}, {'param': 'victoria_metrics_select', 'value': '', 'section': 'smon', 'desc': '', 'group_id': '1'}, {'param': 'victoria_metrics_insert', 'value': '', 'section': 'smon', 'desc': '', 'group_id': '1'}, - {'param': 'rabbitmq_enabled', 'value': '0', 'section': 'rabbitmq', 'desc': 'Enable alerting in the WEB panel', 'group_id': '1'}, - {'param': 'rabbitmq_host', 'value': '127.0.0.1', 'section': 'rabbitmq', 'desc': 'RabbitMQ-server host', 'group_id': '1'}, - {'param': 'rabbitmq_port', 'value': '5672', 'section': 'rabbitmq', 'desc': 'RabbitMQ-server port', 'group_id': '1'}, - {'param': 'rabbitmq_port', 'value': '5672', 'section': 'rabbitmq', 'desc': 'RabbitMQ-server port', 'group_id': '1'}, - {'param': 'rabbitmq_vhost', 'value': '/', 'section': 'rabbitmq', 'desc': 'RabbitMQ-server vhost', 'group_id': '1'}, - {'param': 'rabbitmq_queue', 'value': 'rmon', 'section': 'rabbitmq', 'desc': 'RabbitMQ-server queue', 'group_id': '1'}, - {'param': 'rabbitmq_user', 'value': 'rmon', 'section': 'rabbitmq', 'desc': 'RabbitMQ-server user', 'group_id': '1'}, - {'param': 'rabbitmq_password', 'value': 'rmon123', 'section': 'rabbitmq', 'desc': 'RabbitMQ-server user password', 'group_id': '1'}, {'param': 'mail_ssl', 'value': '0', 'section': 'mail', 'desc': 'Enable TLS', 'group_id': '1'}, {'param': 'mail_from', 'value': '', 'section': 'mail', 'desc': 'Address of sender', 'group_id': '1'}, {'param': 'mail_smtp_host', 'value': '', 'section': 'mail', 'desc': 'SMTP server address', 'group_id': '1'}, @@ -70,7 +62,8 @@ def default_values(): ] try: - Setting.insert_many(data_source).on_conflict_ignore().execute() + from app.modules.common.agent_transport import setting_rows + Setting.insert_many(data_source + setting_rows(1)).on_conflict_ignore().execute() except Exception as e: print(str(e)) @@ -124,10 +117,8 @@ def default_values(): print(str(e)) data_source = [ - {'name': 'rmon-socket', 'current_version': '1.0', 'new_version': '0', 'is_roxy': 1, 'desc': ''}, {'name': 'rmon-server', 'current_version': '1.0', 'new_version': '0', 'is_roxy': 1, 'desc': ''}, {'name': 'fail2ban', 'current_version': '1.0', 'new_version': '1.0', 'is_roxy': 0, 'desc': 'Fail2ban service'}, - {'name': 'rabbitmq-server', 'current_version': '1.0', 'new_version': '1.0', 'is_roxy': 0, 'desc': 'Rabbitmq service'}, ] try: @@ -146,5 +137,20 @@ def default_values(): if __name__ == "__main__": + # Models describe the current schema; historical rename/drop migrations must + # not be replayed against a freshly created database. + bootstrap_connection = connect() + try: + was_empty = not bootstrap_connection.get_tables() + finally: + bootstrap_connection.close() create_tables() default_values() + if was_empty: + from app.modules.db.migrations import get_migration_files + from app.version import get_service_version + if User.select().count() != 3 or Role.select().count() != 4 or not Setting.select().exists(): + raise RuntimeError('New database initialization is incomplete; migrations were not baselined') + for filename in get_migration_files(): + Migration.create(name=filename[:-3]) + Version.insert(version=get_service_version()).execute() diff --git a/app/jobs.py b/app/jobs.py index 3b8dec4..fad52d8 100644 --- a/app/jobs.py +++ b/app/jobs.py @@ -67,6 +67,8 @@ def delete_old_logs(): @scheduler.task('interval', id='update_owner_on_log', hours=12, misfire_grace_time=None) def update_owner_on_log(): + if os.getenv('RMON_CONTAINER') == '1': + return log_path = get_config.get_config_var('main', 'log_path') try: if distro.id() == 'ubuntu': @@ -79,13 +81,28 @@ def update_owner_on_log(): @scheduler.task('interval', id='delete_ansible_artifacts', hours=24, misfire_grace_time=None) def delete_ansible_artifacts(): + from app.modules.db.db_model import OperationJob, conn + try: + if OperationJob.select().where(OperationJob.status == 'running').exists(): + return + finally: + conn.close() ansible_path = '/var/www/rmon/app/scripts/ansible' folders = ['artifacts', 'env'] for folder in folders: if os.path.isdir(f'{ansible_path}/{folder}'): try: - shutil.rmtree(f'{ansible_path}/{folder}') + # Preserve writable roots and recent files created by operations. + cutoff = datetime.datetime.now().timestamp() - 2 * 86400 + with os.scandir(f'{ansible_path}/{folder}') as entries: + for entry in entries: + if entry.stat(follow_symlinks=False).st_mtime >= cutoff: + continue + if entry.is_dir(follow_symlinks=False): + shutil.rmtree(entry.path) + else: + os.unlink(entry.path) except Exception as e: raise Exception(f'error: Cron cannot delete ansible folders: {e}') @@ -110,3 +127,13 @@ def delete_smon_history(): app = scheduler.app with app.app_context(): smon_sql.delete_smon_history() + + +@scheduler.task('interval', id='cleanup_operations', hours=24, max_instances=1, coalesce=True) +def cleanup_operations(): + from app.modules.operations.queue import cleanup + from app.modules.db.db_model import conn + try: + return cleanup() + finally: + conn.close() diff --git a/app/migrate.py b/app/migrate.py index dd5cb01..f149321 100755 --- a/app/migrate.py +++ b/app/migrate.py @@ -2,16 +2,16 @@ import os import sys import argparse +from tempfile import TemporaryDirectory # Add the app directory to the Python path sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from app.modules.db.migrations import ( - create_migration, migrate, rollback, list_migrations, MigrationError -) - - def main(): + from app.modules.db.migrations import ( + create_migration, migrate, rollback, list_migrations, MigrationError + ) + parser = argparse.ArgumentParser(description='RMON Database Migration Tool') subparsers = parser.add_subparsers(dest='command', help='Command to run') @@ -53,4 +53,8 @@ def main(): if __name__ == '__main__': - main() + os.environ['RMON_SCHEDULER_ENABLED'] = '0' + # CLI imports initialize metrics too; keep them separate from running services. + with TemporaryDirectory(prefix='rmon-migrate-metrics-') as metrics_directory: + os.environ['RMON_PROMETHEUS_MULTIPROC_DIR'] = metrics_directory + main() diff --git a/app/migrations/20260911000000_add_agent_result_transport.py b/app/migrations/20260911000000_add_agent_result_transport.py new file mode 100644 index 0000000..98a75b1 --- /dev/null +++ b/app/migrations/20260911000000_add_agent_result_transport.py @@ -0,0 +1,34 @@ +from peewee import CharField, DateTimeField +from playhouse.migrate import migrate + +from app.modules.db.db_model import Groups, Setting, connect +from app.modules.common.agent_transport import setting_rows, DEFAULTS + + +FIELDS = { + 'result_transport': CharField(null=True), + 'applied_result_transport': CharField(null=True), + 'transport_settings_hash': CharField(null=True), + 'transport_checked_at': DateTimeField(null=True), +} + + +def upgrade(): + database = connect() + migrator = connect(get_migrator=True) + existing = {column.name for column in database.get_columns('smon_agents')} + for name, field in FIELDS.items(): + if name not in existing: + migrate(migrator.add_column('smon_agents', name, field)) + for group in Groups.select(): + Setting.insert_many(setting_rows(group.group_id)).on_conflict_ignore().execute() + + +def downgrade(): + database = connect() + migrator = connect(get_migrator=True) + Setting.delete().where(Setting.param.in_(tuple(DEFAULTS))).execute() + existing = {column.name for column in database.get_columns('smon_agents')} + for name in FIELDS: + if name in existing: + migrate(migrator.drop_column('smon_agents', name)) diff --git a/app/migrations/20260912000000_add_http_ssl_policy.py b/app/migrations/20260912000000_add_http_ssl_policy.py new file mode 100644 index 0000000..e68be3c --- /dev/null +++ b/app/migrations/20260912000000_add_http_ssl_policy.py @@ -0,0 +1,19 @@ +from peewee import CharField, SQL +from playhouse.migrate import migrate + +from app.modules.db.db_model import connect + + +def upgrade(): + database = connect() + if 'ssl_policy' not in {column.name for column in database.get_columns('smon_http_check')}: + migrate(connect(get_migrator=True).add_column( + 'smon_http_check', 'ssl_policy', + CharField(default='default', constraints=[SQL("DEFAULT 'default'")]), + )) + + +def downgrade(): + database = connect() + if 'ssl_policy' in {column.name for column in database.get_columns('smon_http_check')}: + migrate(connect(get_migrator=True).drop_column('smon_http_check', 'ssl_policy')) diff --git a/app/migrations/20260913000000_remove_web_notifications.py b/app/migrations/20260913000000_remove_web_notifications.py new file mode 100644 index 0000000..87a567d --- /dev/null +++ b/app/migrations/20260913000000_remove_web_notifications.py @@ -0,0 +1,12 @@ +from app.modules.db.db_model import connect, Setting, RoxyTool + + +def upgrade(): + database = connect() + with database.connection_context(), database.atomic(): + Setting.delete().where(Setting.section == 'rabbitmq').execute(database) + RoxyTool.delete().where(RoxyTool.name.in_(('rmon-socket', 'rabbitmq-server'))).execute(database) + + +def downgrade(): + raise RuntimeError('Restore a database backup to recover removed web-notification settings.') diff --git a/app/migrations/20260916000000_add_operation_queue.py b/app/migrations/20260916000000_add_operation_queue.py new file mode 100644 index 0000000..61817a3 --- /dev/null +++ b/app/migrations/20260916000000_add_operation_queue.py @@ -0,0 +1,21 @@ +from app.modules.db.db_model import InstallationTasks, OperationJob, OperationLock, connect + + +def upgrade(): + database = connect() + with database.bind_ctx([OperationJob, OperationLock]): + database.create_tables([OperationJob, OperationLock], safe=True) + index_name = 'installation_tasks_status_finish_date' + if index_name not in {index.name for index in database.get_indexes('installation_tasks')}: + from playhouse.migrate import migrate + migrate(connect(get_migrator=True).add_index('installation_tasks', ('status', 'finish_date'))) + + +def downgrade(): + database = connect() + if 'operation_jobs' in database.get_tables(): + with database.bind_ctx([OperationJob]): + if OperationJob.select().where(OperationJob.status.in_(('queued', 'running'))).exists(): + raise RuntimeError('Finish pending agent operations before rolling back this migration') + with database.bind_ctx([OperationJob, OperationLock]): + database.drop_tables([OperationJob, OperationLock], safe=True) diff --git a/app/modules/common/agent_transport.py b/app/modules/common/agent_transport.py new file mode 100644 index 0000000..a9380b0 --- /dev/null +++ b/app/modules/common/agent_transport.py @@ -0,0 +1,130 @@ +"""Group-owned settings for agent result delivery.""" +import hashlib +import json +import os +from pathlib import Path +from uuid import UUID + +MODES = ('http', 'https', 'mtls') +DEFAULTS = { + 'agent_result_transport': 'http', + 'agent_tls_ca_file': '', + 'agent_tls_generate': '1', + 'agent_tls_ca_key_file': '', + 'agent_tls_cert_file': '', + 'agent_tls_key_file': '', +} +PATH_SETTINGS = frozenset(DEFAULTS) - {'agent_result_transport', 'agent_tls_generate'} + + +def setting_rows(group_id): + return [dict(param=key, value=value, section='agent', desc='', group_id=group_id) + for key, value in DEFAULTS.items()] + + +def group_settings(group_id): + from app.modules.db.db_model import Setting + values = dict(DEFAULTS) + values.update({row.param: row.value or '' for row in Setting.select().where( + (Setting.group_id == int(group_id)) & (Setting.param.in_(tuple(DEFAULTS))))}) + return values + + +def certificate_root(group_id): + # Only host configuration can select the base; group admins cannot change it. + base = Path(os.getenv('RMON_AGENT_CERTIFICATES_DIR', '/var/lib/rmon/keys/agent-certificates')) + base = base.resolve() + root = base / str(int(group_id)) + if not root.resolve().is_relative_to(base) or root.is_symlink(): + raise ValueError('The group certificate directory is invalid') + return root + + +def certificate_path(value, group_id, agent_uuid=None, *, require_file=False): + if not value: + return '' + if any(char in value for char in ('\n', '\r', '\x00', ',', '\\')): + raise ValueError('Invalid certificate path') + if '{' in value or '}' in value: + if value.replace('{uuid}', '').find('{') != -1 or '}' in value.replace('{uuid}', ''): + raise ValueError('Only {uuid} is supported in certificate paths') + value = value.replace('{uuid}', str(UUID(str(agent_uuid))) if agent_uuid else 'agent-id') + root = certificate_root(group_id) + path = Path(value) + if not path.is_absolute() or not path.resolve().is_relative_to(root): + raise ValueError(f'Certificate files must be inside {root.as_posix()}') + if require_file and (not path.is_file() or not os.access(path, os.R_OK)): + raise ValueError('A configured certificate file is missing or cannot be read') + return path.resolve().as_posix() + + +def validate_setting(param, value, group_id): + if param not in DEFAULTS: + raise ValueError('Unknown agent connection setting') + value = value.strip() + if param == 'agent_result_transport' and value not in MODES: + raise ValueError('Select HTTP, HTTPS or HTTPS + mTLS') + if param == 'agent_tls_generate' and value not in ('0', '1'): + raise ValueError('Certificate generation must be enabled or disabled') + if param in PATH_SETTINGS: + if '{uuid}' in value and param not in ('agent_tls_cert_file', 'agent_tls_key_file'): + raise ValueError('The CA path cannot contain {uuid}') + certificate_path(value, group_id) + return value + + +def settings_hash(values, mode): + # A new group default does not reconfigure agents that already chose a mode. + keys = set() + if mode == 'mtls': + keys = PATH_SETTINGS | {'agent_tls_generate'} + elif mode == 'https': + keys = {'agent_tls_ca_file'} + data = {key: values[key] for key in sorted(keys)} + return hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest() + + +def inventory_settings(group_id, agent_uuid, mode): + if mode not in MODES: + raise ValueError('Select HTTP, HTTPS or HTTPS + mTLS') + values = group_settings(group_id) + validate_setting('agent_tls_generate', values['agent_tls_generate'], group_id) + result = dict(agent_transport=mode, agent_mtls_enabled=mode == 'mtls', + agent_tls_generate=values['agent_tls_generate'] == '1', + agent_verify_result_connection=True, + agent_transport_settings_hash=settings_hash(values, mode), + agent_certificate_directory=certificate_root(group_id).as_posix()) + for key in PATH_SETTINGS: + result[key] = '' + if mode == 'http' or (mode == 'https' and key != 'agent_tls_ca_file'): + continue + value = values[key] + validate_setting(key, value, group_id) + result[key] = certificate_path(value, group_id, agent_uuid, require_file=True) + if mode == 'mtls': + if not result['agent_tls_ca_file']: + raise ValueError('Select the trusted CA certificate in the agent group settings') + if bool(result['agent_tls_cert_file']) != bool(result['agent_tls_key_file']): + raise ValueError('Specify both the client certificate and its private key') + return result + + +def pending(agent): + mode = agent.result_transport + if not mode: + return False + return (mode != agent.applied_result_transport or + (mode != 'http' and agent.transport_settings_hash != + settings_hash(group_settings(agent.server_id.group_id), mode))) + + +def record_applied(inventory): + from datetime import datetime + from app.modules.db.db_model import SmonAgent + for host in inventory.get('server', {}).get('hosts', {}).values(): + if host.get('agent_verify_result_connection') and host.get('action') == 'install': + SmonAgent.update( + applied_result_transport=host['agent_transport'], + transport_settings_hash=host['agent_transport_settings_hash'], + transport_checked_at=datetime.now(), + ).where(SmonAgent.uuid == str(host['agent_uuid'])).execute() diff --git a/app/modules/common/proxy.py b/app/modules/common/proxy.py new file mode 100644 index 0000000..2d94039 --- /dev/null +++ b/app/modules/common/proxy.py @@ -0,0 +1,11 @@ +"""Only enabled by deployments where the Gunicorn backend is private.""" +import os + +from werkzeug.middleware.proxy_fix import ProxyFix + + +def configure_trusted_proxy(app): + if os.getenv('RMON_PROXY_MODE') == '1': + # Nginx replaces XFF; Apache appends the actual client as the last hop. + # Gunicorn handles the trusted scheme header. Never trust forwarded Host. + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=0, x_host=0, x_port=0, x_prefix=0) diff --git a/app/modules/db/db_model.py b/app/modules/db/db_model.py index 0c85325..995a32a 100644 --- a/app/modules/db/db_model.py +++ b/app/modules/db/db_model.py @@ -248,11 +248,11 @@ class OidcProvider(BaseModel): token_endpoint = TextField(null=True) userinfo_endpoint = TextField(null=True) jwks_uri = TextField(null=True) - scope = CharField(constraints=[SQL('DEFAULT "openid email profile"')]) - subject_claim = CharField(constraints=[SQL('DEFAULT "sub"')]) - email_claim = CharField(constraints=[SQL('DEFAULT "email"')]) - username_claim = CharField(constraints=[SQL('DEFAULT "preferred_username"')]) - groups_claim = CharField(constraints=[SQL('DEFAULT "groups"')]) + scope = CharField(constraints=[SQL("DEFAULT 'openid email profile'")]) + subject_claim = CharField(constraints=[SQL("DEFAULT 'sub'")]) + email_claim = CharField(constraints=[SQL("DEFAULT 'email'")]) + username_claim = CharField(constraints=[SQL("DEFAULT 'preferred_username'")]) + groups_claim = CharField(constraints=[SQL("DEFAULT 'groups'")]) allowed_domains = TextField(null=True) auto_create_users = IntegerField(constraints=[SQL('DEFAULT 0')]) auto_link_by_email = IntegerField(constraints=[SQL('DEFAULT 1')]) @@ -383,6 +383,10 @@ class SmonAgent(BaseModel): shared = IntegerField(constraints=[SQL('DEFAULT 0')], index=True) port = IntegerField(constraints=[SQL('DEFAULT 5701')]) region_id = ForeignKeyField(Region, null=True, on_delete='SET NULL', index=True) + result_transport = CharField(null=True) + applied_result_transport = CharField(null=True) + transport_settings_hash = CharField(null=True) + transport_checked_at = DateTimeField(null=True) class Meta: table_name = 'smon_agents' @@ -573,11 +577,12 @@ class SmonHttpCheck(BaseModel): body_req = JSONField(null=True) ignore_ssl_error = IntegerField(constraints=[SQL('DEFAULT 0')]) redirects = IntegerField(constraints=[SQL('DEFAULT 10')]) + ssl_policy = CharField(default='default', constraints=[SQL("DEFAULT 'default'")]) auth = JSONField(null=True) body_json = JSONField(null=True) proxy = JSONField(null=True) headers_response = JSONField(null=True) - accept_cookies = BooleanField(constraints=[SQL('DEFAULT 1')]) + accept_cookies = BooleanField(constraints=[SQL('DEFAULT TRUE')]) http_version = IntegerField(constraints=[SQL('DEFAULT 0')]) resole_to_ip = CharField(null=True) @@ -660,6 +665,31 @@ class InstallationTasks(BaseModel): class Meta: table_name = 'installation_tasks' + indexes = ((('status', 'finish_date'), False),) + + +class OperationJob(BaseModel): + task = ForeignKeyField(InstallationTasks, primary_key=True, on_delete='CASCADE') + server = ForeignKeyField(Server, on_delete='CASCADE') + payload = TextField(null=True) + status = CharField(default='queued') + available_at = DateTimeField() + owner = CharField(null=True) + lease_until = DateTimeField(null=True) + attempts = IntegerField(default=0) + + class Meta: + table_name = 'operation_jobs' + indexes = ((('status', 'available_at'), False), (('status', 'lease_until'), False), + (('server', 'status', 'task'), False)) + + +class OperationLock(BaseModel): + server = ForeignKeyField(Server, primary_key=True, on_delete='CASCADE') + owner = CharField(null=True, index=True) + + class Meta: + table_name = 'operation_locks' class AlertState(BaseModel): @@ -705,5 +735,5 @@ def create_tables(): Setting, Cred, Version, ActionHistory, Region, SystemInfo, UserName, PD, SmonHistory, SmonAgent, SmonTcpCheck, SmonHttpCheck, SmonPingCheck, SmonDnsCheck, RoxyTool, SmonStatusPage, SmonStatusPageCheck, SMON, SmonGroup, MM, RMONAlertsHistory, SmonSMTPCheck, SmonRabbitCheck, - Country, MultiCheck, Email, InstallationTasks, Migration, AlertEvent, AlertState, AggregatorLock, IncidentRelay] + Country, MultiCheck, Email, InstallationTasks, OperationJob, OperationLock, Migration, AlertEvent, AlertState, AggregatorLock, IncidentRelay] ) diff --git a/app/modules/db/group.py b/app/modules/db/group.py index 8fffc07..8ad1e8a 100644 --- a/app/modules/db/group.py +++ b/app/modules/db/group.py @@ -1,4 +1,5 @@ from peewee import IntegrityError +from app.modules.common.agent_transport import setting_rows from app.modules.db.db_model import Groups, OidcGroupMapping, Setting, UserGroups from app.modules.db.common import out_error, resource_not_empty @@ -58,7 +59,7 @@ def add_setting_for_new_group(group_id): ] try: - Setting.insert_many(data_source).execute() + Setting.insert_many(data_source + setting_rows(group_id)).execute() except Exception as e: out_error(e) diff --git a/app/modules/db/roxy.py b/app/modules/db/roxy.py index 2ad2a67..62fc287 100644 --- a/app/modules/db/roxy.py +++ b/app/modules/db/roxy.py @@ -2,6 +2,9 @@ from app.modules.db.common import out_error +SUPPORTED_SERVICES = ('rmon-server', 'fail2ban') + + def insert_user_name(user_name): try: UserName.insert(UserName=user_name).execute() @@ -67,7 +70,7 @@ def select_user_all(): def get_roxy_tools(): - query = RoxyTool.select() + query = RoxyTool.select().where(RoxyTool.name.in_(SUPPORTED_SERVICES)) try: query_res = query.where(RoxyTool.is_roxy == 1).execute() except Exception as e: @@ -81,7 +84,7 @@ def get_roxy_tools(): def get_all_tools(): try: - query_res = RoxyTool.select().execute() + query_res = RoxyTool.select().where(RoxyTool.name.in_(SUPPORTED_SERVICES)).execute() except Exception as e: out_error(e) else: diff --git a/app/modules/db/sql.py b/app/modules/db/sql.py index e935a0a..c94d7eb 100755 --- a/app/modules/db/sql.py +++ b/app/modules/db/sql.py @@ -17,7 +17,7 @@ def get_setting(param, **kwargs): if param in ( 'proxy', 'agent_port', 'master_port', 'master_ip', 'rmon_name', 'use_victoria_metrics', - 'victoria_metrics_select', 'rabbitmq_enabled', 'json_format' + 'victoria_metrics_select', 'json_format' ): user_group_id = 1 @@ -40,7 +40,7 @@ def get_setting(param, **kwargs): if param in ( 'session_ttl', 'token_ttl', 'ldap_type', 'ldap_port', 'ldap_enable', 'log_time_storage', 'syslog_server_enable', 'keep_history_range', 'ssl_expire_warning_alert', 'ssl_expire_critical_alert', 'action_keep_history_range', - 'use_victoria_metrics', 'mail_enabled', 'mail_send_hello_message', 'rabbitmq_enabled', 'json_format' + 'use_victoria_metrics', 'mail_enabled', 'mail_send_hello_message', 'json_format' ): return int(setting.value) else: diff --git a/app/modules/operations/__init__.py b/app/modules/operations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/operations/queue.py b/app/modules/operations/queue.py new file mode 100644 index 0000000..ab90f1c --- /dev/null +++ b/app/modules/operations/queue.py @@ -0,0 +1,166 @@ +"""Durable agent operations. Empty polls are a single indexed read, never a write.""" +import json +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +from cryptography.fernet import Fernet +from peewee import MySQLDatabase, PostgresqlDatabase, SQL, Select, fn + +from app.modules.db.db_model import InstallationTasks, OperationJob, OperationLock, Server, conn +from app.modules.server.ssh import _get_fernet_key + +LEASE_SECONDS = 180 +MAX_ATTEMPTS = 3 +RETENTION_DAYS = 30 + + +def clock_expression(): + if isinstance(conn, MySQLDatabase): + return fn.UTC_TIMESTAMP() + if isinstance(conn, PostgresqlDatabase): + return fn.timezone('UTC', SQL('CURRENT_TIMESTAMP')) + return SQL('CURRENT_TIMESTAMP') + + +def database_time(): + value = conn.execute(Select(columns=[clock_expression()])).fetchone() + now = value[0] + if isinstance(now, str): + now = datetime.fromisoformat(now) + if now.tzinfo: + now = now.astimezone(timezone.utc).replace(tzinfo=None) + return now + + +def enqueue(inventory, server_ips, role, service_name, action, user_id): + if role != 'rmon_agent' or action not in {'install', 'delete', 'reconfigure', 'start', 'stop', 'restart'}: + raise ValueError('Unsupported agent operation') + if len(server_ips) != 1 or set(inventory.get('server', {}).get('hosts', {})) != set(server_ips): + raise ValueError('An agent operation must target exactly one server') + server = Server.get(Server.ip == server_ips[0]) + payload = Fernet(_get_fernet_key()).encrypt(json.dumps({ + 'inventory': inventory, 'server_ips': server_ips, 'role': role, + }, separators=(',', ':')).encode()).decode() + with conn.atomic(): + OperationLock.insert(server=server.server_id).on_conflict_ignore().execute() + task = InstallationTasks.create(service_name=service_name, server_id=server.server_id, + user_id=user_id, group_id=server.group_id, action=action) + OperationJob.create(task=task.id, server=server.server_id, payload=payload, available_at=database_time()) + return task.id + + +def claim(): + earlier = OperationJob.alias() + preceding = earlier.select(SQL('1')).where( + (earlier.server == OperationJob.server) & (earlier.task < OperationJob.task) + & earlier.status.in_(('queued', 'running'))) + candidates = list(OperationJob.select(OperationJob.task, OperationJob.server) + .join(OperationLock, on=(OperationJob.server == OperationLock.server)) + .where((OperationJob.status == 'queued') & (OperationJob.available_at <= clock_expression()) + & OperationLock.owner.is_null() & ~fn.EXISTS(preceding)) + .order_by(OperationJob.task).limit(16).dicts()) + for candidate in candidates: + owner = uuid4().hex + with conn.atomic() as transaction: + locked = OperationLock.update(owner=owner).where( + (OperationLock.server == candidate['server']) & OperationLock.owner.is_null()).execute() + if not locked: + continue + now = database_time() + updated = OperationJob.update(status='running', owner=owner, + lease_until=now + timedelta(seconds=LEASE_SECONDS), + attempts=OperationJob.attempts + 1).where( + (OperationJob.task == candidate['task']) & (OperationJob.status == 'queued') + & (OperationJob.available_at <= now)).execute() + if not updated: + transaction.rollback() + continue + InstallationTasks.update(status='running', error=None).where( + InstallationTasks.id == candidate['task']).execute() + return candidate['task'], owner + return None + + +def renew(task_id, owner): + now = database_time() + deadline = now + timedelta(seconds=LEASE_SECONDS) + changed = OperationJob.update(lease_until=deadline).where( + (OperationJob.task == task_id) & (OperationJob.owner == owner) + & (OperationJob.status == 'running') & (OperationJob.lease_until > now)).execute() == 1 + if changed: + return True + # MySQL reports zero affected rows when the deadline is already the same. + return OperationJob.select().where( + (OperationJob.task == task_id) & (OperationJob.owner == owner) + & (OperationJob.status == 'running') & (OperationJob.lease_until >= deadline) + & (OperationJob.lease_until > clock_expression())).exists() + + +def load_payload(task_id, owner): + job = OperationJob.get((OperationJob.task == task_id) & (OperationJob.owner == owner) + & (OperationJob.status == 'running') & (OperationJob.lease_until > clock_expression())) + if str(job.task.group_id_id) != str(job.server.group_id): + raise ValueError('The server group changed; submit the operation again') + return json.loads(Fernet(_get_fernet_key()).decrypt(job.payload.encode())) + + +def finish(task_id, owner, error=None, on_success=None): + with conn.atomic(): + now = database_time() + status = 'failed' if error else 'completed' + changed = OperationJob.update(status=status, payload=None, owner=None, lease_until=None).where( + (OperationJob.task == task_id) & (OperationJob.owner == owner) + & (OperationJob.status == 'running') & (OperationJob.lease_until > now)).execute() + if not changed: + return False + if not error and on_success: + on_success() + InstallationTasks.update(status=status, error=error, finish_date=datetime.now()).where( + InstallationTasks.id == task_id).execute() + OperationLock.update(owner=None).where(OperationLock.owner == owner).execute() + return True + + +def recover_expired(limit=100): + expired = list(OperationJob.select(OperationJob.task, OperationJob.owner, OperationJob.attempts) + .where((OperationJob.status == 'running') & (OperationJob.lease_until <= clock_expression())) + .order_by(OperationJob.lease_until).limit(limit).dicts()) + recovered = 0 + for job in expired: + with conn.atomic(): + now = database_time() + failed = job['attempts'] >= MAX_ATTEMPTS + changed = OperationJob.update( + status='failed' if failed else 'queued', owner=None, lease_until=None, + available_at=now + timedelta(seconds=30), + **({'payload': None} if failed else {}), + ).where((OperationJob.task == job['task']) & (OperationJob.owner == job['owner']) + & (OperationJob.status == 'running') & (OperationJob.lease_until <= now)).execute() + if not changed: + continue + InstallationTasks.update( + status='failed' if failed else 'created', + error='Operation interrupted repeatedly. Check the agent and try again.' if failed else None, + finish_date=datetime.now(), + ).where(InstallationTasks.id == job['task']).execute() + OperationLock.update(owner=None).where(OperationLock.owner == job['owner']).execute() + recovered += 1 + return recovered + + +def cleanup(batch_size=500, max_batches=20): + """Bound each daily cleanup; pending operations are never removed.""" + cutoff = datetime.now() - timedelta(days=RETENTION_DAYS) + active = OperationJob.select(OperationJob.task).where(OperationJob.status.in_(('queued', 'running'))) + eligible = ((InstallationTasks.status.in_(('completed', 'failed'))) + & (InstallationTasks.finish_date < cutoff) & InstallationTasks.id.not_in(active)) + deleted = 0 + for _ in range(max_batches): + ids = [row.id for row in InstallationTasks.select(InstallationTasks.id).where(eligible) + .order_by(InstallationTasks.finish_date).limit(batch_size)] + if not ids: + break + deleted += InstallationTasks.delete().where(eligible & InstallationTasks.id.in_(ids)).execute() + if len(ids) < batch_size: + break + return deleted diff --git a/app/modules/operations/worker.py b/app/modules/operations/worker.py new file mode 100644 index 0000000..259a24e --- /dev/null +++ b/app/modules/operations/worker.py @@ -0,0 +1,140 @@ +import logging +import random +import signal +import threading +import time + +import runtime_health +from app import app +from app.modules.db.db_model import conn +from app.modules.operations import queue + +logger = logging.getLogger(__name__) + + +class LeaseGuard: + """Cancel Ansible before a lease expires, even if the renewal query hangs.""" + def __init__(self): + self.lost = False + self.renewed() + + def renewed(self): + self.deadline = time.monotonic() + queue.LEASE_SECONDS - 60 + + def cancelled(self): + return self.lost or time.monotonic() >= self.deadline + + +class Worker: + def __init__(self): + self.stop_event = threading.Event() + self.idle_delay = 2.0 + self.error_delay = 5.0 + self.next_recovery = 0.0 + self.next_artifact_cleanup = 0.0 + + def stop(self, *_): + self.stop_event.set() + + def wait(self, seconds, ready=True): + deadline = time.monotonic() + seconds + while not self.stop_event.is_set(): + runtime_health.pulse('operations', ready=ready) + remaining = deadline - time.monotonic() + if remaining <= 0: + return + self.stop_event.wait(min(5, remaining)) + + def execute(self, task_id, owner): + guard = LeaseGuard() + done = threading.Event() + result = {} + + def run(): + try: + with app.app_context(): + payload = queue.load_payload(task_id, owner) + from app.modules.service.installation import execute_installation + execute_installation(payload['inventory'], payload['server_ips'], payload['role'], + cancel_callback=guard.cancelled) + result['inventory'] = payload['inventory'] + except Exception as error: + logger.error('Agent operation %s failed (%s)', task_id, type(error).__name__) + result['error'] = 'Agent operation failed. Check SSH access and the operations service logs.' + finally: + try: + conn.close() + finally: + done.set() + + thread = threading.Thread(target=run, name=f'agent-operation-{task_id}', daemon=False) + thread.start() + next_renew = time.monotonic() + 30 + lost = False + while not done.wait(5): + runtime_health.pulse('operations', ready=not lost and not self.stop_event.is_set()) + if not lost and time.monotonic() >= next_renew: + try: + lost = not queue.renew(task_id, owner) + except Exception as error: + logger.error('Cannot renew operation %s (%s); cancelling execution', task_id, type(error).__name__) + lost = True + finally: + conn.close() + if lost: + guard.lost = True + else: + guard.renewed() + next_renew = time.monotonic() + 30 + thread.join() + if lost or guard.cancelled(): + logger.warning('Operation %s lost its lease; completion was not recorded', task_id) + return + from app.modules.common.agent_transport import record_applied + if not queue.finish(task_id, owner, result.get('error'), + on_success=lambda: record_applied(result['inventory'])): + logger.warning('Operation %s completion rejected after lease expiry', task_id) + + def step(self): + try: + if time.monotonic() >= self.next_recovery: + queue.recover_expired() + self.next_recovery = time.monotonic() + 60 + claimed = queue.claim() + runtime_health.pulse('operations', ready=not self.stop_event.is_set()) + if claimed: + self.execute(*claimed) + self.idle_delay = 2.0 + elif time.monotonic() >= self.next_artifact_cleanup: + # Each operations container owns its Ansible files; the separate + # scheduler cannot clean files in this container's filesystem. + from app.jobs import delete_ansible_artifacts + delete_ansible_artifacts() + self.next_artifact_cleanup = time.monotonic() + 86400 + self.error_delay = 5.0 + return bool(claimed) + finally: + conn.close() + + def run(self): + with app.app_context(): + while not self.stop_event.is_set(): + try: + if self.step(): + continue + except Exception as error: + logger.error('Operations service unavailable (%s); retrying in %.0fs', + type(error).__name__, self.error_delay) + self.wait(self.error_delay, ready=False) + self.error_delay = min(300, self.error_delay * 2) + continue + self.wait(min(30, self.idle_delay * random.uniform(1, 1.15))) + self.idle_delay = min(30, self.idle_delay * 2) + runtime_health.pulse('operations', ready=False) + + +def main(): + worker = Worker() + signal.signal(signal.SIGTERM, worker.stop) + signal.signal(signal.SIGINT, worker.stop) + worker.run() diff --git a/app/modules/roxywi/auth.py b/app/modules/roxywi/auth.py index 5882581..ccea617 100644 --- a/app/modules/roxywi/auth.py +++ b/app/modules/roxywi/auth.py @@ -97,7 +97,7 @@ def check_in_ldap(user, password): def do_login(user_params: dict, next_url: str): next_url = safe_next_url(next_url) - redirect_to = f'https://{request.host}{next_url}' + redirect_to = next_url response = jsonify({"status": "done", "next_url": redirect_to}) access_token = create_jwt_token(user_params) diff --git a/app/modules/roxywi/class_models.py b/app/modules/roxywi/class_models.py index 73a4558..797f94b 100644 --- a/app/modules/roxywi/class_models.py +++ b/app/modules/roxywi/class_models.py @@ -216,6 +216,7 @@ class HttpCheckRequest(BaseCheckRequest): body_json: Optional[JSONPathRule] = None accepted_status_codes: List[Union[int, str]] ignore_ssl_error: Optional[bool] = 0 + ssl_policy: Literal['default', 'require_https', 'require_http'] = 'default' redirects: Optional[int] = 10 auth: Optional[dict] = None proxy: Optional[HttpProxy] = None @@ -284,7 +285,7 @@ def validate_status_codes(cls, v): class DnsCheckRequest(BaseCheckRequest): resolver: Union[IPvAnyAddress, DomainName] port: Annotated[int, Gt(1), Le(65535)] = 53 - record_type: Literal['a', 'aaa', 'caa', 'cname', 'mx', 'ns', 'ptr', 'sao', 'src', 'txt'] + record_type: Literal['a', 'aaaa', 'caa', 'cname', 'mx', 'ns', 'ptr', 'soa', 'srv', 'txt'] ip: Union[IPvAnyAddress, DomainName] @@ -348,7 +349,7 @@ class AddUserToGroup(BaseModel): class RmonAgent(BaseModel): name: EscapedString - description: Optional[EscapedString] = None + description: Optional[EscapedString] = '' enabled: Optional[bool] = 1 shared: Optional[bool] = 0 port: Annotated[int, Gt(1024), Le(65535)] = 5101 @@ -356,6 +357,12 @@ class RmonAgent(BaseModel): uuid: Optional[UUID4] = '' reconfigure: Optional[bool] = 0 region_id: Optional[int] = None + result_transport: Optional[Literal['http', 'https', 'mtls']] = None + + @field_validator('description', mode='before') + @classmethod + def normalize_description(cls, value): + return '' if value is None else value class GroupQuery(BaseModel): diff --git a/app/modules/roxywi/overview.py b/app/modules/roxywi/overview.py index 75d659a..b328f64 100644 --- a/app/modules/roxywi/overview.py +++ b/app/modules/roxywi/overview.py @@ -1,5 +1,3 @@ -import json - from flask import render_template import app.modules.db.sql as sql @@ -66,8 +64,7 @@ def show_overview(server_ip) -> str: agent_id = 0 try: - req = smon_agent.send_get_request_to_agent(agent_id, server_ip, 'scheduler') - req = json.loads(req.decode('utf-8')) + req = smon_agent.get_agent_health(agent_id, server_ip) except Exception: req = {'running': False} diff --git a/app/modules/roxywi/roxy.py b/app/modules/roxywi/roxy.py index 62eb09a..7329f6d 100644 --- a/app/modules/roxywi/roxy.py +++ b/app/modules/roxywi/roxy.py @@ -33,6 +33,8 @@ def versions(): json_data = { 'need_update': 0 } + if os.getenv('RMON_CONTAINER') == '1': + json_data['managed_by'] = 'docker' try: current_ver = get_service_version() json_data['current_ver'] = current_ver @@ -89,6 +91,10 @@ def update_user_status() -> None: def action_service(action: str, service: str) -> str: require_feature(SERVICE_CONTROL) + if service not in roxy_sql.SUPPORTED_SERVICES: + return 'error: Unsupported service' + if os.getenv('RMON_CONTAINER') == '1': + return 'error: Manage external services outside the RMON web container.' is_in_docker = is_docker() cmd = f"sudo systemctl disable {service} --now" if action in ("start", "restart"): diff --git a/app/modules/server/ssh_connection.py b/app/modules/server/ssh_connection.py index 7198629..dbc3a69 100644 --- a/app/modules/server/ssh_connection.py +++ b/app/modules/server/ssh_connection.py @@ -5,6 +5,8 @@ class SshConnection: def __init__(self, server_ip: str, ssh_settings: dict): + if not ssh_settings.get('user'): + raise ValueError('SSH credentials are not configured for this server') self.ssh = paramiko.SSHClient() self.ssh.load_system_host_keys() self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) diff --git a/app/modules/service/installation.py b/app/modules/service/installation.py index 3d730d8..bd7b7fa 100644 --- a/app/modules/service/installation.py +++ b/app/modules/service/installation.py @@ -1,6 +1,8 @@ import os -import random -import threading +import json +import uuid +import subprocess +from pathlib import Path from datetime import datetime from packaging import version @@ -12,104 +14,96 @@ from app.modules.db.db_model import InstallationTasks -def run_ansible(inv: dict, server_ips: list, ansible_role: str) -> dict: - import ansible_runner - - inventory_path = '/var/www/rmon/app/scripts/ansible/inventory' - inventory = f'{inventory_path}/{ansible_role}-{random.randint(0, 135)}.json' - proxy = sql.get_setting('proxy') - proxy_serv = '' - tags = '' - agent_pid = server_mod.start_ssh_agent() - - try: - _install_ansible_collections() - except Exception as e: - raise Exception(f'{e}') - - for server_ip in server_ips: - ssh_settings = return_ssh_keys_path(server_ip) - if ssh_settings['enabled']: - inv['server']['hosts'][server_ip]['ansible_ssh_private_key_file'] = ssh_settings['key'] - inv['server']['hosts'][server_ip]['ansible_password'] = ssh_settings['password'] - inv['server']['hosts'][server_ip]['ansible_user'] = ssh_settings['user'] - inv['server']['hosts'][server_ip]['ansible_port'] = ssh_settings['port'] - inv['server']['hosts'][server_ip]['ansible_become'] = True - - if ssh_settings['enabled']: - try: - server_mod.add_key_to_agent(ssh_settings, agent_pid) - except Exception as e: - server_mod.stop_ssh_agent(agent_pid) - raise Exception(e) - - if proxy is not None and proxy != '' and proxy != 'None': - proxy_serv = proxy - - inv['server']['hosts'][server_ip]['PROXY'] = proxy_serv - - if 'DOCKER' in inv['server']['hosts'][server_ip]: - if inv['server']['hosts'][server_ip]['DOCKER']: - tags = 'docker' - else: - tags = 'system' - - envvars = { - 'ANSIBLE_DISPLAY_OK_HOSTS': 'no', - 'ANSIBLE_SHOW_CUSTOM_STATS': 'no', - 'ANSIBLE_DISPLAY_SKIPPED_HOSTS': "no", - 'ANSIBLE_DEPRECATION_WARNINGS': "no", - 'ANSIBLE_HOST_KEY_CHECKING': "no", - 'ANSIBLE_TIMEOUT': 15, - 'ACTION_WARNINGS': "no", - 'LOCALHOST_WARNING': "no", - 'COMMAND_WARNINGS': "no", - 'AWX_DISPLAY': False, - 'SSH_AUTH_PID': agent_pid['pid'], - 'SSH_AUTH_SOCK': agent_pid['socket'], - 'ANSIBLE_PYTHON_INTERPRETER': '/usr/bin/python3' - } - kwargs = { - 'private_data_dir': '/var/www/rmon/app/scripts/ansible/', - 'inventory': inventory, - 'envvars': envvars, - 'playbook': f'/var/www/rmon/app/scripts/ansible/roles/{ansible_role}.yml', - 'tags': tags - } - - if os.path.isfile(inventory): - os.remove(inventory) - - if not os.path.isdir(inventory_path): - os.makedirs(inventory_path) - - try: - with open(inventory, 'a') as invent: - invent.write(str(inv)) - except Exception as e: - server_mod.stop_ssh_agent(agent_pid) - roxywi_common.handle_exceptions(e, 'Cannot save inventory file') - - try: - result = ansible_runner.run(**kwargs) - except Exception as e: - server_mod.stop_ssh_agent(agent_pid) - roxywi_common.handle_exceptions(e, 'Cannot run Ansible') - - try: - server_mod.stop_ssh_agent(agent_pid) - except Exception as e: - roxywi_common.logger(f'Cannot stop SSH agent {e}', 'error') - - os.remove(inventory) - - if result.rc != 0: - raise Exception('Something wrong with installation, check Apache logs for details') - - return result.stats +def run_ansible(inv: dict, server_ips: list, ansible_role: str, *, cancel_callback=None) -> dict: + import ansible_runner + import logging + + def check_cancelled(): + if cancel_callback and cancel_callback(): + raise RuntimeError('Agent operation was interrupted') + + check_cancelled() + collection_path = _ensure_agent_collections() if ansible_role == 'rmon_agent' else None + if collection_path is None: + _install_ansible_collections() + proxy = sql.get_setting('proxy') + proxy_serv = proxy if proxy and proxy != 'None' else '' + inventory_path = Path('/var/www/rmon/app/scripts/ansible/inventory') + inventory = inventory_path / f'{ansible_role}-{uuid.uuid4().hex}.json' + agent_pid = server_mod.start_ssh_agent() + tags = '' + try: + for server_ip in server_ips: + check_cancelled() + ssh_settings = return_ssh_keys_path(server_ip) + host = inv['server']['hosts'][server_ip] + if ssh_settings['enabled']: + host['ansible_ssh_private_key_file'] = ssh_settings['key'] + server_mod.add_key_to_agent(ssh_settings, agent_pid) + host.update(ansible_password=ssh_settings['password'], ansible_user=ssh_settings['user'], + ansible_port=ssh_settings['port'], ansible_become=True, ansible_connection='ssh', + PROXY=proxy_serv) + if 'DOCKER' in host: + tags = 'docker' if host['DOCKER'] else 'system' + envvars = { + 'ANSIBLE_DISPLAY_OK_HOSTS': 'no', 'ANSIBLE_SHOW_CUSTOM_STATS': 'no', + 'ANSIBLE_DISPLAY_SKIPPED_HOSTS': 'no', 'ANSIBLE_DEPRECATION_WARNINGS': 'no', + 'ANSIBLE_HOST_KEY_CHECKING': 'no', 'ANSIBLE_TIMEOUT': 15, + 'ACTION_WARNINGS': 'no', 'LOCALHOST_WARNING': 'no', 'COMMAND_WARNINGS': 'no', + 'AWX_DISPLAY': False, 'SSH_AUTH_PID': agent_pid['pid'], 'SSH_AUTH_SOCK': agent_pid['socket'], + 'ANSIBLE_PYTHON_INTERPRETER': '/usr/bin/python3', + } + if collection_path: + envvars['ANSIBLE_COLLECTIONS_PATH'] = collection_path + if os.getenv('RMON_CONTAINER') == '1': + envvars.update(ANSIBLE_HOME='/tmp/ansible-local', ANSIBLE_SSH_CONTROL_PATH_DIR='/tmp/ansible-local/cp') + kwargs = { + 'private_data_dir': '/var/www/rmon/app/scripts/ansible/', + 'inventory': str(inventory), 'envvars': envvars, + 'playbook': f'/var/www/rmon/app/scripts/ansible/roles/{ansible_role}.yml', 'tags': tags, + } + if cancel_callback is not None: + kwargs['cancel_callback'] = cancel_callback + check_cancelled() + inventory_path.mkdir(parents=True, exist_ok=True) + descriptor = os.open(inventory, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, 'w') as stream: + json.dump(inv, stream) + result = ansible_runner.run(**kwargs) + check_cancelled() + if result.rc != 0: + raise RuntimeError('Agent operation failed. Check SSH access and the failed task in the operations service logs.') + return result.stats + finally: + try: + inventory.unlink(missing_ok=True) + finally: + try: + server_mod.stop_ssh_agent(agent_pid) + except Exception as error: + logging.getLogger(__name__).error('Cannot stop operation SSH agent (%s)', type(error).__name__) + + +def _ensure_agent_collections() -> str: + if os.getenv('RMON_CONTAINER') == '1': + root = Path('/usr/share/ansible/collections') + else: + from app.modules.roxy_wi_tools import GetConfigVar + root = Path(GetConfigVar().get_config_var('main', 'lib_path')) / 'ansible/collections' + required = ('docker', 'general', 'crypto') + if not all((root / 'ansible_collections/community' / name).is_dir() for name in required): + if os.getenv('RMON_CONTAINER') == '1': + raise RuntimeError('Required Ansible collections are missing from this RMON installation') + root.mkdir(parents=True, exist_ok=True) + manifest = Path(__file__).resolve().parents[2] / 'scripts/ansible/requirements.yml' + subprocess.run(['ansible-galaxy', 'collection', 'install', '-r', str(manifest), '-p', str(root)], + check=True, capture_output=True, text=True, timeout=180) + return str(root) def _install_ansible_collections(): + if os.getenv('RMON_CONTAINER') == '1': + return import ansible old_ansible_server = '' @@ -131,27 +125,29 @@ def _install_ansible_collections(): def run_ansible_thread(inv: dict, server_ips: list, ansible_role: str, service_name: str, action: str) -> int: + # Keep the existing caller contract; execution now belongs to the operations service. + from app.modules.operations.queue import enqueue claims = roxywi_common.get_jwt_token_claims() - server_id = server_sql.get_server_by_ip(server_ips[0]).server_id + return enqueue(inv, server_ips, ansible_role, service_name, action, claims['user_id']) + - task_id = InstallationTasks.insert( - service_name=service_name, server_id=server_id, user_id=claims['user_id'], group_id=claims['group'], action=action - ).execute() - thread = threading.Thread(target=run_installations, args=(inv, server_ips, ansible_role, task_id)) - thread.start() - return task_id +def execute_installation(inv, server_ips, service, *, cancel_callback=None): + kwargs = {'cancel_callback': cancel_callback} if cancel_callback else {} + output = run_ansible(inv, server_ips, service, **kwargs) + if output.get('failures') or output.get('dark'): + raise RuntimeError('Agent operation failed. Check SSH access and the failed task in the operations service logs.') + if cancel_callback and cancel_callback(): + raise RuntimeError('Agent operation was interrupted') def run_installations(inv: dict, server_ips: list, service: str, task_id: int) -> None: try: InstallationTasks.update(status='running').where(InstallationTasks.id == task_id).execute() - output = run_ansible(inv, server_ips, service) - if len(output['failures']) > 0 or len(output['dark']) > 0: - InstallationTasks.update( - status='failed', finish_date=datetime.now(), error=f'Cannot install {service}. Check Apache error log' - ).where(InstallationTasks.id == task_id).execute() - roxywi_common.logger('', f'error: Cannot install {service}') + execute_installation(inv, server_ips, service) + if service == 'rmon_agent': + from app.modules.common.agent_transport import record_applied + record_applied(inv) InstallationTasks.update(status='completed', finish_date=datetime.now()).where(InstallationTasks.id == task_id).execute() except Exception as e: InstallationTasks.update(status='failed', finish_date=datetime.now(), error=str(e)).where(InstallationTasks.id == task_id).execute() - roxywi_common.logger('', f'error: Cannot install {service}: {e}') + roxywi_common.logging_without_user(f'Cannot install {service}: {e}', 'error', extra={'task_id': task_id}) diff --git a/app/modules/tools/alerting.py b/app/modules/tools/alerting.py index 5a3cb3c..ba8be79 100644 --- a/app/modules/tools/alerting.py +++ b/app/modules/tools/alerting.py @@ -1,7 +1,6 @@ import json from typing import Union -import pika import pagerduty import telebot from telebot import apihelper @@ -20,34 +19,6 @@ from app.modules.subscription.access import ALERTING_CHANNELS, is_feature_available, require_feature -def send_message_to_rabbit(message: str, **kwargs) -> None: - require_feature(ALERTING_CHANNELS) - rabbit_user = sql.get_setting('rabbitmq_user') - rabbit_password = sql.get_setting('rabbitmq_password') - rabbit_host = sql.get_setting('rabbitmq_host') - rabbit_port = sql.get_setting('rabbitmq_port') - rabbit_vhost = sql.get_setting('rabbitmq_vhost') - if kwargs.get('rabbit_queue'): - rabbit_queue = kwargs.get('rabbit_queue') - else: - rabbit_queue = sql.get_setting('rabbitmq_queue') - - credentials = pika.PlainCredentials(rabbit_user, rabbit_password) - parameters = pika.ConnectionParameters( - rabbit_host, - rabbit_port, - rabbit_vhost, - credentials - ) - - connection = pika.BlockingConnection(parameters) - channel = connection.channel() - channel.queue_declare(queue=rabbit_queue) - channel.basic_publish(exchange='', routing_key=rabbit_queue, body=message) - - connection.close() - - def send_email_to_server_group(subject: str, mes: str, level: str, group_id: int) -> None: require_feature(ALERTING_CHANNELS) if not sql.get_setting('mail_enabled'): @@ -508,23 +479,6 @@ def incidentrelay_send_mess(mess, level, **kwargs): return 'ok' -def check_rabbit_alert() -> Union[str, dict]: - require_feature(ALERTING_CHANNELS) - claims = roxywi_common.get_jwt_token_claims() - try: - user_group_id = claims['group'] - except Exception as e: - return roxywi_common.handle_json_exceptions(e, 'Cannot get group') - - try: - json_for_sending = {"user_group": user_group_id, "message": 'info: Test message'} - send_message_to_rabbit(json.dumps(json_for_sending)) - except Exception as e: - return roxywi_common.handle_json_exceptions(e, 'Cannot get group') - else: - return 'ok' - - def email_send_mess(mess, o_level, **kwargs): require_feature(ALERTING_CHANNELS) runbook = '' diff --git a/app/modules/tools/common.py b/app/modules/tools/common.py index 16c0aa4..45e145d 100644 --- a/app/modules/tools/common.py +++ b/app/modules/tools/common.py @@ -1,6 +1,14 @@ from typing import Union, Type +import ast +import os +from pathlib import Path +import re +import subprocess +from urllib.parse import urlparse import distro +import requests +from packaging.version import InvalidVersion, Version import app.modules.db.roxy as roxy_sql import app.modules.roxywi.roxy as roxywi_mod @@ -11,17 +19,29 @@ def get_services_status(update_cur_ver=0): services = [] - services_name = roxy_sql.get_all_tools() + refreshed = {} if update_cur_ver: try: - update_cur_tool_versions() + refreshed = update_cur_tool_versions() except Exception as e: raise Exception(f'error: Cannot update current versions: {e}') try: + # Read after refresh: the first request must not render yesterday's values. + services_name = roxy_sql.get_all_tools() for s, v in services_name.items(): status = is_tool_active(s) + v.update(refreshed.get(s, {})) + current = _valid_version(v['current_version']) + latest = _valid_version(v['new_version']) + v.setdefault('version_known', current is not None) + v.setdefault('installed', True if current else None) + v['update_available'] = bool(current and latest and Version(current) < Version(latest)) + if os.getenv('RMON_CONTAINER') == '1': + v['managed_by'] = 'external' + if not (s == 'rmon-server' and os.getenv('RMON_SERVER_INTERNAL_URL')): + v.update(current_version='0', version_known=False, installed=None, update_available=False) services.append([s, status, v]) except Exception as e: raise Exception(f'error: Cannot get tools status: {e}') @@ -30,6 +50,10 @@ def get_services_status(update_cur_ver=0): def update_roxy_wi(service: str) -> str: + if os.getenv('RMON_CONTAINER') == '1': + raise ValueError('error: Update container images or external services outside the RMON web container.') + if service == 'rmon-server' and os.getenv('RMON_SERVER_INTERNAL_URL'): + raise ValueError('error: This RMON Server is managed by Docker. Update its container image instead of installing an OS package.') restart_service = '' services = roxy_sql.get_roxy_tools() @@ -54,42 +78,123 @@ def update_roxy_wi(service: str) -> str: def is_tool_active(tool_name: str) -> str: + if os.getenv('RMON_CONTAINER') == '1': + return 'external' is_in_docker = roxywi_mod.is_docker() if is_in_docker: cmd = f"sudo supervisorctl status {tool_name}|awk '{{print $2}}'" else: cmd = f"systemctl is-active {tool_name}" status, stderr = server_mod.subprocess_execute(cmd) - return status[0] + return status[0] if status else 'unknown' -def update_cur_tool_versions() -> None: +def update_cur_tool_versions() -> dict: tools = roxy_sql.get_all_tools() + versions = {} for s, _v in tools.items(): - update_cur_tool_version(s) + versions[s] = update_cur_tool_version(s) + return versions -def update_cur_tool_version(tool_name: str) -> None: - correct_name = tool_name - if distro.id() == 'ubuntu': - cmd = f"apt list --installed 2>&1 |grep {correct_name}|awk '{{print $2}}'|sed 's/-/./'" - else: - cmd = f"rpm -q {correct_name}|awk -F\"{correct_name}\" '{{print $2}}' |awk -F\".noa\" '{{print $1}}' |sed 's/-//1' |sed 's/-/./'" +def _valid_version(value) -> str | None: + value = str(value or '').strip() + if not value or value == '0' or len(value) > 100: + return None + try: + Version(value) + except InvalidVersion: + return None + return value - service_ver, stderr = server_mod.subprocess_execute(cmd) +def _version_command(args: list[str]) -> str | None: try: - service_ver = service_ver[0] - except Exception: - service_ver = 0 + result = subprocess.run(args, capture_output=True, text=True, timeout=3, check=False) + except (OSError, subprocess.TimeoutExpired): + return None + return result.stdout.strip() if result.returncode == 0 else None - if service_ver in ('command', 'prometheus:', 'not'): - service_ver = 0 +def _source_version(directory: str) -> str | None: + """Read version metadata without importing or executing the service's code.""" try: - roxy_sql.update_tool_cur_version(tool_name, service_ver) - except Exception: + path = Path(directory) / 'modules' / 'common' / 'version.py' + if not path.is_absolute() or path.stat().st_size > 65536: + return None + tree = ast.parse(path.read_text(encoding='utf-8')) + for node in tree.body: + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == 'SERVICE_VERSION' for target in node.targets): + if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): + return _valid_version(node.value.value) + except (OSError, SyntaxError, UnicodeError, ValueError): + return None + return None + + +def _server_runtime_version() -> str | None: + """Read the explicitly configured receiver over HTTP, HTTPS or mTLS.""" + url = os.getenv('RMON_SERVER_INTERNAL_URL', '').rstrip('/') + parsed = urlparse(url) + if parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment: + return None + try: + token = Path(os.environ['RMON_SERVER_INTERNAL_TOKEN_FILE']).read_text().strip() + if not token: + return None + certfile = os.getenv('RMON_SERVER_CLIENT_CERT_FILE') + keyfile = os.getenv('RMON_SERVER_CLIENT_KEY_FILE') + if bool(certfile) != bool(keyfile) or (certfile and parsed.scheme != 'https'): + return None + cert = (certfile, keyfile) if certfile else None + with requests.Session() as session: + session.trust_env = False + with session.get(url + '/internal/version', headers={'Authorization': 'Bearer ' + token}, + verify=os.getenv('RMON_SERVER_CA_FILE') or True, cert=cert, + timeout=(3, 3), allow_redirects=False) as response: + if response.status_code != 200: + return None + data = response.json() + if isinstance(data, dict) and data.get('service') == 'rmon-server' and isinstance(data.get('version'), str): + return _valid_version(data['version']) + except (OSError, KeyError, ValueError, requests.RequestException): pass + return None + + +def update_cur_tool_version(tool_name: str) -> dict: + if not re.fullmatch(r'[a-z0-9][a-z0-9.-]*', tool_name): + raise ValueError('Invalid service name') + if tool_name == 'rmon-server' and os.getenv('RMON_SERVER_INTERNAL_URL'): + current = _server_runtime_version() + roxy_sql.update_tool_cur_version(tool_name, current or '0') + return {'current_version': current or '0', 'version_known': bool(current), 'installed': True, 'managed_by': 'docker'} + if os.getenv('RMON_CONTAINER') == '1': + return {'current_version': '0', 'version_known': False, 'installed': None, 'managed_by': 'external'} + if distro.id() in ('ubuntu', 'debian') or 'debian' in distro.like().split(): + package = _version_command(['dpkg-query', '-W', '-f=${Status}\t${Version}', tool_name]) + version = package.split('\t', 1)[1] if package and package.startswith('install ok installed\t') else None + else: + version = _version_command(['rpm', '-q', '--queryformat', '%{VERSION}-%{RELEASE}', tool_name]) + # Preserve the package revision convention already used by the UI. + current = _valid_version(version.split(':')[-1].replace('-', '.') if version else None) + if not current and version: + # Distribution suffixes (ubuntu1, el9, etc.) are package builds, not service versions. + current = _valid_version(version.split(':')[-1].split('-', 1)[0]) + installed = True if current else None + if not current: + properties = _version_command(['systemctl', 'show', tool_name, + '--property=LoadState', '--property=WorkingDirectory']) + unit = dict(line.split('=', 1) for line in (properties or '').splitlines() if '=' in line) + if unit.get('LoadState') == 'not-found': + installed = False + elif unit.get('LoadState') == 'loaded': + installed = True + if tool_name == 'rmon-server' and unit.get('WorkingDirectory'): + current = _source_version(unit['WorkingDirectory']) + roxy_sql.update_tool_cur_version(tool_name, current or '0') + return {'current_version': current or '0', 'version_known': bool(current), 'installed': installed} def get_model_for_check( diff --git a/app/modules/tools/smon.py b/app/modules/tools/smon.py index 694c9a0..89eced8 100644 --- a/app/modules/tools/smon.py +++ b/app/modules/tools/smon.py @@ -89,6 +89,7 @@ def create_http_check(data: HttpCheckRequest, check_id: int) -> Optional[tuple[d 'accepted_status_codes': data.accepted_status_codes, 'ignore_ssl_error': data.ignore_ssl_error, 'redirects': data.redirects, + 'ssl_policy': data.ssl_policy, 'auth': data.auth, 'proxy': data.proxy.model_dump(mode='json') if data.proxy else None, 'headers_response': data.headers_response.model_dump(mode='json') if data.headers_response else None, diff --git a/app/modules/tools/smon_agent.py b/app/modules/tools/smon_agent.py index ed1e5e7..5d43b4d 100644 --- a/app/modules/tools/smon_agent.py +++ b/app/modules/tools/smon_agent.py @@ -1,20 +1,33 @@ +import json import uuid +import os +import re from typing import Union import requests from requests import Response +from flask import current_app, has_app_context, has_request_context, request import app.modules.db.sql as sql import app.modules.db.smon as smon_sql import app.modules.db.server as server_sql import app.modules.roxywi.common as roxywi_common from app.modules.subscription.access import MONITORING_AGENTS, enforce_resource_limit -from app.modules.service.installation import run_ansible_thread +from app.modules.service.installation import run_ansible, run_ansible_thread from app.modules.roxywi.class_models import RmonAgent from app.modules.roxywi.exception import RoxywiResourceNotFound - - -def generate_agent_inv(server_ip: str, action: str, agent_uuid: uuid, agent_port=5101) -> object: +from app.modules.roxy_wi_tools import GetConfigVar +from app.modules.common import agent_transport + + +def generate_agent_inv(server_ip: str, action: str, agent_uuid: uuid, agent_port=5101, + *, group_id=None, result_transport=None) -> object: + config = GetConfigVar().config + control_url = os.getenv('RMON_AGENT_CONTROL_URL') or config.get('agent_deployment', 'control_url', fallback='') + if not control_url and has_app_context(): + control_url = current_app.config.get('PUBLIC_URL', '') + if not control_url and has_request_context(): + control_url = request.url_root.rstrip('/') master_port = sql.get_setting('master_port') master_ip = sql.get_setting('master_ip') if not master_ip: raise Exception(' Master IP cannot be empty') @@ -28,11 +41,32 @@ def generate_agent_inv(server_ip: str, action: str, agent_uuid: uuid, agent_port 'agent_uuid': agent_uuid, 'master_ip': master_ip, 'master_port': master_port, + 'agent_control_url': control_url, + 'agent_image': os.getenv('RMON_AGENT_IMAGE') or config.get( + 'agent_deployment', 'image', fallback='ghcr.io/roxy-wi/rmon/rmon-agent:2.0'), } + for option in ('bind_ip', 'pull'): + if config.has_option('agent_deployment', option): + inv['server']['hosts'][server_ip]['agent_' + option] = config.get('agent_deployment', option) + if action == 'install' and result_transport is not None: + if group_id is None: + raise ValueError('The agent owner group is required') + inv['server']['hosts'][server_ip].update( + agent_transport.inventory_settings(group_id, agent_uuid, result_transport)) + return inv, server_ips +def run_agent_action(agent_id: int, action: str): + if action not in ('start', 'stop', 'restart'): + raise ValueError('Unsupported agent action') + agent = smon_sql.get_agent_data(agent_id) + server_ip = smon_sql.get_agent_ip_by_id(agent_id) + inventory = {'server': {'hosts': {server_ip: {'action': action, 'agent_uuid': str(agent.uuid)}}}} + return run_ansible_thread(inventory, [server_ip], 'rmon_agent', 'Agent', action) + + def check_agent_limit(): count_agents = smon_sql.count_agents() enforce_resource_limit(MONITORING_AGENTS, count_agents) @@ -51,20 +85,24 @@ def add_agent(data: RmonAgent) -> Union[tuple[int, int], tuple[dict, int], None] return roxywi_common.handle_json_exceptions('', 'The agent is already installed the server'), 409 agent_uuid = str(uuid.uuid4()) check_agent_limit() + server = server_sql.get_server_by_ip(server_ip) + mode = data.result_transport or agent_transport.group_settings(server.group_id)['agent_result_transport'] agent_kwargs = data.model_dump(mode='json', exclude={'reconfigure': True}) agent_kwargs['uuid'] = agent_uuid + agent_kwargs['result_transport'] = mode try: - inv, server_ips = generate_agent_inv(server_ip, 'install', agent_uuid, data.port) + inv, server_ips = generate_agent_inv(server_ip, 'install', agent_uuid, data.port, + group_id=server.group_id, result_transport=mode) except Exception as e: roxywi_common.handle_exceptions(e, 'Cannot generate inventory') - try: - task_id = run_ansible_thread(inv, server_ips, 'rmon_agent', 'Agent', 'install') - except Exception as e: - roxywi_common.handle_exceptions(e, 'Cannot install RMON agent') - try: last_id = smon_sql.add_agent(**agent_kwargs) + try: + task_id = run_ansible_thread(inv, server_ips, 'rmon_agent', 'Agent', 'install') + except Exception: + smon_sql.delete_agent(last_id) + raise roxywi_common.logger('A new RMON agent has been created', 'info', keep_history=1, service='RMON') return last_id, task_id except Exception as e: @@ -76,7 +114,7 @@ def delete_agent(agent_id: int): server_ip = smon_sql.get_agent_ip_by_id(agent_id) except Exception as e: raise e - agent_uuid = '' + agent_uuid = str(smon_sql.get_agent_data(agent_id).uuid) try: inv, server_ips = generate_agent_inv(server_ip, 'uninstall', agent_uuid) return run_ansible_thread(inv, server_ips, 'rmon_agent', 'Agent', 'delete') @@ -85,22 +123,31 @@ def delete_agent(agent_id: int): def update_agent(agent_id: int, data: RmonAgent): + agent = smon_sql.get_agent_data(agent_id) + if data.server_id != agent.server_id_id: + raise ValueError('An installed agent cannot be moved to another server') json_data = data.model_dump(mode='python', exclude={'reconfigure': True, 'uuid': True}, exclude_none=True) - + inv = None + if data.reconfigure: + mode = data.result_transport or agent.result_transport + inv, server_ips = generate_agent_inv(agent.server_id.ip, 'install', agent.uuid, data.port, + group_id=agent.server_id.group_id, result_transport=mode) try: smon_sql.update_agent(agent_id, **json_data) except Exception as e: raise e if data.reconfigure: - return reconfigure_agent(agent_id) + return run_ansible_thread(inv, server_ips, 'rmon_agent', 'Agent', 'reconfigure') def reconfigure_agent(agent_id: int): agent = smon_sql.get_agent_data(agent_id) server_ip = smon_sql.select_server_ip_by_agent_id(agent_id) try: - inv, server_ips = generate_agent_inv(server_ip, 'install', agent.uuid, agent.port) + inv, server_ips = generate_agent_inv(server_ip, 'install', agent.uuid, agent.port, + group_id=agent.server_id.group_id, + result_transport=agent.result_transport) return run_ansible_thread(inv, server_ips, 'rmon_agent', 'Agent', 'reconfigure') except Exception as e: raise e @@ -123,12 +170,32 @@ def send_get_request_to_agent(agent_id: int, server_ip: str, api_path: str) -> b server_ip = smon_sql.get_agent_ip_by_id(agent_id) try: req = requests.get(f'http://{server_ip}:{agent.port}/{api_path}', headers=headers, timeout=5) - return req.content + try: + req.raise_for_status() + return req.content + finally: + req.close() + except requests.HTTPError: + raise except Exception as e: roxywi_common.logger(f'Cannot get agent status: {e}', 'error') raise Exception(' Cannot get agent status') +def get_agent_health(agent_id: int, server_ip: str) -> dict: + try: + result = send_get_request_to_agent(agent_id, server_ip, 'health') + except requests.HTTPError as error: + if error.response is None or error.response.status_code != 404: + raise + # Older agents expose status only through Flask-APScheduler. + result = send_get_request_to_agent(agent_id, server_ip, 'scheduler') + data = json.loads(result) + if not isinstance(data, dict) or not isinstance(data.get('running'), bool): + raise ValueError('Invalid agent health response') + return data + + def send_post_request_to_agent(agent_id: int, server_ip: str, api_path: str, json_data: object) -> Response: headers = get_agent_headers(agent_id) agent = smon_sql.get_agent_data(agent_id) @@ -264,11 +331,31 @@ def send_dns_checks(agent_id: int, server_ip: str, check_id=None) -> None: ) +def _require_http_policy_support(agent_id: int, server_ip: str) -> None: + try: + data = json.loads(send_get_request_to_agent(agent_id, server_ip, 'version')) + version = data.get('version') if isinstance(data, dict) else None + match = re.fullmatch(r'(\d+)\.(\d+)(?:\.\d+)?', version or '') + except Exception as error: + raise ValueError('Cannot verify agent support for HTTPS policy. Check the agent connection and try again.') from error + if not match or tuple(map(int, match.group(1, 2))) < (1, 20): + raise ValueError('HTTPS policy requires Agent 1.20 or later. Update the agent and resend the check.') + + def send_http_checks(agent_id: int, server_ip: str, check_id=None) -> None: if check_id: checks = smon_sql.select_one_smon(check_id, 2) else: checks = smon_sql.select_en_smon(agent_id, 'http') + checks = list(checks) + policy_error = None + if any(check.ssl_policy != 'default' for check in checks): + try: + _require_http_policy_support(agent_id, server_ip) + except ValueError as error: + policy_error = error + # Continue synchronizing checks that do not need the optional feature. + checks = [check for check in checks if check.ssl_policy == 'default'] for check in checks: body = check.body if body: @@ -290,6 +377,8 @@ def send_http_checks(agent_id: int, server_ip: str, check_id=None) -> None: 'body_req': check.body_req, 'header_req': check.header_req, 'redirects': check.redirects, + 'fail_if_not_ssl': check.ssl_policy == 'require_https', + 'fail_if_ssl': check.ssl_policy == 'require_http', 'auth': check.auth, 'proxy': check.proxy, 'headers_response': check.headers_response, @@ -304,6 +393,8 @@ def send_http_checks(agent_id: int, server_ip: str, check_id=None) -> None: 'error', extra={'check_id': check.id, 'agent_id': agent_id, 'multi_check_id': check.smon_id.multi_check_id} ) + if policy_error: + raise policy_error def send_smtp_checks(agent_id: int, server_ip: str, check_id=None) -> None: diff --git a/app/routes/admin/routes.py b/app/routes/admin/routes.py index c2d57fc..7075c0c 100644 --- a/app/routes/admin/routes.py +++ b/app/routes/admin/routes.py @@ -14,6 +14,7 @@ import app.modules.roxywi.auth as roxywi_auth import app.modules.roxywi.common as roxywi_common import app.modules.tools.common as tools_common +from app.modules.common.agent_transport import certificate_root @bp.before_request @@ -52,6 +53,7 @@ def admin(): 'roles': sql.select_roles(), 'timezones': pytz.all_timezones, 'settings': sql.get_setting('', all=1), + 'agent_certificate_root': certificate_root(g.user_params['group_id']).as_posix(), 'ldap_enable': sql.get_setting('ldap_enable'), 'guide_me': 1, 'user_subscription': roxywi_common.return_user_subscription(), @@ -141,7 +143,7 @@ def update_roxywi(): """ roxywi_auth.page_for_admin() versions = roxy.versions() - services = tools_common.get_services_status() + services = tools_common.get_services_status(update_cur_ver=1) lang = roxywi_common.get_user_lang_for_flask() return render_template( diff --git a/app/routes/channel/routes.py b/app/routes/channel/routes.py index 76c84eb..6f3dcd5 100644 --- a/app/routes/channel/routes.py +++ b/app/routes/channel/routes.py @@ -43,13 +43,11 @@ def load_channels(): @feature_required(ALERTING_CHANNELS) def check_sender(): json_data = request.get_json() - sender = json_data.get('sender') - send_function = { - 'email': alerting.check_email_alert, - 'web': alerting.check_rabbit_alert - } + sender = json_data.get('sender') if isinstance(json_data, dict) else None + if sender != 'email': + return jsonify({'error': 'Unsupported notification channel'}), 400 try: - send_function[sender]() + alerting.check_email_alert() return jsonify({'status': 'success'}) except Exception as e: return roxywi_common.handle_json_exceptions(e, f'Cannot send message via {sender.title()}') diff --git a/app/routes/main/routes.py b/app/routes/main/routes.py index 0463ac1..0f7b653 100644 --- a/app/routes/main/routes.py +++ b/app/routes/main/routes.py @@ -129,8 +129,9 @@ def show_roxywi_version(): @validate() def scan_port(server_ip: Union[IPvAnyAddress, DomainName]): ip = str(server_ip) - cmd = f"sudo nmap -sS {ip} |grep -E '^[[:digit:]]'|sed 's/ */ /g'" - cmd1 = f"sudo nmap -sS {ip} |head -5|tail -2" + scanner = 'nmap -sT' if os.getenv('RMON_CONTAINER') == '1' else 'sudo nmap -sS' + cmd = f"{scanner} {ip} |grep -E '^[[:digit:]]'|sed 's/ */ /g'" + cmd1 = f"{scanner} {ip} |head -5|tail -2" stdout, stderr = server_mod.subprocess_execute(cmd) stdout1, stderr1 = server_mod.subprocess_execute(cmd1) diff --git a/app/routes/smon/agent_routes.py b/app/routes/smon/agent_routes.py index 47f5349..3114c17 100644 --- a/app/routes/smon/agent_routes.py +++ b/app/routes/smon/agent_routes.py @@ -14,7 +14,7 @@ import app.modules.tools.smon_agent as smon_agent import app.modules.tools.common as tools_common import app.modules.roxywi.common as roxywi_common -import app.modules.server.server as server_mod +from app.modules.common import agent_transport def _require_agent_access(agent_id: int, server_ip: str = None): @@ -140,6 +140,15 @@ def get_free_agents(): return jsonify(servers) +@bp.get('/agent/transport-settings') +@jwt_required() +@get_user_params() +@page_for_admin(level=2) +def get_agent_transport_settings(): + group_id = g.user_params['group_id'] + return jsonify(result_transport=agent_transport.group_settings(group_id)['agent_result_transport']) + + @bp.get('/agent/count') @jwt_required() def get_agent_count(): @@ -157,7 +166,9 @@ def get_agent_count(): def get_agent_info(agent_id): try: _require_agent_access(agent_id) - agent_data = smon_sql.get_agent(agent_id) + agent_data = list(smon_sql.get_agent(agent_id)) + for item in agent_data: + item.transport_pending = agent_transport.pending(item) except Exception as e: return f'{e}' @@ -205,7 +216,7 @@ def get_agent_version(server_ip): j_resp['update'] = "1" return jsonify(j_resp) except Exception as e: - return f'{e}' + return jsonify(error='Cannot get agent version'), 502 @bp.get('/agent/uptime/') @@ -217,9 +228,12 @@ def get_agent_uptime(server_ip): try: req = smon_agent.send_get_request_to_agent(agent_id, agent.ip, 'uptime') - return req + data = json.loads(req) + if not isinstance(data, dict) or not isinstance(data.get('uptime'), str): + raise ValueError('Invalid agent uptime response') + return jsonify(data) except Exception as e: - return f'{e}' + return jsonify(error='Cannot get agent uptime'), 502 @bp.get('/agent/status/') @@ -230,10 +244,9 @@ def get_agent_status(server_ip): agent = _require_agent_access(agent_id, server_ip) try: - req = smon_agent.send_get_request_to_agent(agent_id, agent.ip, 'scheduler') - return req + return jsonify(smon_agent.get_agent_health(agent_id, agent.ip)) except Exception as e: - return f'{e}' + return jsonify(error='Cannot get agent health. Check connectivity and update agents without a health endpoint.'), 502 @bp.get('/agent/checks/') @@ -245,9 +258,12 @@ def get_agent_checks(server_ip): try: req = smon_agent.send_get_request_to_agent(agent_id, agent.ip, 'checks') - return req + count = json.loads(req) + if isinstance(count, bool) or not isinstance(count, int) or count < 0: + raise ValueError('Invalid agent check count') + return jsonify(count) except Exception as e: - return f'{e}' + return jsonify(error='Cannot get agent check count'), 502 @bp.post('/agent/action/') @@ -255,12 +271,11 @@ def get_agent_checks(server_ip): @get_user_params() @page_for_admin(level=2) def agent_action(action): - agent = _require_agent_access(int(request.form.get('agent_id'))) - server_ip = agent.ip + agent_id = int(request.form.get('agent_id')) + _require_agent_access(agent_id) try: - command = f'sudo systemctl {action} rmon-agent' - server_mod.ssh_command(server_ip, command, timeout=30) + task_id = smon_agent.run_agent_action(agent_id, action) except Exception as e: - return f'{e}' - return 'ok' + return jsonify(error=str(e)), 502 + return jsonify(status='queued', task_id=task_id), 202 diff --git a/app/scripts/ansible/requirements.yml b/app/scripts/ansible/requirements.yml new file mode 100644 index 0000000..23771db --- /dev/null +++ b/app/scripts/ansible/requirements.yml @@ -0,0 +1,8 @@ +--- +collections: + - name: community.docker + version: 3.13.3 + - name: community.general + version: 9.5.5 + - name: community.crypto + version: 2.26.9 diff --git a/app/scripts/ansible/roles/rmon_agent/defaults/main.yml b/app/scripts/ansible/roles/rmon_agent/defaults/main.yml new file mode 100644 index 0000000..c3f2021 --- /dev/null +++ b/app/scripts/ansible/roles/rmon_agent/defaults/main.yml @@ -0,0 +1,20 @@ +--- +agent_container_name: rmon-agent +agent_image: ghcr.io/roxy-wi/rmon/rmon-agent:2.0 +agent_pull: missing +agent_bind_ip: 0.0.0.0 +agent_config_path: /etc/rmon/rmon-agent.cfg +agent_data_path: /var/lib/rmon/rmon-agent +agent_log_path: /var/log/rmon +agent_docker_host: unix:///var/run/docker.sock +agent_lock_path: /run/lock/rmon-agent-deployment +agent_transport: preserve +agent_mtls_enabled: false +agent_tls_ca_file: '' +agent_tls_ca_key_file: '' +agent_tls_cert_file: '' +agent_tls_key_file: '' +agent_tls_generate: true +agent_verify_result_connection: false +agent_tls_directory: /etc/rmon/mtls +agent_certificate_directory: /var/lib/rmon/keys/agent-certificates diff --git a/app/scripts/ansible/roles/rmon_agent/tasks/control.yml b/app/scripts/ansible/roles/rmon_agent/tasks/control.yml new file mode 100644 index 0000000..d7b9d92 --- /dev/null +++ b/app/scripts/ansible/roles/rmon_agent/tasks/control.yml @@ -0,0 +1,51 @@ +--- +- name: Find Docker without installing it for a service action + ansible.builtin.command: + argv: [docker, --version] + register: agent_control_docker + changed_when: false + failed_when: false + +- name: Inspect the container on this SSH host + community.docker.docker_container_info: + docker_host: '{{ agent_docker_host }}' + name: '{{ agent_container_name }}' + register: agent_control_container + when: agent_control_docker.rc == 0 + no_log: true + +- name: Require an agent managed by this installation + ansible.builtin.assert: + that: + - agent_control_container.container.Config.Labels.get('io.rmon.managed') == 'true' + - agent_control_container.container.Config.Labels.get('io.rmon.uuid') == agent_uuid + fail_msg: Refusing to manage a container belonging to another agent. + when: agent_control_container.exists | default(false) + +- name: Manage the container without deleting configuration or data + community.docker.docker_container: + docker_host: '{{ agent_docker_host }}' + name: '{{ agent_container_name }}' + state: "{{ {'start': 'started', 'stop': 'stopped', 'restart': 'started', 'uninstall': 'absent'}[action] }}" + restart: "{{ action == 'restart' }}" + stop_timeout: 30 + keep_volumes: true + when: agent_control_container.exists | default(false) + +- name: Inspect native service availability + ansible.builtin.service_facts: + when: not agent_control_container.exists | default(false) + +- name: Manage the existing native service when no container is installed + ansible.builtin.systemd_service: + name: rmon-agent + state: "{{ {'start': 'started', 'stop': 'stopped', 'restart': 'restarted', 'uninstall': 'stopped'}[action] }}" + enabled: "{{ false if action == 'uninstall' else omit }}" + when: + - not agent_control_container.exists | default(false) + - "'rmon-agent.service' in ansible_facts.services" + +- name: Report a missing agent for start, stop or restart + ansible.builtin.assert: + that: action == 'uninstall' or agent_control_container.exists | default(false) or 'rmon-agent.service' in ansible_facts.services + fail_msg: No agent is installed on this server. diff --git a/app/scripts/ansible/roles/rmon_agent/tasks/deploy.yml b/app/scripts/ansible/roles/rmon_agent/tasks/deploy.yml new file mode 100644 index 0000000..525fb18 --- /dev/null +++ b/app/scripts/ansible/roles/rmon_agent/tasks/deploy.yml @@ -0,0 +1,78 @@ +--- +- name: Read native service state before migration + ansible.builtin.service_facts: + +- name: Record the recovery state + ansible.builtin.set_fact: + agent_previous_name: '{{ agent_container_name }}-previous-{{ lookup("ansible.builtin.password", "/dev/null", length=12, chars=["ascii_lowercase", "digits"]) }}' + agent_native_running: "{{ ansible_facts.services.get('rmon-agent.service', {}).get('state') == 'running' }}" + agent_native_enabled: "{{ ansible_facts.services.get('rmon-agent.service', {}).get('status') == 'enabled' }}" + agent_runtime_started: true + agent_previous_renamed: false + +- name: Stop the native agent before using its existing data + ansible.builtin.systemd_service: + name: rmon-agent + state: stopped + when: agent_native_running | bool + +- name: Stop the previous container and disable its automatic restart + community.docker.docker_container: + docker_host: '{{ agent_docker_host }}' + name: '{{ agent_container_name }}' + state: stopped + stop_timeout: 30 + restart_policy: 'no' + when: agent_previous.exists + +- name: Retain the previous container for recovery + ansible.builtin.command: + argv: [docker, --host, '{{ agent_docker_host }}', rename, '{{ agent_container_name }}', '{{ agent_previous_name }}'] + when: agent_previous.exists + changed_when: true + register: agent_rename_result + +- name: Record the retained container + ansible.builtin.set_fact: + agent_previous_renamed: '{{ agent_previous.exists and agent_rename_result is changed }}' + +- name: Start the agent with its original paths and wait for health + community.docker.docker_container: + docker_host: '{{ agent_docker_host }}' + name: '{{ agent_container_name }}' + image: '{{ agent_image_selected.images[0].Id }}' + pull: never + state: healthy + healthy_wait_timeout: 120 + restart_policy: unless-stopped + stop_timeout: 30 + read_only: true + user: '0:0' + cap_drop: [ALL] + capabilities: [NET_RAW, DAC_OVERRIDE] + security_opts: ['no-new-privileges:true'] + tmpfs: ['/tmp:rw,noexec,nosuid,size=64m'] + etc_hosts: + host.docker.internal: host-gateway + published_ports: ['{{ agent_bind_ip }}:{{ agent_port }}:{{ agent_port }}'] + labels: + io.rmon.managed: 'true' + io.rmon.uuid: '{{ agent_uuid }}' + io.rmon.deployment: '{{ agent_deployment_hash }}' + io.rmon.operation: '{{ agent_previous_name }}' + env: + RMON_AGENT_SCHEDULER_API_ENABLED: '0' + RMON_AGENT_CONFIG_FILE: '{{ agent_config_path }}' + RMON_AGENT_DB_PATH: '{{ agent_data_path }}/check_results.db' + RMON_AGENT_LOG_PATH: '{{ agent_log_path }}/rmon-agent.log' + mounts: >- + {{ agent_read_only_mounts + [ + {'type': 'bind', 'source': agent_data_path, 'target': agent_data_path}, + {'type': 'bind', 'source': agent_log_path, 'target': agent_log_path}] }} + no_log: true + +- name: Disable the native service after a successful migration + ansible.builtin.systemd_service: + name: rmon-agent + enabled: false + when: agent_native_enabled | bool diff --git a/app/scripts/ansible/roles/rmon_agent/tasks/docker.yml b/app/scripts/ansible/roles/rmon_agent/tasks/docker.yml new file mode 100644 index 0000000..eec2288 --- /dev/null +++ b/app/scripts/ansible/roles/rmon_agent/tasks/docker.yml @@ -0,0 +1,53 @@ +--- +- name: Check whether Docker is installed + ansible.builtin.command: + argv: [docker, --version] + register: agent_docker_cli + changed_when: false + failed_when: false + +- name: Check the supported package manager + ansible.builtin.assert: + that: ansible_facts.os_family in ['Debian', 'RedHat'] + fail_msg: Docker installation requires a Debian/Ubuntu or EL host. + +- name: Read installed packages before contacting package mirrors + ansible.builtin.package_facts: + manager: auto + when: ansible_facts.os_family == 'Debian' + +- name: Install Docker and module dependencies on Debian or Ubuntu + vars: + agent_required_packages: "{{ ['python3-requests', 'python3-cryptography', 'openssl'] + (['docker.io'] if agent_docker_cli.rc != 0 else []) }}" + ansible.builtin.apt: + name: '{{ agent_required_packages }}' + state: present + update_cache: true + cache_valid_time: 3600 + when: + - ansible_facts.os_family == 'Debian' + - agent_required_packages | difference(ansible_facts.packages.keys() | list) | length > 0 + +- name: Configure the Docker repository on EL + ansible.builtin.yum_repository: + name: docker-ce-stable + description: Docker CE stable + baseurl: https://download.docker.com/linux/centos/$releasever/$basearch/stable + gpgcheck: true + gpgkey: https://download.docker.com/linux/centos/gpg + enabled: true + when: + - ansible_facts.os_family == 'RedHat' + - agent_docker_cli.rc != 0 + +- name: Install Docker and module dependencies on EL + ansible.builtin.dnf: + name: "{{ ['python3-requests', 'python3-cryptography', 'openssl'] + (['docker-ce', 'docker-ce-cli', 'containerd.io'] if agent_docker_cli.rc != 0 else []) }}" + state: present + when: ansible_facts.os_family == 'RedHat' + +- name: Enable and start Docker + ansible.builtin.systemd_service: + name: docker + state: started + enabled: true diff --git a/app/scripts/ansible/roles/rmon_agent/tasks/install.yml b/app/scripts/ansible/roles/rmon_agent/tasks/install.yml index 3c04799..ceb1853 100644 --- a/app/scripts/ansible/roles/rmon_agent/tasks/install.yml +++ b/app/scripts/ansible/roles/rmon_agent/tasks/install.yml @@ -1,78 +1,290 @@ -- name: Create RMON Agent directory - file: - path: "{{ item }}" - owner: "{{ansible_user}}" - group: "{{ansible_user}}" +--- +- name: Validate deployment settings + ansible.builtin.assert: + that: + - agent_image is match('^[A-Za-z0-9][A-Za-z0-9._:/@-]+$') + - "':' in agent_image.split('/')[-1]" + - not agent_image.endswith(':latest') + - agent_pull in ['missing', 'always', 'never'] + - agent_uuid is match('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') + - agent_port | int > 1024 and agent_port | int < 65536 + - master_port | int > 0 and master_port | int < 65536 + - master_ip is match('^[A-Za-z0-9.,: -]+$') + - agent_transport in ['preserve', 'http', 'https', 'mtls'] + quiet: true + no_log: true + +- name: Install Docker before deploying the agent + ansible.builtin.include_tasks: docker.yml + +- name: Inspect the existing agent container + community.docker.docker_container_info: + docker_host: '{{ agent_docker_host }}' + name: '{{ agent_container_name }}' + register: agent_previous + no_log: true + +- name: Check ownership of the existing container + ansible.builtin.assert: + that: + - not agent_previous.exists or agent_previous.container.Config.Labels.get('io.rmon.managed') == 'true' + - not agent_previous.exists or agent_previous.container.Config.Labels.get('io.rmon.uuid') == agent_uuid + fail_msg: The existing container belongs to another installation. + +- name: Find the requested image + community.docker.docker_image_info: + docker_host: '{{ agent_docker_host }}' + name: '{{ agent_image }}' + register: agent_image_before + +- name: Require a local image when downloads are disabled + ansible.builtin.assert: + that: agent_pull != 'never' or agent_image_before.images | length > 0 + +- name: Download the image before stopping the agent + community.docker.docker_image: + docker_host: '{{ agent_docker_host }}' + name: '{{ agent_image }}' + source: pull + force_source: "{{ agent_pull == 'always' }}" + when: agent_pull == 'always' or (agent_pull == 'missing' and agent_image_before.images | length == 0) + +- name: Inspect the selected image + community.docker.docker_image_info: + docker_host: '{{ agent_docker_host }}' + name: '{{ agent_image }}' + register: agent_image_selected + +- name: Inspect the original configuration + ansible.builtin.stat: + path: '{{ agent_config_path }}' + register: agent_config_stat + +- name: Save the original configuration for recovery + ansible.builtin.slurp: + src: '{{ agent_config_path }}' + register: agent_original_config + when: agent_config_stat.stat.exists + no_log: true + +- name: Create a private controller directory for reading configuration + ansible.builtin.tempfile: state: directory - with_items: - - "/etc/rmon" - - "/var/lib/rmon/rmon-agent/modules/db" - - "/var/lib/rmon/rmon-agent/modules/common" - - "/var/lib/rmon/rmon-agent/modules/checks" - - "/var/log/rmon" - - "/etc/pki/tls/certs" - ignore_errors: yes - -- name: Copy RMON Agent files - ansible.builtin.copy: - src: "/var/www/rmon/app/tools/rmon-agent/{{ item }}" - dest: "/var/lib/rmon/rmon-agent/{{ item }}" - owner: "{{ansible_user}}" - group: "{{ansible_user}}" - mode: '0644' - with_items: - - "rmon_agent.py" - - "modules/common/rmon_agent_common.py" - - "rmon_checks.py" - - "modules/checks/http_check.py" - - "modules/checks/http_sub_checks.py" - - "modules/checks/smtp_check.py" - - "modules/checks/rabbitmq_check.py" - - "modules/checks/tcp_check.py" - - "modules/checks/dns_check.py" - - "modules/checks/ping_check.py" - - "wsgi.py" - - "requirements.txt" - - "modules/db/check_result.py" - - "modules/db/db_models.py" - -- name: Copy RMON Agent service file - template: - src: "{{ item.file }}" - dest: "{{ item.path }}" - mode: 0644 - with_items: - - {file: "rmon-agent.service.j2", path: "/etc/systemd/system/rmon-agent.service"} - - {file: "logrotate.j2", path: "/etc/logrotate.d/rmon-agent"} - -- name: Install requirements - ansible.builtin.include_tasks: "{{ role_path }}/tasks/install-{{ ansible_facts['os_family'] | lower }}.yml" - -- name: Copy RMON Agent config file - template: - src: rmon-agent.cfg.j2 - dest: /etc/rmon/rmon-agent.cfg - mode: 0644 - -- name: Try to upgrade pycurl - pip: - name: pycurl - executable: /usr/local/bin/pip3 - state: latest - extra_args: --upgrade --ignore-installed - ignore_errors: yes - -- name: Try to copy ca cert - ansible.builtin.copy: - src: /etc/ssl/certs/ca-certificates.crt - dest: /etc/pki/tls/certs/ca-bundle.crt - remote_src: yes - ignore_errors: yes - -- name: Start and enable RMON Agent - ansible.builtin.systemd: - name: rmon-agent - enabled: true - masked: no - daemon_reload: true - state: restarted + prefix: rmon-agent-config- + delegate_to: localhost + become: false + register: agent_config_temporary + +- name: Configure and deploy the agent + block: + - name: Initialize operation state + ansible.builtin.set_fact: + agent_existing_options: {} + agent_certificate_paths: [] + agent_runtime_started: false + agent_tls_copies: {results: []} + + - name: Read the original settings without changing their format + ansible.builtin.copy: + content: "{{ agent_original_config.content | default('') | b64decode }}" + dest: '{{ agent_config_temporary.path }}/original.cfg' + mode: '0600' + delegate_to: localhost + become: false + no_log: true + + - name: Make absent sections readable in the temporary copy only + ansible.builtin.lineinfile: + path: '{{ agent_config_temporary.path }}/original.cfg' + regexp: '^\s*\[{{ item }}\]\s*(?:[#;].*)?$' + line: '[{{ item }}]' + loop: [agent, master, control] + delegate_to: localhost + become: false + no_log: true + + - name: Preserve existing identity and connection settings + ansible.builtin.set_fact: + agent_existing_options: >- + {{ agent_existing_options | combine({item.section + '.' + item.option: + lookup('ansible.builtin.ini', item.option, section=item.section, + file=agent_config_temporary.path + '/original.cfg', default='')}) }} + loop: + - {section: agent, option: uuid} + - {section: master, option: ca_file} + - {section: master, option: client_cert} + - {section: master, option: client_key} + - {section: control, option: url} + no_log: true + + - name: Refuse to replace another agent's configuration + ansible.builtin.assert: + that: agent_existing_options['agent.uuid'] in ['', agent_uuid] + fail_msg: The existing configuration belongs to another agent. + + - name: Keep the standard configuration, data and log directories + ansible.builtin.file: + path: '{{ item }}' + state: directory + loop: + - '{{ agent_config_path | dirname }}' + - '{{ agent_data_path }}' + - '{{ agent_log_path }}' + + - name: Update only the settings managed by RMON + community.general.ini_file: + path: '{{ agent_config_path }}' + section: '{{ item.section }}' + option: '{{ item.option }}' + value: '{{ item.value }}' + mode: "{{ agent_config_stat.stat.mode | default('0600') }}" + loop: + - {section: agent, option: uuid, value: '{{ agent_uuid }}'} + - {section: agent, option: agent_port, value: '{{ agent_port }}'} + - {section: master, option: host, value: '{{ master_ip }}'} + - {section: master, option: port, value: '{{ master_port }}'} + no_log: true + + - name: Set the assignment address when it was not configured + community.general.ini_file: + path: '{{ agent_config_path }}' + section: control + option: url + value: '{{ agent_control_url }}' + mode: "{{ agent_config_stat.stat.mode | default('0600') }}" + when: + - agent_control_url | default('') | length > 0 + - agent_existing_options['control.url'] | length == 0 + no_log: true + + - name: Configure the requested transport and certificates + ansible.builtin.include_tasks: tls.yml + when: agent_transport != 'preserve' or agent_mtls_enabled | bool + + - name: Read the final configuration for certificate mounts + ansible.builtin.fetch: + src: '{{ agent_config_path }}' + dest: '{{ agent_config_temporary.path }}/final.cfg' + flat: true + no_log: true + + - name: Make optional sections readable in the final temporary copy + ansible.builtin.lineinfile: + path: '{{ agent_config_temporary.path }}/final.cfg' + regexp: '^\s*\[{{ item }}\]\s*(?:[#;].*)?$' + line: '[{{ item }}]' + loop: [master, control] + delegate_to: localhost + become: false + no_log: true + + - name: Collect the certificate paths already used by the agent + ansible.builtin.set_fact: + agent_certificate_paths: >- + {{ (agent_certificate_paths + [lookup('ansible.builtin.ini', item.option, + section=item.section, file=agent_config_temporary.path + '/final.cfg', default='')]) + | reject('equalto', '') | unique | list }} + loop: + - {section: master, option: ca_file} + - {section: master, option: client_cert} + - {section: master, option: client_key} + - {section: control, option: ca_file} + - {section: control, option: client_cert} + - {section: control, option: client_key} + no_log: true + + - name: Validate certificate paths + ansible.builtin.assert: + that: + - item.startswith('/') + - item != agent_config_path + - "item is not search('[,\\r\\n]')" + loop: '{{ agent_certificate_paths }}' + + - name: Check certificates and record their content hashes + ansible.builtin.stat: + path: '{{ item }}' + checksum_algorithm: sha256 + loop: '{{ agent_certificate_paths + [agent_config_path] }}' + register: agent_mounted_files + no_log: true + + - name: Require existing regular files for all read-only mounts + ansible.builtin.assert: + that: item.stat.isreg | default(false) + fail_msg: A referenced agent configuration or certificate file is missing. + loop: '{{ agent_mounted_files.results }}' + no_log: true + + - name: Describe this deployment without moving agent data + ansible.builtin.set_fact: + agent_deployment_hash: >- + {{ {'image': agent_image_selected.images[0].Id, 'port': agent_port, 'bind': agent_bind_ip, + 'config': agent_config_path, 'data': agent_data_path, 'logs': agent_log_path, + 'files': agent_mounted_files.results | map(attribute='stat.checksum') | list} + | to_json | hash('sha256') }} + agent_read_only_mounts: [] + no_log: true + + - name: Mount configuration and certificates at their original paths + ansible.builtin.set_fact: + agent_read_only_mounts: "{{ agent_read_only_mounts + [{'type': 'bind', 'source': item, 'target': item, 'read_only': true}] }}" + loop: '{{ agent_certificate_paths + [agent_config_path] }}' + + - name: Apply the deployment with recovery on failure + ansible.builtin.include_tasks: deploy.yml + when: >- + not agent_previous.exists or + agent_previous.container.Config.Labels.get('io.rmon.deployment') != agent_deployment_hash or + agent_previous.container.State.get('Health', {}).get('Status') != 'healthy' + rescue: + - name: Restore certificate files changed by this operation + ansible.builtin.copy: + src: '{{ item.backup_file }}' + dest: '{{ item.item.target }}' + remote_src: true + mode: preserve + loop: '{{ agent_tls_copies.results | default([]) }}' + when: item.backup_file is defined + no_log: true + + - name: Remove only newly copied credentials after failure + ansible.builtin.file: + path: '{{ item.item.target }}' + state: absent + loop: '{{ agent_tls_copies.results | default([]) }}' + when: + - item is changed + - item.backup_file is not defined + no_log: true + + - name: Restore the original configuration after a failed deployment + ansible.builtin.copy: + content: '{{ agent_original_config.content | b64decode }}' + dest: '{{ agent_config_path }}' + mode: '{{ agent_config_stat.stat.mode }}' + owner: '{{ agent_config_stat.stat.uid }}' + group: '{{ agent_config_stat.stat.gid }}' + when: agent_config_stat.stat.exists + no_log: true + + - name: Remove only the new configuration when initialization failed + ansible.builtin.file: + path: '{{ agent_config_path }}' + state: absent + when: not agent_config_stat.stat.exists + + - name: Restore the previous runtime + ansible.builtin.include_tasks: recover.yml + when: agent_runtime_started | default(false) + + - name: Report the failed agent operation + ansible.builtin.fail: + msg: Agent deployment failed. The previous configuration and runtime were restored; inspect the failed Ansible task. + always: + - name: Remove the temporary controller copy of the configuration + ansible.builtin.file: + path: '{{ agent_config_temporary.path }}' + state: absent + delegate_to: localhost + become: false diff --git a/app/scripts/ansible/roles/rmon_agent/tasks/issue_certificate.yml b/app/scripts/ansible/roles/rmon_agent/tasks/issue_certificate.yml new file mode 100644 index 0000000..853eb37 --- /dev/null +++ b/app/scripts/ansible/roles/rmon_agent/tasks/issue_certificate.yml @@ -0,0 +1,54 @@ +--- +- name: Require an authorized signing CA for certificate generation + ansible.builtin.assert: + that: + - agent_tls_generate | bool + - agent_tls_ca_file | length > 0 + - agent_tls_ca_key_file | length > 0 + fail_msg: Supply existing client credentials or enable generation with the trusted CA certificate and signing key on the controller. + +- name: Issue a client certificate without sending the CA key to the agent + delegate_to: localhost + become: false + block: + - name: Create the private certificate directory + ansible.builtin.file: + path: '{{ agent_certificate_directory }}/{{ agent_uuid }}' + state: directory + mode: '0700' + + - name: Generate the agent private key when needed + community.crypto.openssl_privatekey: + path: '{{ agent_certificate_directory }}/{{ agent_uuid }}/agent.key' + type: ECC + curve: secp256r1 + mode: '0600' + no_log: true + + - name: Create a client authentication certificate request + community.crypto.openssl_csr_pipe: + privatekey_path: '{{ agent_certificate_directory }}/{{ agent_uuid }}/agent.key' + common_name: 'rmon-agent-{{ agent_uuid }}' + use_common_name_for_san: false + key_usage: [digitalSignature] + extended_key_usage: [clientAuth] + basic_constraints: ['CA:FALSE'] + basic_constraints_critical: true + register: agent_client_csr + no_log: true + + - name: Sign the agent certificate with the supplied CA + community.crypto.x509_certificate: + path: '{{ agent_certificate_directory }}/{{ agent_uuid }}/agent.crt' + csr_content: '{{ agent_client_csr.csr }}' + provider: ownca + ownca_path: '{{ agent_tls_ca_file }}' + ownca_privatekey_path: '{{ agent_tls_ca_key_file }}' + ownca_not_after: +365d + mode: '0644' + no_log: true + + - name: Select the generated credentials for copying + ansible.builtin.set_fact: + agent_client_cert_source: '{{ agent_certificate_directory }}/{{ agent_uuid }}/agent.crt' + agent_client_key_source: '{{ agent_certificate_directory }}/{{ agent_uuid }}/agent.key' diff --git a/app/scripts/ansible/roles/rmon_agent/tasks/main.yml b/app/scripts/ansible/roles/rmon_agent/tasks/main.yml index d36cae2..512002e 100644 --- a/app/scripts/ansible/roles/rmon_agent/tasks/main.yml +++ b/app/scripts/ansible/roles/rmon_agent/tasks/main.yml @@ -1,2 +1,22 @@ --- -- include_tasks: "{{ action }}.yml" +- name: Validate the requested agent operation + ansible.builtin.assert: + that: + - action in ['install', 'uninstall', 'start', 'stop', 'restart'] + - agent_container_name is match('^[a-zA-Z0-9][a-zA-Z0-9_.-]+$') + +- name: Create the deployment lock + ansible.builtin.command: + argv: [mkdir, '--', '{{ agent_lock_path }}'] + changed_when: true + +- name: Manage the agent on its SSH host + block: + - name: Run the requested operation + ansible.builtin.include_tasks: + file: "{{ 'install.yml' if action == 'install' else 'control.yml' }}" + always: + - name: Release the deployment lock + ansible.builtin.file: + path: '{{ agent_lock_path }}' + state: absent diff --git a/app/scripts/ansible/roles/rmon_agent/tasks/recover.yml b/app/scripts/ansible/roles/rmon_agent/tasks/recover.yml new file mode 100644 index 0000000..b285815 --- /dev/null +++ b/app/scripts/ansible/roles/rmon_agent/tasks/recover.yml @@ -0,0 +1,39 @@ +--- +- name: Inspect the candidate container after failure + community.docker.docker_container_info: + docker_host: '{{ agent_docker_host }}' + name: '{{ agent_container_name }}' + register: agent_failed_candidate + no_log: true + +- name: Remove only the candidate created by this operation + community.docker.docker_container: + docker_host: '{{ agent_docker_host }}' + name: '{{ agent_container_name }}' + state: absent + force_kill: true + keep_volumes: true + when: + - agent_failed_candidate.exists + - agent_failed_candidate.container.Config.Labels.get('io.rmon.operation') == agent_previous_name + +- name: Restore the previous container name + ansible.builtin.command: + argv: [docker, --host, '{{ agent_docker_host }}', rename, '{{ agent_previous_name }}', '{{ agent_container_name }}'] + when: agent_previous_renamed | bool + changed_when: true + +- name: Restore the previous container state and restart policy + community.docker.docker_container: + docker_host: '{{ agent_docker_host }}' + name: '{{ agent_container_name }}' + state: "{{ 'started' if agent_previous.container.State.Running else 'stopped' }}" + restart_policy: '{{ agent_previous.container.HostConfig.RestartPolicy.Name }}' + when: agent_previous.exists + +- name: Restore the native service state + ansible.builtin.systemd_service: + name: rmon-agent + state: "{{ 'started' if agent_native_running else 'stopped' }}" + enabled: '{{ agent_native_enabled }}' + when: agent_native_running | bool or agent_native_enabled | bool diff --git a/app/scripts/ansible/roles/rmon_agent/tasks/tls.yml b/app/scripts/ansible/roles/rmon_agent/tasks/tls.yml new file mode 100644 index 0000000..85524ec --- /dev/null +++ b/app/scripts/ansible/roles/rmon_agent/tasks/tls.yml @@ -0,0 +1,204 @@ +--- +- name: Select the requested transport without changing existing certificate paths + ansible.builtin.set_fact: + agent_selected_transport: "{{ 'mtls' if agent_mtls_enabled | bool else agent_transport }}" + agent_ca_target: "{{ agent_existing_options['master.ca_file'] or agent_tls_directory + '/ca.crt' }}" + agent_cert_target: "{{ agent_existing_options['master.client_cert'] or agent_tls_directory + '/agent.crt' }}" + agent_key_target: "{{ agent_existing_options['master.client_key'] or agent_tls_directory + '/agent.key' }}" + agent_client_cert_source: '{{ agent_tls_cert_file }}' + agent_client_key_source: '{{ agent_tls_key_file }}' + +- name: Validate certificate destinations + ansible.builtin.assert: + that: + - item.startswith('/') + - item != agent_config_path + - "item is not search('[,\\r\\n]')" + loop: ['{{ agent_ca_target }}', '{{ agent_cert_target }}', '{{ agent_key_target }}'] + +- name: Require separate paths for trust, certificate and private key + ansible.builtin.assert: + that: ([agent_ca_target, agent_cert_target, agent_key_target] | unique | length) == 3 + +- name: Inspect the existing client certificate and key + ansible.builtin.stat: + path: '{{ item }}' + loop: ['{{ agent_ca_target }}', '{{ agent_cert_target }}', '{{ agent_key_target }}'] + register: agent_tls_existing_files + when: agent_selected_transport in ['https', 'mtls'] + +- name: Check the supplied certificate pair + ansible.builtin.assert: + that: (agent_tls_cert_file | length > 0) == (agent_tls_key_file | length > 0) + fail_msg: Supply both the agent client certificate and its private key. + when: agent_selected_transport == 'mtls' + +- name: Preserve an existing incomplete key pair for explicit recovery + ansible.builtin.assert: + that: agent_tls_existing_files.results[1].stat.exists == agent_tls_existing_files.results[2].stat.exists + fail_msg: The existing client certificate pair is incomplete. Restore the missing file or supply both replacement files. + when: + - agent_selected_transport == 'mtls' + - agent_tls_cert_file | length == 0 + +- name: Issue missing client credentials on the controller + ansible.builtin.include_tasks: issue_certificate.yml + when: + - agent_selected_transport == 'mtls' + - agent_tls_cert_file | length == 0 + - not (agent_tls_existing_files.results[1].stat.exists and agent_tls_existing_files.results[2].stat.exists) + +- name: Prepare certificate destination directories + ansible.builtin.file: + path: '{{ item | dirname }}' + state: directory + loop: ['{{ agent_ca_target }}', '{{ agent_cert_target }}', '{{ agent_key_target }}'] + when: agent_selected_transport in ['https', 'mtls'] + +- name: Copy only the trust certificate and client credentials to the agent + ansible.builtin.copy: + src: '{{ item.source }}' + dest: '{{ item.target }}' + mode: '{{ item.mode }}' + backup: true + loop: + - {source: '{{ agent_tls_ca_file }}', target: '{{ agent_ca_target }}', mode: '0644'} + - {source: '{{ agent_client_cert_source }}', target: '{{ agent_cert_target }}', mode: '0644'} + - {source: '{{ agent_client_key_source }}', target: '{{ agent_key_target }}', mode: '0600'} + when: + - agent_selected_transport in ['https', 'mtls'] + - item.source | length > 0 + - item.target == agent_ca_target or agent_selected_transport == 'mtls' + register: agent_tls_copies + no_log: true + +- name: Require all mTLS credentials before changing transport + ansible.builtin.stat: + path: '{{ item }}' + loop: ['{{ agent_ca_target }}', '{{ agent_cert_target }}', '{{ agent_key_target }}'] + register: agent_tls_ready + when: agent_selected_transport == 'mtls' + +- name: Validate the installed mTLS files + ansible.builtin.assert: + that: item.stat.isreg | default(false) + fail_msg: mTLS requires a trusted CA, a client certificate and a private key. + loop: '{{ agent_tls_ready.results | default([]) }}' + when: agent_selected_transport == 'mtls' + no_log: true + +- name: Verify that the client certificate is trusted for client authentication + ansible.builtin.command: + argv: [openssl, verify, -purpose, sslclient, -CAfile, '{{ agent_ca_target }}', '{{ agent_cert_target }}'] + changed_when: false + when: agent_selected_transport == 'mtls' + +- name: Read the client certificate public key + community.crypto.x509_certificate_info: + path: '{{ agent_cert_target }}' + register: agent_client_certificate_info + when: agent_selected_transport == 'mtls' + no_log: true + +- name: Reject expired client certificates + ansible.builtin.command: + argv: [openssl, x509, -checkend, '0', -noout, -in, '{{ agent_cert_target }}'] + changed_when: false + when: agent_selected_transport == 'mtls' + no_log: true + +- name: Read the public part of the agent private key + community.crypto.openssl_privatekey_info: + path: '{{ agent_key_target }}' + register: agent_client_privatekey_info + when: agent_selected_transport == 'mtls' + no_log: true + +- name: Require matching client credentials + ansible.builtin.assert: + that: agent_client_certificate_info.public_key == agent_client_privatekey_info.public_key + fail_msg: The client certificate does not match the agent private key. + when: agent_selected_transport == 'mtls' + +- name: Configure verified HTTPS or explicit HTTP + community.general.ini_file: + path: '{{ agent_config_path }}' + section: master + option: '{{ item.option }}' + value: '{{ item.value }}' + loop: + - {option: scheme, value: "{{ 'http' if agent_selected_transport == 'http' else 'https' }}"} + - {option: tls_verify, value: "{{ 'false' if agent_selected_transport == 'http' else 'true' }}"} + +- name: Use the installed trust certificate + community.general.ini_file: + path: '{{ agent_config_path }}' + section: master + option: ca_file + value: '{{ agent_ca_target }}' + when: agent_selected_transport == 'mtls' or (agent_selected_transport == 'https' and agent_tls_ca_file | length > 0) + +- name: Enable the mTLS client certificate + community.general.ini_file: + path: '{{ agent_config_path }}' + section: master + option: '{{ item.option }}' + value: '{{ item.value }}' + loop: + - {option: client_cert, value: '{{ agent_cert_target }}'} + - {option: client_key, value: '{{ agent_key_target }}'} + when: agent_selected_transport == 'mtls' + +- name: Remove client authentication only when explicitly selecting HTTP or HTTPS + community.general.ini_file: + path: '{{ agent_config_path }}' + section: master + option: '{{ item }}' + state: absent + loop: "{{ ['client_cert', 'client_key'] + (['ca_file'] if agent_selected_transport == 'http' else []) }}" + when: agent_selected_transport in ['http', 'https'] + +- name: Use system trust when HTTPS has no custom CA + community.general.ini_file: + path: '{{ agent_config_path }}' + section: master + option: ca_file + state: absent + when: agent_selected_transport == 'https' and agent_tls_ca_file | length == 0 + +- name: Verify result server connections before replacing the agent + ansible.builtin.uri: + url: "{{ 'http' if agent_selected_transport == 'http' else 'https' }}://{{ item | trim }}:{{ master_port }}/" + method: GET + validate_certs: true + ca_path: "{{ agent_ca_target if agent_tls_ca_file | length > 0 else omit }}" + client_cert: "{{ agent_cert_target if agent_selected_transport == 'mtls' else omit }}" + client_key: "{{ agent_key_target if agent_selected_transport == 'mtls' else omit }}" + status_code: [200, 401, 403, 404, 405] + follow_redirects: none + use_proxy: false + timeout: 10 + loop: "{{ master_ip.split(',') }}" + when: agent_verify_result_connection | bool + changed_when: false + +- name: Check that mTLS rejects connections without a client certificate + ansible.builtin.uri: + url: 'https://{{ item | trim }}:{{ master_port }}/' + validate_certs: true + ca_path: '{{ agent_ca_target }}' + follow_redirects: none + use_proxy: false + timeout: 10 + loop: "{{ master_ip.split(',') }}" + register: agent_unauthenticated_result + failed_when: false + changed_when: false + when: agent_verify_result_connection | bool and agent_selected_transport == 'mtls' + +- name: Require client authentication on every result server + ansible.builtin.assert: + that: item.status == -1 + fail_msg: The result server must require a trusted client certificate for mTLS. + loop: '{{ agent_unauthenticated_result.results | default([]) }}' + when: agent_verify_result_connection | bool and agent_selected_transport == 'mtls' diff --git a/app/scripts/ansible/roles/rmon_agent/tasks/uninstall.yml b/app/scripts/ansible/roles/rmon_agent/tasks/uninstall.yml deleted file mode 100644 index a132f82..0000000 --- a/app/scripts/ansible/roles/rmon_agent/tasks/uninstall.yml +++ /dev/null @@ -1,22 +0,0 @@ -- name: Stop and disable RMON Agent - ansible.builtin.systemd: - name: rmon-agent - enabled: false - state: stopped - -- name: Delete RMON Agent directory - file: - path: "{{ item }}" - state: absent - with_items: ["/var/lib/rmon/rmon-agent"] - ignore_errors: yes - -- name: Remove files - ansible.builtin.file: - path: "{{ item }}" - state: absent - with_items: - - "/etc/rmon/rmon-agent.cfg" - - "/etc/systemd/system/rmon-rmon-agent.service" - - "/etc/logrotate.d/rmon-agent" - - "/etc/rsyslog.d/rmon-agent.conf" diff --git a/app/static/css/check_editor.css b/app/static/css/check_editor.css new file mode 100644 index 0000000..0f07ab8 --- /dev/null +++ b/app/static/css/check_editor.css @@ -0,0 +1,52 @@ +/* Scoped to the check editor; dashboard tables keep their own layout. */ +body .check-editor { color: var(--color-black, #172b3b); padding: 0 22px 20px !important; } +.check-editor-intro { margin: 16px 0; line-height: 1.6; color: var(--color-grey-dark, #526575); } +.check-editor-tabs { display: flex; gap: 8px; margin: 0 0 20px; padding: 4px 0 10px; border-bottom: 1px solid #aebfcb; } +body .check-editor-tabs button { flex: 1; min-width: 0; display: flex; align-items: center; justify-content: center; gap: 8px; white-space: normal; padding: 10px; border: 1px solid #aebfcb; border-radius: 7px; background: transparent; color: inherit; } +body .check-editor-tabs [aria-selected=true] { background: #087fab; border-color: #087fab; color: white; } +.check-editor-tabs button span { display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; border: 1px solid currentColor; flex: 0 0 auto; } +.check-editor h3 { margin: 0 0 8px; padding: 0; background: none; border: 0; height: auto; font-size: 18px; } +.check-editor .field-help { margin: 0 0 18px; } +.check-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 24px; } +.check-field { min-width: 0; } +.check-field-wide { grid-column: 1 / -1; } +.check-field-label { display: block; font-weight: 600; margin-bottom: 7px; line-height: 1.5; } +.check-field-control { min-width: 0; } +body .check-editor .form-control, body .check-editor .ui-selectmenu-button, body .check-editor .tagify { box-sizing: border-box; width: 100% !important; max-width: 100%; margin: 0; } +body .check-editor .form-control { min-height: 40px; padding: 9px 10px; border: 1px solid #92a6b5 !important; border-radius: 5px; background: var(--color-white); color: inherit; } +body .check-editor textarea.form-control { font-family: monospace; min-height: 110px; } +body .check-editor .ui-selectmenu-button { padding: 10px !important; min-height: 40px; } +body .check-editor .tagify { min-height: 40px; border-radius: 5px; } +.check-editor .tagify__input { min-width: 60px; } +.check-editor .tagify + #new-smon-status-code { display: none !important; } +.check-editor .tooltip { display: block; width: auto; max-width: none; margin-top: 6px; line-height: 1.5; font-size: 12px; } +.check-advanced { margin-top: 18px; border: 1px solid #aebfcb; border-radius: 7px; } +.check-advanced > summary { padding: 14px; cursor: pointer; font-weight: 600; } +.check-advanced[open] > summary { border-bottom: 1px solid #aebfcb; } +.check-advanced > .check-fields { padding: 18px; } +.check-field-control > input + input, .check-field-control textarea + textarea { margin-top: 8px !important; } +.check-editor-summary { display: grid; gap: 6px; border-left: 4px solid #087fab; background: #087fab10; padding: 14px; margin-bottom: 20px; overflow-wrap: anywhere; } +.check-editor-error, .check-field-error { color: #a82e27; background: #ffeded; border-radius: 4px; padding: 9px; line-height: 1.5; } +.check-editor-error { margin-bottom: 16px; } +.check-field-error { margin: 6px 0 0; } +body .check-editor [aria-invalid=true] { border-color: #b72c24 !important; } +.check-editor .country, .check-entity-headings { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; width: 100%; height: auto; } +.check-entity-headings { font-weight: 600; margin-bottom: 8px; } +.check-editor #all-entities, .check-editor #checked-entities { width: auto; height: auto; min-height: 52px; max-height: 220px; overflow: auto; float: none; margin: 0; border: 1px solid #aebfcb; border-radius: 5px; } +.check-editor #all-entities { grid-column: 1; grid-row: 1; } +.check-editor #checked-entities { grid-column: 2; grid-row: 1; } +.check-editor .country .all-checks, .check-editor #checked-entities > div { display: flex; width: auto; height: auto; min-height: 42px; align-items: center; padding: 4px; gap: 4px; } +.check-editor .country .check-name { flex: 1; min-width: 0; width: auto; float: none; overflow-wrap: anywhere; white-space: normal; margin: 0; } +.check-editor .country .check-button { margin: 0; float: none; flex: 0 0 auto; } +body #check-editor-submit { background: #087fab; color: white; border-color: #087fab; font-weight: 600; } +body #check-editor-submit:disabled { opacity: .6; } +@media (max-width: 640px) { + body .check-editor { padding: 0 12px 12px !important; } + .check-fields { grid-template-columns: minmax(0, 1fr); gap: 16px; } + .check-editor-tabs { gap: 4px; } + body .check-editor-tabs button { flex-direction: column; font-size: 12px; padding: 8px 4px; } + .check-editor-intro { font-size: 13px; } + .check-advanced > .check-fields { padding: 12px; } + body .ui-dialog:has(.check-editor) .ui-dialog-buttonset { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; float: none; } + body .ui-dialog:has(.check-editor) .ui-dialog-buttonset button { margin: 0; padding: 8px; font-size: 13px; } +} diff --git a/app/static/css/ux.css b/app/static/css/ux.css new file mode 100644 index 0000000..3610150 --- /dev/null +++ b/app/static/css/ux.css @@ -0,0 +1,140 @@ +button, input, select, textarea { font: inherit; } +[hidden] { display: none !important; } +:focus-visible { outline: 3px solid #147eb3 !important; outline-offset: 3px; } +.skip-link { position: fixed; top: -100px; left: 16px; z-index: 2000; padding: 12px; background: white; color: #00243e; } +.skip-link:focus { top: 8px; } +.app-shell { --sidebar-width: 207px; min-height: 100vh; } +.app-shell .top-menu { position: fixed; width: var(--sidebar-width); height: 100dvh; min-height: 0; margin: 0; overflow-y: auto; overflow-x: hidden; } +.app-shell .top-menu .v_menu { display: none; position: static; width: auto; opacity: 1; border: 0; transition: none; } +.app-shell .top-menu .p_menu:hover .v_menu, .app-shell .top-menu .p_menu:focus-within .v_menu { display: block; } +.app-shell .top-menu .v_menu a { padding-left: 24px; } +.app-shell .container { box-sizing: border-box; min-width: 0; width: auto; max-width: none; margin-left: var(--sidebar-width); min-height: calc(100vh - 90px); } +.page-header { display: flex; align-items: center; gap: 10px; padding: 10px 16px; flex-wrap: wrap; } +.page-header h2 { padding: 0; margin: 0; height: auto; flex: 1; min-width: 0; } +.account-actions { display: flex; gap: 8px; align-items: center; margin-left: auto; } +.page-header .login { float: none; margin: 0; font-size: 14px; } +body .icon-button { display: inline-flex; align-items: center; justify-content: center; min-width: 36px; min-height: 36px; box-sizing: border-box; margin: 0; padding: 6px !important; border: 1px solid transparent; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; text-decoration: none; } +body .icon-button:hover { background: rgba(49, 175, 225, .15); } +#menu-toggle { font-size: 22px; color: var(--color-blue); } +body.nav-collapsed { --sidebar-width: 0px; } +.nav-collapsed .top-menu { display: none; } +.menu-backdrop { position: fixed; inset: 0; z-index: 98; background: #001b30a6; border: 0; } +.app-shell .footer { box-sizing: border-box; display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 12px; margin: 0 16px 0 calc(var(--sidebar-width) + 16px); padding: 12px 0; width: auto; min-width: 0; max-width: none; } +.footer #logo_footer, .footer .footer-div, .footer #version { float: none; margin: 0; padding: 0; width: auto; } +.footer .footer-div { min-width: 0; } +.footer #useful-links { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px 14px; } +.footer .footer-link { padding: 4px 0; font-size: 13px; } +.footer #logo_footer_img { margin: 0; } +.footer #hide_menu, .footer .show_menu { display: none !important; } +.table-scroll { clear: both; width: auto; max-width: 100%; min-width: 0; overflow-x: auto; overscroll-behavior-x: contain; } +.table-scroll > table { float: none !important; margin: 0 !important; width: 100% !important; max-width: none !important; } +.admin-table-toolbar, .admin-table-bottom { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 0; clear: both; } +.admin-page-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 16px 20px; } +body .admin-table-toolbar .dataTables_length, body .admin-table-toolbar .dataTables_filter, +body .admin-table-bottom .dataTables_info, body .admin-table-bottom .dataTables_paginate { float: none; margin: 0; padding-top: 0; } +body #tabs table.overview { table-layout: auto; display: table; } +body #tabs table.overview th { width: auto !important; } +body #tabs table.dataTable th:first-child { width: 56px !important; } +body #tabs table.dataTable th.checkbox-head { width: 90px !important; } +body #tabs table.overview .cred-field { min-width: 170px; } +body #ajax-users .form-control { width: 100%; } +body #tabs table.overview .form-control { box-sizing: border-box; max-width: 100%; } +body #tabs table.overview th.actions-column, body table.overview th.actions-column, body table.overview td.actions-column { width: 80px !important; min-width: 80px; text-align: center; white-space: nowrap; box-sizing: border-box; } +body table.overview th.actions-column { cursor: default; padding-right: 10px; } +.row-actions { display: inline-block; } +body .row-actions-toggle { font-size: 24px; line-height: 1; border: 0; background: transparent; } +body .row-actions-menu { position: fixed; z-index: 2000; box-sizing: border-box; width: max-content; min-width: 180px; max-width: min(340px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); overflow-y: auto; padding: 6px; border: 1px solid #8295a5; border-radius: 8px; box-shadow: 0 6px 24px #001b3033; background: var(--color-white, white); color: var(--color-black, #171717); text-align: left; white-space: normal; } +body .row-actions-menu > [role=menuitem] { display: block; box-sizing: border-box; width: 100%; min-height: 36px; margin: 0; padding: 8px 12px; border: 0; border-radius: 4px; background: transparent; color: inherit; font: inherit; text-align: left; text-decoration: none; white-space: normal; cursor: pointer; } +body .row-actions-menu > [role=menuitem]:hover, body .row-actions-menu > [role=menuitem]:focus { background: #31afe126; } +body .row-actions-menu > [role=menuitem]:disabled { opacity: .5; cursor: default; } +.overview-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; padding: 16px; } +.overview-grid .overview-wi { width: 100%; min-width: 0; max-width: none; float: none; margin: 0; } +.overview-grid > .table-scroll { border: 1px solid #b8c3cc; border-radius: 8px; align-self: start; } +.overview-grid .overviewHead { height: auto; } +.overview-grid .overview-small-table { height: auto; min-height: 0; } +.overview-grid #cpu_div:has(.widget-state), .overview-grid #ram_div:has(.widget-state) { height: auto !important; min-height: 122px; } +.widget-state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; min-height: 100px; padding: 12px; text-align: center; box-sizing: border-box; } +.retry-button { color: inherit; border: 1px solid #8295a5; padding: 7px 12px; border-radius: 5px; background: transparent; cursor: pointer; } +.empty-state { text-align: center; margin: 20px auto; padding: 24px; max-width: 650px; } +.empty-state h3 { background: transparent; border: 0; padding: 0; } +.empty-state p { font-size: 16px; line-height: 1.6; } +.empty-state img { max-width: min(240px, 70vw); height: auto; } +body button.add-button { background: #087fab; color: #fff; border: 1px solid transparent; font-weight: 600; min-height: 40px; } +body button.add-button:hover { background: #00668f; } +body .ui-dialog { box-sizing: border-box; max-width: calc(100vw - 24px); } +body .ui-dialog .ui-dialog-titlebar { flex: 0 0 auto; } +body .ui-dialog .ui-dialog-title { white-space: normal; } +body .ui-dialog .ui-dialog-buttonpane { flex: 0 0 auto; margin: 0; background: var(--color-white); border-top: 1px solid #b8c3cc; } +body .ui-dialog .ui-dialog-content { min-width: 0; } +body .ui-dialog .overview { max-width: 100%; } +body .ui-dialog .validateTips { width: auto; max-width: 100%; } +body .ui-dialog textarea { box-sizing: border-box; max-width: 100%; } +body .ui-selectmenu-menu .ui-menu { max-width: calc(100vw - 32px); } +body .field-help { font-size: 13px; line-height: 1.5; overflow-wrap: anywhere; margin: 8px 0; } +body #create-status-page-step-1-overview, body #create-status-page-step-2-overview { width: 100%; table-layout: fixed; } +body #create-status-page-step-1 .form-control, body #create-status-page-step-2 .form-control { width: 100%; box-sizing: border-box; } +#status-page-url-preview { color: var(--color-blue); } +#pages .page_div { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +#pages .page_link { flex: 1; min-width: 0; overflow-wrap: anywhere; } +#pages .page_slug { font-size: 13px; } +body #admin-tabs { display: flex; overflow-x: auto; overflow-y: hidden; flex-wrap: nowrap; gap: 4px; } +body #admin-tabs li { flex: 0 0 auto; float: none; } +.ui-tabs-panel { min-width: 0; } +body #toast-container { max-width: calc(100vw - 24px); } + +/* Login keeps its existing form IDs, login flow and SSO buttons. */ +body.login-page { min-height: 100dvh; display: grid; place-items: center; background: #eef4f8; color: #172b3b; padding: 24px; box-sizing: border-box; } +.login-page #main_div { margin: 0; padding: 0; width: 100%; min-width: 0; max-width: 440px; background: white; border-radius: 14px; box-shadow: 0 8px 36px #00243e1a; } +.login-page #login-form { padding: 28px; min-height: 0; } +.login-page #logo_span { float: none; border: 0; margin: -28px -28px 28px; padding: 24px; width: auto; height: auto; background: #00243e; border-radius: 14px 14px 0 0; text-align: center; } +.login-page #logo_img { margin: 0; float: none; width: auto; } +.login-page #logo_span img { width: 170px; margin: 0; padding: 0; } +.login-page #logo_sign { float: none; color: white; margin: 6px 0 0; font-size: 14px; } +.login-page #auth { margin: 0; float: none; position: static; width: 100%; } +.login-page #auth > label { display: block; margin: 12px 0 6px; font-weight: 600; } +.login-page .fontuser, .login-page .fontpassword { position: relative; } +.login-page .fontuser > i, .login-page .fontpassword > i { position: absolute; top: 16px; left: 12px; color: #436078; } +.login-page .form-login { box-sizing: border-box; width: 100%; min-height: 46px; padding: 10px 44px 10px 34px; border: 1px solid #91a4b4 !important; border-radius: 6px; font-size: 16px; color: #172b3b; background: white; } +.login-page .password-toggle { position: absolute; top: 4px; right: 4px; color: #172b3b; } +.login-page #enter { width: 100%; margin: 20px 0 0; min-height: 46px; color: white; background: #007cab; border: 0; font-weight: 600; } +.login-page .wrong-login { box-sizing: border-box; width: 100%; margin: 16px 0 0; text-align: left; } +.login-page .oidc-login-options { width: 100%; margin: 20px 0 0; padding: 0; clear: both; } +.login-page .oidc-login-button { box-sizing: border-box; width: 100%; min-height: 44px; color: #172b3b; white-space: normal; } + +@media (max-width: 1100px) { + .footer .footer-div { grid-column: 1 / -1; } + .footer #useful-links { justify-content: center; } + .overview-grid { grid-template-columns: minmax(0, 1fr); } + body .ui-dialog #smon-add-table > table { width: 100% !important; float: none !important; } +} +@media (max-width: 800px) { + .app-shell { --sidebar-width: 0px; } + .app-shell .top-menu { width: 260px; display: none; top: 62px; height: calc(100dvh - 62px); z-index: 99; } + .app-shell.nav-open .top-menu { display: block; } + .nav-open { overflow: hidden; } + .page-header { min-height: 62px; padding: 8px 12px; box-sizing: border-box; position: relative; z-index: 100; background: var(--color-white); } + .page-header h2 { font-size: 18px; } + .account-actions { gap: 2px; } + .account-actions #show-user-settings-button { max-width: 140px; overflow-wrap: anywhere; } + .app-shell .top-menu .menu a { min-height: 44px; box-sizing: border-box; } + .app-shell .top-menu .v_menu { display: block; position: static; width: auto; } + .app-shell .top-menu .v_menu a { padding-left: 30px; } + .app-shell .container { width: 100%; margin-left: 0; } + .app-shell .footer { margin-left: 12px; margin-right: 12px; } + .footer #useful-links { justify-content: flex-start; } + body .icon-button, body [role=button], body .ui-button { min-height: 44px; min-width: 44px; } + body .table-scroll { margin: 0; } + body .table-scroll table { min-width: 560px; } + .overview-grid { padding: 12px; gap: 12px; } + .overview-grid .table-scroll table { min-width: 0; } + body .ui-dialog table.overview { width: 100% !important; table-layout: fixed; } + body .ui-dialog table.overview td { display: block; box-sizing: border-box; width: 100% !important; padding: 6px 2px !important; } + body .ui-dialog input:not([type=checkbox]):not([type=radio]):not([type=hidden]), body .ui-dialog textarea, + body .ui-dialog .ui-selectmenu-button, body .ui-dialog .tagify { box-sizing: border-box; width: 100% !important; max-width: 100%; font-size: 16px; } + body .ui-dialog .checks { display: flex; gap: 12px; height: auto; max-height: 300px; } + body .ui-dialog .checks > div { width: 50%; min-width: 0; float: none; } + body .ui-dialog .all-checks { width: 100%; height: auto; min-height: 52px; display: flex; } + body .ui-dialog .check-name { width: auto; min-width: 0; overflow-wrap: anywhere; float: none; margin: 4px; flex: 1; } + body .ui-tabs-panel { padding: 10px !important; } + .empty-state { padding: 16px; margin: 12px; } +} diff --git a/app/static/js/admin/cred.js b/app/static/js/admin/cred.js index 1d610eb..7c93894 100644 --- a/app/static/js/admin/cred.js +++ b/app/static/js/admin/cred.js @@ -31,12 +31,12 @@ $( function() { } }] }); - $("#ssh_enable_table input").change(function () { + $("#ssh_enable_table").on('change', 'input', function () { let id = $(this).attr('id').split('-'); updateSSH(id[1]) sshKeyEnableShow(id[1]) }); - $("#ssh_enable_table select").on('selectmenuchange', function () { + $("#ssh_enable_table").on('selectmenuchange', 'select', function () { let id = $(this).attr('id').split('-'); updateSSH(id[1]) sshKeyEnableShow(id[1]) diff --git a/app/static/js/admin/group.js b/app/static/js/admin/group.js index d017ac3..bce73d8 100644 --- a/app/static/js/admin/group.js +++ b/app/static/js/admin/group.js @@ -1,5 +1,5 @@ $( function() { - $( "#ajax-group input" ).change(function() { + $("#ajax-group").on('change', 'input', function() { var id = $(this).attr('id').split('-'); updateGroup(id[1]) }); @@ -65,8 +65,8 @@ function addGroup(dialog_id) { elem("td", null, [ elem("input", {"type":"text","name":"descript-"+id,"value":desc,"id":"descript-"+id,"size":"60","class":"form-control","autocomplete":"off"}) ]), - elem("td", null, [ - elem("a", {"class":"delete","onclick":"confirmDeleteGroup("+id+")","title":"Delete group "+name,"style":"cursor: pointer;"}) + elem("td", {"class": "actions-column"}, [ + elem("button", {"type":"button","onclick":"confirmDeleteGroup("+id+")"}, "Delete group "+name) ]) ]) common_ajax_action_after_success(dialog_id, 'newgroup', 'ajax-group', new_group); @@ -120,12 +120,12 @@ function removeGroup(id) { contentType: "application/json; charset=utf-8", statusCode: { 204: function (xhr) { - $("#group-" + id).remove(); + RmonUI.removeTableRow("#group-" + id); $('select:regex(id, group) option[value=' + id + ']').remove(); $('select:regex(id, group)').selectmenu("refresh"); }, 404: function (xhr) { - $("#group-" + id).remove(); + RmonUI.removeTableRow("#group-" + id); $('select:regex(id, group) option[value=' + id + ']').remove(); $('select:regex(id, group)').selectmenu("refresh"); } diff --git a/app/static/js/admin/oidc.js b/app/static/js/admin/oidc.js index b75e71c..7c90f90 100644 --- a/app/static/js/admin/oidc.js +++ b/app/static/js/admin/oidc.js @@ -139,16 +139,7 @@ } function actionButton(cssClass, title, handler) { - return $('') - .addClass(cssClass) - .attr({title: title, role: 'button', tabindex: 0}) - .on('click', handler) - .on('keydown', function (event) { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - handler(); - } - }); + return $(' {% endif %} @@ -104,16 +104,14 @@ {{lang.words.service|title()}} - - {{lang.words.actions|title()}} - {{lang.words.version|title()}} {{lang.words.desc|title()}} - + Actions +
{{lang.words.read|title()}} {{lang.words.about}} {{lang.words.services}} {{lang.words.here}}
@@ -126,14 +124,16 @@ {{lang.words.service|title()}} {{lang.words.current2|title()}} {{lang.words.version}} {{lang.words.latest|title()}} {{lang.words.version}} - {{lang.words.desc|title()}} - - + Actions +
+ + +
{{lang.words.note|title()}}: {{lang.admin_page.desc.proxy_settings}} @@ -177,20 +177,7 @@ {% endfor %} }); $(document).ready(function() { - $('#ajax-group').on('page.dt') - .DataTable({ - dom: 'Blfrtip', - colReorder: true, - "pageLength": 25, - stateSave: true, - "lengthMenu": [[10, 25, 50, -1], [10, 25, 50, "All"]], - "columnDefs": [ - { - "searchable": false, - "orderable": false - } - ], - }); + RmonUI.initAdminTable('#ajax-group'); }); diff --git a/app/templates/ajax/channels.html b/app/templates/ajax/channels.html index 999dc86..2d899ca 100644 --- a/app/templates/ajax/channels.html +++ b/app/templates/ajax/channels.html @@ -316,15 +316,11 @@

{{lang.words.test2|title()}} {{lang.words.message}}

{{lang.words.email|title()}} - {{lang.words.web_panel|title()}} - - -

diff --git a/app/templates/ajax/load_services.html b/app/templates/ajax/load_services.html index 029469f..7d73c52 100644 --- a/app/templates/ajax/load_services.html +++ b/app/templates/ajax/load_services.html @@ -1,12 +1,12 @@ {% import 'languages/'+lang|default('en')+'.html' as lang %} +{% from 'include/service_version.html' import service_version %} {% set services_name = { 'rmon-server': { 'name': 'RMON Server', 'desc': lang.admin_page.desc.smon_desc }, - 'rmon-socket': { 'name': 'Socket', 'desc': lang.admin_page.desc.socket_desc }, 'fail2ban': { 'name': 'Fail2ban', 'desc': 'Fail2ban scans log files (e.g. /var/log/apache/error_log) and bans IPs that show the malicious signs' }, - 'rabbitmq-server': { 'name': 'RabbitMQ', 'desc': 'RabbitMQ server' }, } %} -{% for s in services %} +{% for s in services if s.0 in services_name %} +{% set installed = s.2.get('installed', s.2['current_version'] not in ('0', '', '* is not installed')) %} {% set is_service_started_class = '' %} {% set is_service_stopped_class = '' %} {% set onclick_start_action = "confirmAjaxServiceAction('start', '"+s.0+"')" %} @@ -15,7 +15,9 @@ {% set is_service_stopped_title = lang.words.stop|title()+" "+lang.words.and+" "+lang.words.disable+" "+services_name[s.0]['name']+" "+lang.words.service %} - {% if s.1 == 'active' or s.1 == 'RUNNING' %} + {% if s.2.get('managed_by') == 'external' %} + + {% elif s.1 == 'active' or s.1 == 'RUNNING' %} {% set is_service_started_class = 'disabled-button' %} {% set onclick_start_action = "" %} @@ -32,32 +34,24 @@ {% endif %} {{services_name[s.0]['name']}} - - {% if 'is not installed' in s.2['current_version'] or not s.2['current_version'] or s.2['current_version'] == '0' %} - {% if s.0 in ('rabbitmq-server', 'fail2ban') %} - {{lang.words.install|title()}} + + {{service_version(s.2, lang)}} + + {{ services_name[s.0]['desc'] | safe }} + + {% if s.2.get('managed_by') == 'external' %} + Managed externally + {% elif installed is sameas false %} + {% if s.0 == 'fail2ban' %} + {{lang.words.install|title()}} {% else %} - {{lang.words.install|title()}} + {% endif %} {% else %} - - - - - - - - - + + + {% endif %} - - {% if 'is not installed' not in s.2['current_version'] %} - {{s.2['current_version']}} - {% endif %} - - - {{ services_name[s.0]['desc'] | safe }} - {% endfor %} diff --git a/app/templates/ajax/load_updateroxywi.html b/app/templates/ajax/load_updateroxywi.html index 6d81d88..60894ae 100644 --- a/app/templates/ajax/load_updateroxywi.html +++ b/app/templates/ajax/load_updateroxywi.html @@ -1,7 +1,7 @@ {% import 'languages/'+lang|default('en')+'.html' as lang %} +{% from 'include/service_version.html' import service_version %} {% set services_name = { 'rmon-server': { 'link': 'rmon-server', 'name': 'RMON Server', 'desc': lang.admin_page.desc.smon_desc }, - 'rmon-socket': { 'link': 'socket', 'name': 'Socket', 'desc': lang.admin_page.desc.socket_desc }, } %} @@ -11,10 +11,10 @@ @@ -24,58 +24,55 @@ {{versions.new_ver}} - - {% if versions.need_update %} - {{lang.words.w_update|title()}} + {{lang.admin_page.desc.main_app}} + + {% if versions.get('managed_by') == 'docker' %} + Update container image + {% elif versions.need_update %} + {% endif %} - - {{lang.admin_page.desc.main_app}} - {% for s in services %} - {% if s.0 in ('rmon-server', 'rmon-socket') %} - {% set is_need_update = 0 %} - {% if s.2['current_version']|float < s.2['new_version']|float %} - {% set is_need_update = 1 %} - {% endif %} + {% if s.0 == 'rmon-server' %} + {% set is_need_update = s.2.get('update_available', false) %} + {% set installed = s.2.get('installed') %} + {% set known_version = s.2.get('version_known', s.2['current_version'] not in ('0', '', '* is not installed')) %} {{services_name[s.0]['name']}} - {% if s.2['current_version'] != '* is not installed' and s.2['current_version'] != '' %} - {{s.2['current_version']}} + {{service_version(s.2, lang)}} - {% endif %} {{s.2['new_version']}} - - {% if 'is not installed' not in s.2['current_version'] and s.2['current_version'] != '' and s.2['current_version'] != '0' %} - {% if is_need_update %} - {{lang.words.w_update|title()}} - {% endif %} + {{services_name[s.0]['desc']|safe}} + + {% if s.2.get('managed_by') == 'external' %} + Managed externally + {% elif s.2.get('managed_by') == 'docker' %} + Docker + {% elif known_version and is_need_update %} + {% endif %} - {% if s.2['current_version'] == '0' %} - {{lang.words.install|title()}} + {% if installed is sameas false and s.2.get('managed_by') not in ('docker', 'external') %} + {% endif %} - - {{services_name[s.0]['desc']|safe}} - {% endif %} {% endfor %} diff --git a/app/templates/ajax/new_ssh.html b/app/templates/ajax/new_ssh.html index 79f64a9..0a6713f 100644 --- a/app/templates/ajax/new_ssh.html +++ b/app/templates/ajax/new_ssh.html @@ -44,11 +44,11 @@ {% endif %}
- + {% if ssh.shared and g.user_params['group_id']|string() != ssh.group_id|string() %} {% else %} - + {% endif %} diff --git a/app/templates/ajax/show_services_ovw.html b/app/templates/ajax/show_services_ovw.html index c53ff01..9632fe8 100644 --- a/app/templates/ajax/show_services_ovw.html +++ b/app/templates/ajax/show_services_ovw.html @@ -23,30 +23,3 @@ - - - {% if roxy_tools_status['rmon-socket'] == 'active' %} - - {% if role <= 1 %} - - Socket service - - {% else %} - Socket service - {% endif %} - {% else %} - {% if roxy_tools_status['rmon-socket'] == 'inactive' or roxy_tools_status['rmon-socket'] == 'failed' %} - - - Socket service - - {% else %} - - - Socket service - - {% endif %} - {% endif %} - - - diff --git a/app/templates/ajax/smon/agent.html b/app/templates/ajax/smon/agent.html index 517a8dd..63b766e 100644 --- a/app/templates/ajax/smon/agent.html +++ b/app/templates/ajax/smon/agent.html @@ -31,6 +31,14 @@ {% endif %}
+
+ {{ lang.agent_transport.label }}: + {{ {'http': 'HTTP', 'https': 'HTTPS', 'mtls': 'HTTPS + mTLS'}.get(agent.applied_result_transport, lang.agent_transport.unknown) }} + {% if agent.transport_pending %}— {{ lang.agent_transport.pending }}{% endif %} + {% if agent.transport_checked_at %} + ({{ agent.transport_checked_at }}) + {% endif %} +
{{ lang.words.server|title() }}: {{ copy_to_clipboard(value=agent.hostname) }} {{ lang.words.server|title() }} IP: {{ copy_to_clipboard(value=agent.ip) }} diff --git a/app/templates/ajax/smon/status_pages.html b/app/templates/ajax/smon/status_pages.html index 82fd71e..37e64b7 100644 --- a/app/templates/ajax/smon/status_pages.html +++ b/app/templates/ajax/smon/status_pages.html @@ -1,6 +1,6 @@ {% for page in pages %} {% endfor %} diff --git a/app/templates/base.html b/app/templates/base.html index 74d45d9..94d8c90 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,3 +1,4 @@ + {% set chosen_lang = lang|default('en') %} {% import 'languages/'+lang|default('en')+'.html' as lang %} @@ -21,17 +22,20 @@ data-unknown="{{ lang.smon_page.desc.UNKNOWN }}" data-warning="{{ lang.smon_page.desc.WARNING }}"/> {% include 'include/main_head.html' %} - + + {% if g.user_params['user'] %} - - - + {% include 'include/main_menu.html' %} -
+
+ +
    {% if g.user_params['role'] %} {% if g.user_params['role'] <= 2 %} @@ -44,7 +48,7 @@

    {% block content %}{% endblock %}
    -

    + {% include 'include/del_confirm.html' %} diff --git a/app/templates/languages/en.html b/app/templates/languages/en.html index 99c13b3..d90aaeb 100644 --- a/app/templates/languages/en.html +++ b/app/templates/languages/en.html @@ -1,4 +1,30 @@ {% set lang_short = 'en' %} +{% set agent_transport = { + "section": "Agent connections", + "label": "Result delivery", + "preserve": "Keep current configuration", + "unknown": "Not yet verified", + "pending": "Changes pending application", + "checked": "Connection verified", + "help": "Uses the agent owner's group settings. Apply connection changes with Reconfigure. Configure the result server for the selected mode. The agent management API remains HTTP.", + "settings_help": "Certificate paths refer to files accessible to RMON inside this group's directory:", + "fields": { + "agent_result_transport": "Default connection", + "agent_tls_ca_file": "Trusted CA", + "agent_tls_generate": "Generate client certificates", + "agent_tls_ca_key_file": "CA signing key", + "agent_tls_cert_file": "Client certificate", + "agent_tls_key_file": "Client private key" + }, + "descriptions": { + "agent_result_transport": "Default for newly installed agents. Existing agents keep their selected mode.", + "agent_tls_ca_file": "Path to the trusted CA bundle. Leave empty for HTTPS with system trust. Required for mTLS.", + "agent_tls_generate": "Issue a separate client certificate for each agent when a pair is missing.", + "agent_tls_ca_key_file": "Path to the CA signing key for certificate generation. The key stays in RMON.", + "agent_tls_cert_file": "Path to an existing client certificate. Use {uuid} for an individual file for each agent.", + "agent_tls_key_file": "Path to the matching client private key. Supports {uuid}." + } +} %} {% set menu_links = dict() %} {% set menu_links = { "overview": { @@ -45,15 +71,6 @@ } %} {% set settings = { - "rabbitmq": { - "rabbitmq_enabled": "Enable alerting in the WEB panel", - "rabbitmq_host": "RabbitMQ-server host", - "rabbitmq_port": "RabbitMQ-server port", - "rabbitmq_vhost": "RabbitMQ-server vhost", - "rabbitmq_queue": "RabbitMQ-server queue", - "rabbitmq_user": "RabbitMQ-server user", - "rabbitmq_password": "RabbitMQ-server password", - }, "monitoring": { "port_scan_interval": "Check interval for Port scanner (in minutes)", "portscanner_keep_history_range": "Retention period for Port scanner history", @@ -166,7 +183,6 @@ "alert_master_backup": "Alert about changing Master/Backup status", "new_version": "There is a new version RMON. Check the", "all_alerts_enabled": "Alerts are enabled", - "disable_alerts": "Disable alerting", "work_with_prev": "Here you can work with previous versions of configs", "roll_back": "Roll back to them, view or delete", "files_been_deleted": "The following files have been deleted", @@ -240,7 +256,6 @@ "auto_start_desc": "The Auto Start service allows to restart the HAProxy, NGINX, Apache and Keepalived services if they are down", "metrics_desc": "Collects number of connections for HAProxy, NGINX, Apache and HAProxy WAF services", "p_s_desc": "Checks the server for open ports and saves history", - "socket_desc": "Socket is a service for sending alerts and notifications", "a_new_version": "There is a newer version", "no_new_version": "There is not a newer version", "main_app": "The main application", @@ -602,7 +617,6 @@ "test2": "test", "disabled": "disabled", "via": "via", - "web_panel": "web panel", "message": "message", "menu": "menu", "language": "language", @@ -773,3 +787,92 @@ "accept": "accept", } %} + +{% set ux = { + "skip_content": "Skip to content", + "toggle_menu": "Toggle navigation", + "close_menu": "Close navigation", + "retry": "Try again", + "loading": "Loading…", + "widget_error": "This information is temporarily unavailable.", + "network_error": "Cannot reach RMON. Check your connection and try again.", + "request_error": "The request could not be completed. Please try again.", + "server_error": "RMON could not complete this request. Try again shortly.", + "validation_error": "Check the required fields and their values, then try again.", + "unauthorized": "Your session has expired. Sign in again to continue.", + "forbidden": "You do not have permission to perform this action.", + "not_found": "This item is no longer available. Refresh the page.", + "conflict": "This item has changed or already exists. Refresh and try again.", + "rate_limit": "Too many requests. Wait a moment and try again.", + "login_failed": "Login or password is incorrect, or this account is disabled.", + "show_password": "Show password", + "hide_password": "Hide password", + "no_checks": "No checks yet", + "no_checks_description": "Add your first check to monitor availability and receive alerts.", + "no_status_pages": "No status pages yet", + "no_status_pages_description": "Create a public page to share the availability of your monitored services.", + "slug_hint": "Use lowercase letters, numbers, underscores or dashes. Consecutive dashes are not allowed.", + "table_label": "Data table. Scroll horizontally to see all columns.", + "name_required": "Enter a name.", + "slug_invalid": "Enter a valid URL slug.", + "edit": "Edit", + "delete": "Delete", + "action": "Action", + "service_not_installed": "Not installed", + "service_version_unknown": "Version unavailable" +} %} + + +{% set check_editor = { + "ssl_policy": "HTTPS policy", + "ssl_policy_default": "Default", + "ssl_policy_https": "Require HTTPS", + "ssl_policy_http": "Require HTTP", + "ssl_policy_hint": "Checks the final URL after allowed redirects. HTTPS to HTTP is always an error, including in Default mode. Ignoring certificate errors does not disable this policy.", + "create": "Create check", + "edit": "Edit check", + "sections": "Check setup", + "target": "Check", + "settings": "Settings", + "notifications": "Notifications", + "intro": "Start with the target. Defaults are ready to use; tune advanced settings only when needed.", + "target_hint": "Choose what to monitor and where to run the check.", + "settings_hint": "Intervals and timeouts are in seconds. Expand the blocks below for advanced settings.", + "notifications_hint": "Choose notification channels or leave them disabled. You can change these settings later.", + "locations": "Run checks from", + "available": "Available", + "selected": "Selected", + "request": "Request and connection", + "response": "Response validation", + "authentication": "Authentication", + "proxy": "Proxy", + "extra": "Additional settings", + "back": "Back", + "next": "Next", + "save": "Save changes", + "saving": "Saving…", + "required": "Fill in this field.", + "invalid_url": "Enter an HTTP or HTTPS URL with a hostname.", + "number": "Enter a number in the allowed range.", + "port": "Enter a port from 2 to 65535.", + "timeout": "The timeout must be shorter than the interval.", + "ping_timeout": "Packet count × timeout must be shorter than the interval.", + "entities": "Select at least one location.", + "status_codes": "Add at least one accepted HTTP status code.", + "json": "Enter a valid JSON object.", + "save_error": "The check was not saved. Your input is still here; review the fields and try again.", + "loading": "Loading check settings…", + "load_error": "Unable to load the check. Close this window and try again.", + "copy": "copy", + "disabled": "Disabled", + "summary": "Review", + "seconds": "s", + "summary_empty": "Choose a target", + "no_channels": "Notifications are disabled", + "json_path": "JSON path", + "json_value": "Expected JSON value", + "proxy_host": "Proxy hostname", + "proxy_port": "Proxy port", + "proxy_username": "Proxy username", + "proxy_password": "Proxy password" +} %} diff --git a/app/templates/languages/fr.html b/app/templates/languages/fr.html index 2e4456b..2814db0 100644 --- a/app/templates/languages/fr.html +++ b/app/templates/languages/fr.html @@ -1,4 +1,30 @@ {% set lang_short = 'fr' %} +{% set agent_transport = { + "section": "Agent connections", + "label": "Result delivery", + "preserve": "Keep current configuration", + "unknown": "Not yet verified", + "pending": "Changes pending application", + "checked": "Connection verified", + "help": "Uses the agent owner's group settings. Apply connection changes with Reconfigure. Configure the result server for the selected mode. The agent management API remains HTTP.", + "settings_help": "Certificate paths refer to files accessible to RMON inside this group's directory:", + "fields": { + "agent_result_transport": "Default connection", + "agent_tls_ca_file": "Trusted CA", + "agent_tls_generate": "Generate client certificates", + "agent_tls_ca_key_file": "CA signing key", + "agent_tls_cert_file": "Client certificate", + "agent_tls_key_file": "Client private key" + }, + "descriptions": { + "agent_result_transport": "Default for newly installed agents. Existing agents keep their selected mode.", + "agent_tls_ca_file": "Path to the trusted CA bundle. Leave empty for HTTPS with system trust. Required for mTLS.", + "agent_tls_generate": "Issue a separate client certificate for each agent when a pair is missing.", + "agent_tls_ca_key_file": "Path to the CA signing key for certificate generation. The key stays in RMON.", + "agent_tls_cert_file": "Path to an existing client certificate. Use {uuid} for an individual file for each agent.", + "agent_tls_key_file": "Path to the matching client private key. Supports {uuid}." + } +} %} {% set menu_links = dict() %} {% set menu_links = { "overview": { @@ -45,15 +71,6 @@ } %} {% set settings = { - "rabbitmq": { - "rabbitmq_enabled": "Activer les alertes dans le panneau WEB", - "rabbitmq_host": "RabbitMQ-server serveur", - "rabbitmq_port": "RabbitMQ-server port", - "rabbitmq_vhost": "RabbitMQ-server vhost", - "rabbitmq_queue": "RabbitMQ-server queue", - "rabbitmq_user": "RabbitMQ-server utilisateur", - "rabbitmq_password": "RabbitMQ-server mot de passe", - }, "monitoring": { "port_scan_interval": "Interval de vérification pour le scanner de Port (en minutes)", "portscanner_keep_history_range": "Période de rétention pour l\'historiques le scanner de Port", @@ -166,7 +183,6 @@ "alert_master_backup": "Alert about changing Master/Backup status", "new_version": "Il y a une nouvelle version RMON. Vérifiez le", "all_alerts_enabled": "Les alertes sont activées", - "disable_alerts": "Désactiver les alertes", "work_with_prev": "Ici, vous pouvez travailler avec les versions précédentes des configurations", "roll_back": "Revenir en arrière, afficher ou supprimer", "files_been_deleted": "Les fichiers suivants ont été supprimés", @@ -240,7 +256,6 @@ "auto_start_desc": "Le service Auto Start permet de redémarrer les services HAProxy, NGINX, Apache et Keepalived s\'ils sont hors service.", "metrics_desc": "Collecte du nombre de connexions pour les services HAProxy, NGINX, Apache et HAProxy WAF", "p_s_desc": "Vérifie les ports ouverts du serveur et enregistre l\'historique.", - "socket_desc": "Socket est un service permettant d\'envoyer des alertes et des notifications.", "a_new_version": "Il existe une version plus récente", "no_new_version": "Il n\'existe pas de version plus récente", "main_app": "Application principale", @@ -602,7 +617,6 @@ "test2": "test", "disabled": "désactivé", "via": "via", - "web_panel": "volet webl", "message": "message", "menu": "menu", "language": "langage", @@ -773,3 +787,92 @@ "accept": "accepter", } %} + +{% set ux = { + "skip_content": "Aller au contenu", + "toggle_menu": "Afficher ou masquer le menu", + "close_menu": "Fermer le menu", + "retry": "Réessayer", + "loading": "Chargement…", + "widget_error": "Ces informations sont temporairement indisponibles.", + "network_error": "Connexion à RMON impossible. Vérifiez votre connexion.", + "request_error": "La demande n’a pas abouti. Veuillez réessayer.", + "server_error": "RMON n’a pas pu traiter la demande. Réessayez plus tard.", + "validation_error": "Vérifiez les champs obligatoires et leurs valeurs.", + "unauthorized": "Votre session a expiré. Connectez-vous à nouveau.", + "forbidden": "Vous n’avez pas l’autorisation d’effectuer cette action.", + "not_found": "Cet élément n’est plus disponible. Actualisez la page.", + "conflict": "Cet élément a changé ou existe déjà. Actualisez la page.", + "rate_limit": "Trop de demandes. Réessayez dans un instant.", + "login_failed": "Identifiant ou mot de passe incorrect, ou compte désactivé.", + "show_password": "Afficher le mot de passe", + "hide_password": "Masquer le mot de passe", + "no_checks": "Aucune vérification pour le moment", + "no_checks_description": "Ajoutez une vérification pour surveiller la disponibilité et recevoir des alertes.", + "no_status_pages": "Aucune page de statut pour le moment", + "no_status_pages_description": "Créez une page publique pour partager la disponibilité de vos services.", + "slug_hint": "Lettres minuscules, chiffres, tirets bas ou tirets. Pas de tirets consécutifs.", + "table_label": "Tableau de données. Faites défiler horizontalement pour voir toutes les colonnes.", + "name_required": "Saisissez un nom.", + "slug_invalid": "Saisissez une adresse de page valide.", + "edit": "Modifier", + "delete": "Supprimer", + "action": "Action", + "service_not_installed": "Non installé", + "service_version_unknown": "Version indisponible" +} %} + + +{% set check_editor = { + "ssl_policy": "Politique HTTPS", + "ssl_policy_default": "Par défaut", + "ssl_policy_https": "Exiger HTTPS", + "ssl_policy_http": "Exiger HTTP", + "ssl_policy_hint": "Vérifie l’adresse finale après les redirections autorisées. Un passage de HTTPS à HTTP est toujours une erreur, même en mode Par défaut. Ignorer les erreurs de certificat ne désactive pas cette politique.", + "create": "Créer une vérification", + "edit": "Modifier la vérification", + "sections": "Configuration", + "target": "Vérification", + "settings": "Paramètres", + "notifications": "Notifications", + "intro": "Commencez par la cible. Les valeurs par défaut sont prêtes ; développez les paramètres avancés si nécessaire.", + "target_hint": "Choisissez la cible et les emplacements d’exécution.", + "settings_hint": "Les intervalles et délais sont en secondes. Développez les blocs pour les paramètres avancés.", + "notifications_hint": "Choisissez les canaux ou laissez-les désactivés. Ces paramètres restent modifiables.", + "locations": "Emplacements", + "available": "Disponibles", + "selected": "Sélectionnés", + "request": "Requête et connexion", + "response": "Validation de la réponse", + "authentication": "Authentification", + "proxy": "Proxy", + "extra": "Paramètres supplémentaires", + "back": "Retour", + "next": "Suivant", + "save": "Enregistrer", + "saving": "Enregistrement…", + "required": "Remplissez ce champ.", + "invalid_url": "Saisissez une URL HTTP ou HTTPS avec un nom d’hôte.", + "number": "Saisissez un nombre dans la plage autorisée.", + "port": "Saisissez un port entre 2 et 65535.", + "timeout": "Le délai doit être inférieur à l’intervalle.", + "ping_timeout": "Nombre de paquets × délai doit être inférieur à l’intervalle.", + "entities": "Sélectionnez au moins un emplacement.", + "status_codes": "Ajoutez au moins un code HTTP accepté.", + "json": "Saisissez un objet JSON valide.", + "save_error": "La vérification n’a pas été enregistrée. Vos données sont conservées ; vérifiez les champs et réessayez.", + "loading": "Chargement des paramètres…", + "load_error": "Impossible de charger la vérification. Fermez cette fenêtre et réessayez.", + "copy": "copie", + "disabled": "Désactivé", + "summary": "Résumé", + "seconds": "s", + "summary_empty": "Choisissez une cible", + "no_channels": "Notifications désactivées", + "json_path": "Chemin JSON", + "json_value": "Valeur JSON attendue", + "proxy_host": "Hôte du proxy", + "proxy_port": "Port du proxy", + "proxy_username": "Utilisateur du proxy", + "proxy_password": "Mot de passe du proxy" +} %} diff --git a/app/templates/languages/pt-br.html b/app/templates/languages/pt-br.html index b3ba445..e38005f 100644 --- a/app/templates/languages/pt-br.html +++ b/app/templates/languages/pt-br.html @@ -1,4 +1,30 @@ {% set lang_short = 'pt-br' %} +{% set agent_transport = { + "section": "Agent connections", + "label": "Result delivery", + "preserve": "Keep current configuration", + "unknown": "Not yet verified", + "pending": "Changes pending application", + "checked": "Connection verified", + "help": "Uses the agent owner's group settings. Apply connection changes with Reconfigure. Configure the result server for the selected mode. The agent management API remains HTTP.", + "settings_help": "Certificate paths refer to files accessible to RMON inside this group's directory:", + "fields": { + "agent_result_transport": "Default connection", + "agent_tls_ca_file": "Trusted CA", + "agent_tls_generate": "Generate client certificates", + "agent_tls_ca_key_file": "CA signing key", + "agent_tls_cert_file": "Client certificate", + "agent_tls_key_file": "Client private key" + }, + "descriptions": { + "agent_result_transport": "Default for newly installed agents. Existing agents keep their selected mode.", + "agent_tls_ca_file": "Path to the trusted CA bundle. Leave empty for HTTPS with system trust. Required for mTLS.", + "agent_tls_generate": "Issue a separate client certificate for each agent when a pair is missing.", + "agent_tls_ca_key_file": "Path to the CA signing key for certificate generation. The key stays in RMON.", + "agent_tls_cert_file": "Path to an existing client certificate. Use {uuid} for an individual file for each agent.", + "agent_tls_key_file": "Path to the matching client private key. Supports {uuid}." + } +} %} {% set menu_links = dict() %} {% set menu_links = { "overview": { @@ -45,15 +71,6 @@ } %} {% set settings = { - "rabbitmq": { - "rabbitmq_enabled": "Habilitar alertas no painel WEB", - "rabbitmq_host": "RabbitMQ-server host", - "rabbitmq_port": "RabbitMQ-server port", - "rabbitmq_vhost": "RabbitMQ-server vhost", - "rabbitmq_queue": "RabbitMQ-server queue", - "rabbitmq_user": "Usuario de RabbitMQ-server", - "rabbitmq_password": "Senha do RabbitMQ-server", - }, "monitoring": { "port_scan_interval": "Intervalo de verificação para o Port scanner (em minutos)", "portscanner_keep_history_range": "Período de retenção de historico de Port scanner", @@ -166,7 +183,6 @@ "alert_master_backup": "Alerta sobre a alteração de estado do Master/Backup", "new_version": "Uma nova versão RMON apareceu. Verifica a", "all_alerts_enabled": "Alertas são ativadas", - "disable_alerts": "Desativar alertas", "work_with_prev": "Aqui você pode editar as versões anteriores de configurações", "roll_back": "Reverta para eles, visualize ou exclua", "files_been_deleted": "Os seguintes arquivos foram exluídos", @@ -240,7 +256,6 @@ "auto_start_desc": "O serviços Auto Start permite reiniciar os serviços HAProxy, NGINX, Apache e Keepalived se estiverem inoperantes", "metrics_desc": "Coleta o número de conexões para os serviços HAProxy, NGINX, Apache e HAProxy WAF", "p_s_desc": "Verifica se há portas abertas no servidor e salva o histórico", - "socket_desc": "Socket é um serviço de envio de alertas e notificações", "a_new_version": "Há uma versão mais recente", "no_new_version": "Nã há uma versão mais recente", "main_app": "O aplicativo principal", @@ -602,7 +617,6 @@ "test2": "test", "disabled": "desativado", "via": "via", - "web_panel": "painel web", "message": "mensagem", "menu": "menu", "language": "idioma", @@ -773,3 +787,92 @@ "accept": "aceitar", } %} + +{% set ux = { + "skip_content": "Ir para o conteúdo", + "toggle_menu": "Mostrar ou ocultar o menu", + "close_menu": "Fechar o menu", + "retry": "Tentar novamente", + "loading": "Carregando…", + "widget_error": "Estas informações estão temporariamente indisponíveis.", + "network_error": "Não foi possível conectar ao RMON. Verifique sua conexão.", + "request_error": "Não foi possível concluir a solicitação. Tente novamente.", + "server_error": "O RMON não pôde concluir a solicitação. Tente mais tarde.", + "validation_error": "Verifique os campos obrigatórios e seus valores.", + "unauthorized": "Sua sessão expirou. Entre novamente para continuar.", + "forbidden": "Você não tem permissão para esta ação.", + "not_found": "Este item não está mais disponível. Atualize a página.", + "conflict": "Este item mudou ou já existe. Atualize a página.", + "rate_limit": "Muitas solicitações. Aguarde e tente novamente.", + "login_failed": "Login ou senha incorretos, ou conta desativada.", + "show_password": "Mostrar senha", + "hide_password": "Ocultar senha", + "no_checks": "Ainda não há verificações", + "no_checks_description": "Adicione uma verificação para monitorar a disponibilidade e receber alertas.", + "no_status_pages": "Ainda não há páginas de status", + "no_status_pages_description": "Crie uma página pública para compartilhar a disponibilidade dos serviços.", + "slug_hint": "Letras minúsculas, números, sublinhados ou hífens. Hífens consecutivos não são permitidos.", + "table_label": "Tabela de dados. Role horizontalmente para ver todas as colunas.", + "name_required": "Digite um nome.", + "slug_invalid": "Digite um endereço de página válido.", + "edit": "Editar", + "delete": "Excluir", + "action": "Ação", + "service_not_installed": "Não instalado", + "service_version_unknown": "Versão indisponível" +} %} + + +{% set check_editor = { + "ssl_policy": "Política HTTPS", + "ssl_policy_default": "Padrão", + "ssl_policy_https": "Exigir HTTPS", + "ssl_policy_http": "Exigir HTTP", + "ssl_policy_hint": "Verifica o endereço final após os redirecionamentos permitidos. A mudança de HTTPS para HTTP sempre é um erro, inclusive no modo Padrão. Ignorar erros de certificado não desativa esta política.", + "create": "Criar verificação", + "edit": "Editar verificação", + "sections": "Configuração", + "target": "Verificação", + "settings": "Parâmetros", + "notifications": "Notificações", + "intro": "Comece pelo destino. Os valores padrão estão prontos; expanda as opções avançadas quando necessário.", + "target_hint": "Escolha o destino e os locais de execução.", + "settings_hint": "Intervalos e tempos limite estão em segundos. Expanda os blocos para opções avançadas.", + "notifications_hint": "Escolha os canais ou deixe-os desativados. Você poderá alterar as opções depois.", + "locations": "Locais de execução", + "available": "Disponíveis", + "selected": "Selecionados", + "request": "Requisição e conexão", + "response": "Validação da resposta", + "authentication": "Autenticação", + "proxy": "Proxy", + "extra": "Configurações adicionais", + "back": "Voltar", + "next": "Próximo", + "save": "Salvar alterações", + "saving": "Salvando…", + "required": "Preencha este campo.", + "invalid_url": "Digite uma URL HTTP ou HTTPS com um nome de host.", + "number": "Digite um número no intervalo permitido.", + "port": "Digite uma porta de 2 a 65535.", + "timeout": "O tempo limite deve ser menor que o intervalo.", + "ping_timeout": "Quantidade de pacotes × tempo limite deve ser menor que o intervalo.", + "entities": "Selecione pelo menos um local.", + "status_codes": "Adicione pelo menos um código HTTP aceito.", + "json": "Digite um objeto JSON válido.", + "save_error": "A verificação não foi salva. Os dados foram mantidos; confira os campos e tente novamente.", + "loading": "Carregando configurações…", + "load_error": "Não foi possível carregar a verificação. Feche a janela e tente novamente.", + "copy": "cópia", + "disabled": "Desativado", + "summary": "Resumo", + "seconds": "s", + "summary_empty": "Escolha um destino", + "no_channels": "Notificações desativadas", + "json_path": "Caminho JSON", + "json_value": "Valor JSON esperado", + "proxy_host": "Host do proxy", + "proxy_port": "Porta do proxy", + "proxy_username": "Usuário do proxy", + "proxy_password": "Senha do proxy" +} %} diff --git a/app/templates/languages/ru.html b/app/templates/languages/ru.html index 64318bc..023480b 100644 --- a/app/templates/languages/ru.html +++ b/app/templates/languages/ru.html @@ -1,4 +1,30 @@ {% set lang_short = 'ru' %} +{% set agent_transport = { + "section": "Соединения агентов", + "label": "Передача результатов", + "preserve": "Сохранить текущую конфигурацию", + "unknown": "Ещё не проверено", + "pending": "Есть неприменённые изменения", + "checked": "Соединение проверено", + "help": "Используются настройки группы владельца агента. Для применения изменений нажмите «Перенастроить». Принимающий сервер должен поддерживать выбранный режим. API управления агентом остаётся HTTP.", + "settings_help": "Укажите пути к файлам, доступным RMON внутри каталога этой группы:", + "fields": { + "agent_result_transport": "Режим по умолчанию", + "agent_tls_ca_file": "Доверенный CA", + "agent_tls_generate": "Генерировать клиентские сертификаты", + "agent_tls_ca_key_file": "Ключ подписи CA", + "agent_tls_cert_file": "Клиентский сертификат", + "agent_tls_key_file": "Клиентский закрытый ключ" + }, + "descriptions": { + "agent_result_transport": "Для новых агентов. Режим существующих агентов сохраняется.", + "agent_tls_ca_file": "Путь к доверенному CA. Для HTTPS с системным доверием можно оставить пустым. Для mTLS обязателен.", + "agent_tls_generate": "Выпускать отдельный клиентский сертификат для каждого агента, если пара отсутствует.", + "agent_tls_ca_key_file": "Путь к ключу CA для выпуска сертификатов. Этот ключ остаётся в RMON.", + "agent_tls_cert_file": "Путь к готовому клиентскому сертификату. Используйте {uuid} для отдельного файла каждого агента.", + "agent_tls_key_file": "Путь к соответствующему закрытому ключу. Поддерживает {uuid}." + } +} %} {% set menu_links = dict() %} {% set menu_links = { "overview": { @@ -45,15 +71,6 @@ } %} {% set settings = { - "rabbitmq": { - "rabbitmq_enabled": "Включить оповещение на WEB-панели", - "rabbitmq_host": "Адрес сервера RabbitMQ", - "rabbitmq_port": "Порт сервера RabbitMQ", - "rabbitmq_vhost": "Виртуальный хост RabbitMQ", - "rabbitmq_queue": "Имя очереди RabbitMQ", - "rabbitmq_user": "Имя пользователя RabbitMQ", - "rabbitmq_password": "Пароль пользователя RabbitMQ", - }, "monitoring": { "port_scan_interval": "Интервал проверки для Port scanner (в минутах)", "portscanner_keep_history_range": "Время хранения истории Port scanner", @@ -166,7 +183,6 @@ "alert_master_backup": "Уведомлять о смене статуса Master/Backup", "new_version": "Доступна новая версия RMON. Проверьте", "all_alerts_enabled": "Уведомления включены", - "disable_alerts": "Выключить уведомления", "work_with_prev": "Здесь вы можете работать с предыдущими версиями конфигов", "roll_back": "Откатиться к ним, просмотреть или удалить", "files_been_deleted": "Следующие файлы были удалены", @@ -240,7 +256,6 @@ "auto_start_desc": "Служба автозапуска позволяет перезапустить службы HAProxy, NGINX, Apache и Keepalived, если они не работают.", "metrics_desc": "Собирает количество подключений для сервисов HAProxy, NGINX, Apache и HAProxy WAF", "p_s_desc": "Проверяет сервер на наличие открытых портов и сохраняет историю", - "socket_desc": "Socket — сервис для отправки предупреждений и уведомлений", "a_new_version": "Доступна новая версия", "no_new_version": "Нет новой версии", "main_app": "Главное приложение", @@ -602,7 +617,6 @@ "test2": "тестовое", "disabled": "отключено", "via": "через", - "web_panel": "веб интерфейс", "message": "сообщение", "menu": "меню", "language": "язык", @@ -773,3 +787,92 @@ "accept": "принимать", } %} + +{% set ux = { + "skip_content": "Перейти к содержимому", + "toggle_menu": "Открыть или скрыть меню", + "close_menu": "Закрыть меню", + "retry": "Повторить", + "loading": "Загрузка…", + "widget_error": "Эти данные временно недоступны.", + "network_error": "Нет связи с RMON. Проверьте подключение и повторите попытку.", + "request_error": "Не удалось выполнить запрос. Попробуйте еще раз.", + "server_error": "RMON не смог выполнить запрос. Повторите попытку чуть позже.", + "validation_error": "Проверьте обязательные поля и их значения.", + "unauthorized": "Сессия истекла. Войдите снова, чтобы продолжить.", + "forbidden": "У вас нет прав для этого действия.", + "not_found": "Элемент больше недоступен. Обновите страницу.", + "conflict": "Элемент изменился или уже существует. Обновите страницу.", + "rate_limit": "Слишком много запросов. Попробуйте чуть позже.", + "login_failed": "Неверный логин или пароль, либо учетная запись отключена.", + "show_password": "Показать пароль", + "hide_password": "Скрыть пароль", + "no_checks": "Пока нет проверок", + "no_checks_description": "Добавьте первую проверку, чтобы следить за доступностью и получать уведомления.", + "no_status_pages": "Пока нет статус-страниц", + "no_status_pages_description": "Создайте публичную страницу доступности ваших сервисов.", + "slug_hint": "Строчные латинские буквы, цифры, подчеркивания и дефисы. Два дефиса подряд недопустимы.", + "table_label": "Таблица данных. Прокрутите вправо, чтобы увидеть все столбцы.", + "name_required": "Укажите название.", + "slug_invalid": "Укажите корректный адрес страницы.", + "edit": "Изменить", + "delete": "Удалить", + "action": "Действие", + "service_not_installed": "Не установлен", + "service_version_unknown": "Версия недоступна" +} %} + + +{% set check_editor = { + "ssl_policy": "Политика HTTPS", + "ssl_policy_default": "По умолчанию", + "ssl_policy_https": "Требовать HTTPS", + "ssl_policy_http": "Требовать HTTP", + "ssl_policy_hint": "Проверяется конечный адрес после разрешённых редиректов. Переход HTTPS → HTTP всегда считается ошибкой, в том числе в режиме «По умолчанию». Игнорирование ошибок сертификата не отключает эту политику.", + "create": "Создать проверку", + "edit": "Изменить проверку", + "sections": "Настройка проверки", + "target": "Проверка", + "settings": "Параметры", + "notifications": "Уведомления", + "intro": "Начните с цели проверки. Значения по умолчанию готовы к работе; дополнительные настройки можно раскрыть при необходимости.", + "target_hint": "Выберите, что проверять и откуда запускать проверку.", + "settings_hint": "Интервалы и тайм-ауты указаны в секундах. Дополнительные настройки находятся в раскрываемых блоках.", + "notifications_hint": "Выберите каналы уведомлений или оставьте их отключёнными. Позже настройки можно изменить.", + "locations": "Откуда проверять", + "available": "Доступные", + "selected": "Выбранные", + "request": "Запрос и соединение", + "response": "Проверка ответа", + "authentication": "Авторизация", + "proxy": "Прокси", + "extra": "Дополнительные настройки", + "back": "Назад", + "next": "Далее", + "save": "Сохранить изменения", + "saving": "Сохранение…", + "required": "Заполните это поле.", + "invalid_url": "Укажите HTTP- или HTTPS-адрес с именем хоста.", + "number": "Укажите число в допустимом диапазоне.", + "port": "Укажите порт от 2 до 65535.", + "timeout": "Тайм-аут должен быть меньше интервала.", + "ping_timeout": "Количество пакетов × тайм-аут должно быть меньше интервала.", + "entities": "Выберите хотя бы одно место запуска.", + "status_codes": "Добавьте хотя бы один допустимый HTTP-код ответа.", + "json": "Укажите корректный JSON-объект.", + "save_error": "Проверка не сохранена. Введённые данные остались в форме: проверьте поля и попробуйте ещё раз.", + "loading": "Загрузка настроек проверки…", + "load_error": "Не удалось загрузить проверку. Закройте окно и попробуйте ещё раз.", + "copy": "копия", + "disabled": "Отключено", + "summary": "Сводка", + "seconds": "с", + "summary_empty": "Укажите цель проверки", + "no_channels": "Уведомления отключены", + "json_path": "JSON-путь", + "json_value": "Ожидаемое JSON-значение", + "proxy_host": "Хост прокси", + "proxy_port": "Порт прокси", + "proxy_username": "Пользователь прокси", + "proxy_password": "Пароль прокси" +} %} diff --git a/app/templates/login.html b/app/templates/login.html index 5b871f4..dda6227 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -1,84 +1,30 @@ + {% from 'include/input_macros.html' import input %} {% import 'languages/'+lang|default('en')+'.html' as lang %} + {{ lang.words.login2|title() }} — RMON {% include 'include/main_head.html' %} - - -
    -
    + +
    +
    -
    +
    RMON
    remote monitoring
    -
    -
    - {{ input('login', class='form-login', placeholder=lang.words.login|title(), required='required', autofocus='autofocus') }} + + +
    + {{ input('login', class='form-login', placeholder=lang.words.login|title(), required='required', autofocus='autofocus', autocomplete='username') }}
    -
    -
    - {{ input('pass', type='password', class='form-login', placeholder=lang.words.password|title(), required='required') }} + +
    + {{ input('pass', type='password', class='form-login', placeholder=lang.words.password|title(), required='required', autocomplete='current-password') }} {{ input('ref', type='hidden', value=ref) }} - - - +
    @@ -96,20 +42,19 @@
    {% endif %} {% if error_log or error %} - +
    diff --git a/app/templates/ovw.html b/app/templates/ovw.html index 59ebf96..f2bd9f0 100644 --- a/app/templates/ovw.html +++ b/app/templates/ovw.html @@ -21,25 +21,26 @@ +
    diff --git a/app/templates/smon/agent.html b/app/templates/smon/agent.html index 40f1e02..dae9062 100644 --- a/app/templates/smon/agent.html +++ b/app/templates/smon/agent.html @@ -41,6 +41,7 @@ title="{{lang.words.create|title()}} {{lang.words.w_a}} {{lang.words.new2}} {{lang.words.agent}}" data-edit="{{lang.words.edit|title()}} {{lang.words.agent}}"> {% include 'include/tr_validate_tips.html' %} + {% include 'include/agent_transport.html' %} {{lang.words.server|title()}} diff --git a/app/templates/smon/agents.html b/app/templates/smon/agents.html index 66f5596..95fa209 100644 --- a/app/templates/smon/agents.html +++ b/app/templates/smon/agents.html @@ -68,6 +68,7 @@

    {{lang.smon_page.desc.run_smon}} {% include 'include/tr_validate_tips.html' %} + {% include 'include/agent_transport.html' %} {{lang.words.server|title()}} diff --git a/app/templates/smon/dashboard.html b/app/templates/smon/dashboard.html index eb25c8d..ef5575c 100644 --- a/app/templates/smon/dashboard.html +++ b/app/templates/smon/dashboard.html @@ -14,26 +14,26 @@ {% if smon_status in ('failed', 'inactive', 'ERROR') %} -
    +

    {{lang.smon_page.desc.smon_is_not_run}}

    - There is no server -

    {{lang.smon_page.desc.run_smon}} {{lang.words.here}} {{lang.smon_page.desc.before_use}}

    + +

    {{lang.smon_page.desc.run_smon}} {{lang.words.here}} {{lang.smon_page.desc.before_use}}

    {% elif multi_checks|length == 0 %} -
    +

    -

    {{lang.smon_page.desc.not_added}}

    - There is no server -

    {{lang.smon_page.desc.create_server}} {{lang.words.here}} {{lang.smon_page.desc.before_use}}

    +

    {{ lang.ux.no_checks }}

    + +

    {{ lang.ux.no_checks_description }}


    {% if g.user_params['role'] <= 3 %} -
    + {{lang.words.add|title()}} {{ lang.words.check2 }}
    + {% endif %} {% else %} {% if g.user_params['role'] <= 3 %} -
    + {{lang.words.add|title()}} {{ lang.words.check2 }}
    + {% endif %}
    {% include 'ajax/smon/smon_dashboard.html' %} diff --git a/app/templates/smon/manage_status_page.html b/app/templates/smon/manage_status_page.html index c15db44..b148036 100644 --- a/app/templates/smon/manage_status_page.html +++ b/app/templates/smon/manage_status_page.html @@ -14,10 +14,13 @@

    {{lang.smon_page.desc.smon_is_not_run}}

    There is no server -

    {{lang.smon_page.desc.run_smon}} {{lang.words.here}} {{lang.smon_page.desc.before_use}}

    +

    {{lang.smon_page.desc.run_smon}} {{lang.words.here}} {{lang.smon_page.desc.before_use}}

    {% else %} -
    + {{lang.phrases.create_page_status}}
    + +
    +

    {{ lang.ux.no_status_pages }}

    {{ lang.ux.no_status_pages_description }}

    +
    {% include 'ajax/smon/status_pages.html' %}
    @@ -28,7 +31,7 @@

    {{lang.smon_page.desc.run_smon}} - {{lang.words.name|title()}} + * @@ -37,15 +40,13 @@

    {{lang.smon_page.desc.run_smon}} - Slug + * - /app/smon/status/{{ input('new-status-page-slug') }} -
      -
    • Accept characters: a-z 0-9 -
    • -
    • No consecutive dashes --
    • -
    + {{ input('new-status-page-slug', title=lang.ux.slug_hint) }} +

    {{ url_for('smon.show_smon_status_page', slug='SLUG')|replace('SLUG', '') }}

    +

    {{ lang.ux.slug_hint }}

    @@ -57,14 +58,14 @@

    {{lang.smon_page.desc.run_smon}} - {{lang.words.desc|title()}} + {{ input('new-status-page-desc', autofocus='autofocus') }} - {{lang.phrases.custom_styles}} +

    +
    +
    + +
    +
    +
    + +
    + + + +
    +
    +
    + +
    + + + + +
    Maximum number of redirects to follow. Set to 0 to disable redirects.
    +
    +
    + +
    + +
    Checks the final URL after allowed redirects. HTTPS to HTTP is always an error, including in Default mode. Ignoring certificate errors does not disable this policy.
    +
    +
    +
    Ignore TLS/SSL error
    + + + +
    +
    Accept cookies
    + + + +

    +
    Response validation
    + +
    +
    +
    +
    + +
    + +
    +
    +
    Authentication
    + +
    +
    + + + + +
    +
    Proxy
    + +
    +
    + +
    +
    Additional settings
    + +
    + + + + +
    Use kernel timestamps for more accurate RTT measurements. More expensive.
    +
    +
    + +
    + + + + +
    Time UTC
    +
    + + + +
    + diff --git a/tests/frontend/package-lock.json b/tests/frontend/package-lock.json new file mode 100644 index 0000000..c5e643d --- /dev/null +++ b/tests/frontend/package-lock.json @@ -0,0 +1,543 @@ +{ + "name": "rmon-frontend-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rmon-frontend-tests", + "devDependencies": { + "jsdom": "26.1.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tests/frontend/package.json b/tests/frontend/package.json new file mode 100644 index 0000000..bc43b15 --- /dev/null +++ b/tests/frontend/package.json @@ -0,0 +1,6 @@ +{ + "name": "rmon-frontend-tests", + "private": true, + "scripts": {"test": "node --test *.test.cjs"}, + "devDependencies": {"jsdom": "26.1.0"} +} diff --git a/tests/frontend/user_preferences.test.cjs b/tests/frontend/user_preferences.test.cjs new file mode 100644 index 0000000..32fbb16 --- /dev/null +++ b/tests/frontend/user_preferences.test.cjs @@ -0,0 +1,40 @@ +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const {JSDOM, VirtualConsole} = require('jsdom'); + +const scripts = path.resolve(__dirname, '../../app/static/js'); + +for (const protocol of ['http', 'https']) { + test(`shared UI and user preferences work without browser notification services over ${protocol}`, async t => { + const errors = []; + const output = new VirtualConsole(); + output.on('jsdomError', error => errors.push(error)); + const dom = new JSDOM('
    ' + + '' + + '', + {url: `${protocol}://rmon.test/overview`, runScripts: 'outside-only', pretendToBeVisual: true, virtualConsole: output}); + t.after(() => dom.window.close()); + const w = dom.window; + w.csrf_token = 'test-only-csrf'; + w.matchMedia = () => ({matches: false, addEventListener() {}}); + w.WebSocket = function () { throw new Error('Unexpected WebSocket connection'); }; + for (const file of ['jquery-3.6.0.min.js', 'jquery-ui.min.js', 'js.cookie.min.js', + 'select2.js', 'element.js', 'variables.js', 'nprogress.js', 'toastr.js', 'ux.js']) { + w.eval(fs.readFileSync(path.join(scripts, file), 'utf8')); + } + w.jQuery.ajax = () => w.jQuery.Deferred().resolve().promise(); + w.jQuery.getScript = () => w.jQuery.Deferred().resolve().promise(); + w.eval(fs.readFileSync(path.join(scripts, 'script.js'), 'utf8')); + await new Promise(resolve => w.jQuery(resolve)); + assert.deepEqual(errors, []); + const groups = []; + w.changeCurrentGroupF = user => groups.push(user); + w.saveUserSettings(7); + assert.deepEqual(groups, [7]); + assert.equal(w.localStorage.getItem('theme'), 'dark'); + if (protocol === 'https') assert.equal(w.Cookies.get('lang'), 'ru'); + assert.equal(w.document.querySelectorAll('link[href="/static/css/dark.css"]').length, 1); + }); +} diff --git a/tests/frontend/ux.test.cjs b/tests/frontend/ux.test.cjs new file mode 100644 index 0000000..72b5d26 --- /dev/null +++ b/tests/frontend/ux.test.cjs @@ -0,0 +1,209 @@ +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const {JSDOM} = require('jsdom'); + +const scripts = path.resolve(__dirname, '../../app/static/js'); +const messages = {network_error: 'Offline', server_error: 'Try later', unauthorized: 'Sign in', + forbidden: 'No access', validation_error: 'Check fields', not_found: 'Not found', + conflict: 'Conflict', rate_limit: 'Wait', request_error: 'Request failed', + loading: 'Loading', widget_error: 'Unavailable', retry: 'Retry', table_label: 'Data', action: 'Action'}; + +async function page(t, html = '', mobile = false) { + const dom = new JSDOM(`${html}`, + {url: 'http://rmon.test', runScripts: 'outside-only', pretendToBeVisual: true}); + t.after(() => dom.window.close()); + const w = dom.window; + const media = {matches: mobile, addEventListener: (_, fn) => {media.change = fn;}}; + w.matchMedia = () => media; + w.toastr = {error: () => {}, clear: () => {}}; + for (const file of ['jquery-3.6.0.min.js', 'jquery-ui.min.js', 'ux.js']) { + w.eval(fs.readFileSync(path.join(scripts, file), 'utf8')); + } + w.jQuery.fx.off = true; + await new Promise(resolve => w.jQuery(resolve)); + return {w, $: w.jQuery, ui: w.RmonUI, media}; +} + +test('request errors are localized and do not display backend HTML or tracebacks', async t => { + const {ui} = await page(t); + for (const [status, key] of [[0,'network_error'],[400,'validation_error'],[401,'unauthorized'], + [403,'forbidden'],[404,'not_found'],[409,'conflict'],[422,'validation_error'], + [429,'rate_limit'],[500,'server_error'],[503,'server_error'],[418,'request_error']]) { + assert.equal(ui.requestError({status, responseJSON: {error: ''}}), messages[key]); + } +}); + +test('failed overview fragment offers retry and restores content when retry succeeds', async t => { + const {ui, $} = await page(t, '
    '); + const requests = []; + $.ajax = options => { requests.push(options); options.beforeSend(); }; + ui.loadFragment('#widget', '/overview/users'); + assert.equal($('#widget').attr('aria-busy'), 'true'); + assert.equal(requests[0].global, false); + requests[0].error({status: 500}); + assert.equal($('#widget').attr('aria-busy'), 'false'); + assert.equal($('#widget button').text(), 'Retry'); + $('#widget button').trigger('click'); + assert.equal(requests.length, 2); + requests[1].success('Alice'); + assert.equal($('#widget').text(), 'Alice'); + assert.equal($('#widget').attr('aria-busy'), 'false'); + assert.equal($('#widget button').length, 0); +}); + +test('legacy error text is replaced and absent role-specific widgets make no requests', async t => { + const {ui, $} = await page(t, '
    '); + let request; + $.ajax = options => {request = options; options.beforeSend();}; + ui.loadFragment('#missing', '/private'); + assert.equal(request, undefined); + ui.loadFragment('#widget', '/overview/logs'); + request.success('error: secret backend traceback'); + assert.equal($('#widget').text(), 'UnavailableRetry'); +}); + +test('chart loading and error states do not accumulate and ready restores canvas', async t => { + const {ui, $} = await page(t, '
    '); + ui.widgetState('#chart', 'loading'); + ui.widgetState('#chart', 'loading'); + assert.equal($('#chart .widget-state').length, 1); + assert.equal($('#chart canvas').css('display'), 'none'); + ui.widgetState('#chart', 'error', () => {}); + ui.widgetState('#chart', 'ready'); + assert.equal($('#chart .widget-state').length, 0); + assert.notEqual($('#chart canvas').css('display'), 'none'); +}); + +const navigation = '' + + ''; + +test('mobile menu opens, closes on Escape and restores focus to toggle', async t => { + const {ui, w, $} = await page(t, navigation, true); + ui.initNavigation(); + assert.equal($('#menu-toggle').attr('aria-expanded'), 'false'); + assert.equal(w.document.getElementById('main-navigation').inert, true); + $('#menu-toggle')[0].click(); + assert.equal($('#menu-toggle').attr('aria-expanded'), 'true'); + assert.equal($('#menu-backdrop')[0].hidden, false); + assert.equal(w.document.activeElement.textContent, 'Overview'); + w.document.dispatchEvent(new w.KeyboardEvent('keydown', {key: 'Escape'})); + assert.equal($('#menu-toggle').attr('aria-expanded'), 'false'); + assert.equal(w.document.activeElement.id, 'menu-toggle'); +}); + +test('desktop menu preference survives resize while mobile starts collapsed', async t => { + const {ui, w, $, media} = await page(t, navigation); + ui.initNavigation(); + $('#menu-toggle')[0].click(); + assert.equal(w.sessionStorage.getItem('hide_menu'), 'hide'); + assert.ok(w.document.body.classList.contains('nav-collapsed')); + media.matches = true; media.change(); + assert.equal($('#menu-toggle').attr('aria-expanded'), 'false'); + media.matches = false; media.change(); + assert.ok(w.document.body.classList.contains('nav-collapsed')); +}); + +test('legacy action controls support keyboard activation without duplicate activation', async t => { + const {w, $} = await page(t, ''); + let clicks = 0; + $('#action').on('click', () => clicks++); + assert.equal($('#action').attr('role'), 'button'); + assert.equal($('#action').attr('aria-label'), 'Edit'); + $('#action')[0].dispatchEvent(new w.KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); + assert.equal(clicks, 1); +}); + +test('enhancement keeps a single scroll region when a table plugin inserts a wrapper', async t => { + const {w, ui} = await page(t, '
    '); + w.document.querySelector('table').getClientRects = () => [{width: 600, height: 400}]; + ui.enhance(w.document); + ui.enhance(w.document); + assert.equal(w.document.querySelectorAll('.table-scroll').length, 1); +}); + +test('empty checkbox labels and custom comboboxes get meaningful field names', async t => { + const {w, ui, $} = await page(t, '' + + '' + + '
    EnabledAccept cookies
    Protocol
    '); + $('#protocol').selectmenu(); + ui.enhance(w.document); + assert.equal($('#enabled').attr('aria-label'), 'Enabled'); + assert.equal($('#cookies').attr('aria-label'), 'Accept cookies'); + assert.equal($('#protocol-button').attr('aria-label'), 'Protocol'); +}); + +test('mobile menu traps Tab and closes when a destination is chosen', async t => { + const {w, ui, $} = await page(t, navigation, true); + for (const link of w.document.querySelectorAll('aside a')) link.getClientRects = () => [{width: 100, height: 44}]; + ui.initNavigation(); + $('#menu-toggle')[0].click(); + w.document.dispatchEvent(new w.KeyboardEvent('keydown', {key: 'Tab', shiftKey: true})); + assert.equal(w.document.activeElement.id, 'menu-toggle'); + w.document.dispatchEvent(new w.KeyboardEvent('keydown', {key: 'Tab', shiftKey: true})); + assert.equal(w.document.activeElement.textContent, 'Admin'); + // Suppress navigation: this test verifies only the menu's click handler. + $('aside a').on('click', event => event.preventDefault()).last()[0].click(); + assert.equal($('#menu-toggle').attr('aria-expanded'), 'false'); + assert.equal($('#menu-backdrop')[0].hidden, true); +}); + +test('dialog keeps viewport constraints after animation restores inline styles', async t => { + const {w, $} = await page(t, '
    Fields
    '); + w.innerWidth = 390; + $('#editor').dialog({width: 1225, autoOpen: false}); + const dialog = $('#editor').dialog('widget'); + // jsdom has no layout engine; expose the visible box used by jQuery's positioning code. + dialog[0].getClientRects = () => [{width: 366, height: 400}]; + $('#editor').dialog('open'); + assert.equal(dialog[0].style.width, '366px'); + dialog.attr('style', 'position: absolute; display: block; width: 1225px'); + $('#editor').trigger('dialogfocus'); + assert.equal(dialog[0].style.width, '366px'); + assert.equal(dialog[0].style.position, 'fixed'); + assert.equal(dialog[0].style.display, 'flex'); + assert.equal($('#editor')[0].style.overflow, 'auto'); +}); + +async function statusEditor(t) { + const context = await page(t, '
    ' + + '
    ' + + '' + + '

    ' + + '
    ' + + '
    ' + + '
    '); + Object.assign(context.w, {clearTips: () => {}, cancel_word: 'Cancel', back_word: 'Back', add_word: 'Add'}); + context.w.eval(fs.readFileSync(path.join(scripts, 'rmon/status_page.js'), 'utf8')); + await new Promise(resolve => context.$(resolve)); + return context; +} + +test('status page slug is suggested, preview is correct and manual URL is preserved', async t => { + const {w, $} = await statusEditor(t); + w.createStatusPageStep1(); + $('#new-status-page-name').val('API Health').trigger('input'); + assert.equal($('#new-status-page-slug').val(), 'api-health'); + assert.equal($('#status-page-url-preview').text(), 'http://rmon.test/rmon/status/api-health'); + $('#new-status-page-slug').val('public-api').trigger('input'); + $('#new-status-page-name').val('Renamed').trigger('input'); + assert.equal($('#new-status-page-slug').val(), 'public-api'); +}); + +test('status page Back preserves draft and Cancel resets it for a new page', async t => { + const {w, $} = await statusEditor(t); + w.createStatusPageStep1(); + $('#new-status-page-name').val('API Health').trigger('input'); + $('#create-status-page-step-1').dialog('option', 'buttons')[0].click.call($('#create-status-page-step-1')[0]); + $('#new-status-page-desc').val('Public description'); + $('#create-status-page-step-2').dialog('option', 'buttons')[1].click.call($('#create-status-page-step-2')[0]); + assert.equal($('#new-status-page-name').val(), 'API Health'); + assert.equal($('#new-status-page-slug').val(), 'api-health'); + assert.equal($('#new-status-page-desc').val(), 'Public description'); + $('#create-status-page-step-1').dialog('option', 'buttons')[1].click.call($('#create-status-page-step-1')[0]); + w.createStatusPageStep1(); + assert.equal($('#new-status-page-name').val(), ''); + assert.equal($('#new-status-page-desc').val(), ''); + assert.equal($('#new-status-page-slug').data('manually-edited'), false); +}); diff --git a/tests/functional/test_agent_group_transport.py b/tests/functional/test_agent_group_transport.py new file mode 100644 index 0000000..ac69d72 --- /dev/null +++ b/tests/functional/test_agent_group_transport.py @@ -0,0 +1,229 @@ +import configparser +import importlib.util +from pathlib import Path +from types import SimpleNamespace +import uuid +from unittest.mock import Mock +from flask import g, render_template_string + +import pytest +from peewee import SqliteDatabase +from playhouse.migrate import SqliteMigrator + +from app.modules.common import agent_transport as transport +from app.modules.db import group as group_sql +from app.modules.db.db_model import Groups, Setting, Server, SmonAgent, InstallationTasks +from app.modules.roxywi.class_models import RmonAgent +from app.modules.service import installation +from app.modules.tools import smon_agent + + +@pytest.fixture +def owned_agent(tmp_path, monkeypatch): + group = Groups.create(name=uuid.uuid4().hex, description='Transport test') + group_sql.add_setting_for_new_group(group.group_id) + server = Server.create(hostname='transport-test', ip=f'{uuid.uuid4().hex}.test', group_id=group.group_id) + agent = SmonAgent.create(server_id=server, name='Transport test', uuid=str(uuid.uuid4()), description='') + monkeypatch.setenv('RMON_AGENT_CERTIFICATES_DIR', str(tmp_path / 'certificates')) + config = configparser.ConfigParser() + config['agent_deployment'] = {'mtls_enabled': 'true', 'tls_ca_file': '/another-group/ca.crt'} + monkeypatch.setattr(smon_agent, 'GetConfigVar', lambda: SimpleNamespace(config=config)) + get_setting = smon_agent.sql.get_setting + receiver_settings = {'master_ip': 'receiver.test', 'master_port': '5100'} + monkeypatch.setattr(smon_agent.sql, 'get_setting', lambda key, **kwargs: + receiver_settings[key] if key in receiver_settings else get_setting(key, **kwargs)) + yield agent + InstallationTasks.delete().where(InstallationTasks.server_id == server).execute() + SmonAgent.delete().where(SmonAgent.server_id == server).execute() + server.delete_instance() + group_sql.delete_group(group.group_id) + + +def set_value(agent, key, value): + Setting.update(value=value).where((Setting.group_id == agent.server_id.group_id) & (Setting.param == key)).execute() + + +def test_new_groups_have_independent_settings(owned_agent): + values = transport.group_settings(owned_agent.server_id.group_id) + assert values == transport.DEFAULTS + set_value(owned_agent, 'agent_result_transport', 'mtls') + assert transport.group_settings(1)['agent_result_transport'] == 'http' + + +def test_explicit_group_mtls_paths_reach_role(owned_agent): + group_id = owned_agent.server_id.group_id + root = transport.certificate_root(group_id) + root.mkdir(parents=True) + for name, key in [('ca.crt', 'agent_tls_ca_file'), ('ca.key', 'agent_tls_ca_key_file')]: + path = root / name + path.write_text('test file; real PEM validation is done by the role') + set_value(owned_agent, key, path.as_posix()) + inv, _ = smon_agent.generate_agent_inv('host.test', 'install', owned_agent.uuid, + group_id=group_id, result_transport='mtls') + settings = inv['server']['hosts']['host.test'] + assert settings['agent_transport'] == 'mtls' + assert settings['agent_tls_ca_file'] == (root / 'ca.crt').as_posix() + assert settings['agent_tls_ca_key_file'] == (root / 'ca.key').as_posix() + assert settings['agent_certificate_directory'] == root.as_posix() + assert settings['agent_verify_result_connection'] is True + + +@pytest.mark.parametrize('value', ['../other/ca.key', '/etc/passwd', '/var/lib/rmon/keys/agent-certificates/1/ca.key', '{{ secret }}', '/tmp/a\nkey']) +def test_certificate_paths_cannot_escape_the_group(owned_agent, value): + with pytest.raises(ValueError): + transport.validate_setting('agent_tls_ca_key_file', value, owned_agent.server_id.group_id) + + +def test_uuid_template_uses_persisted_agent_identity(owned_agent): + group_id = owned_agent.server_id.group_id + root = transport.certificate_root(group_id) + path = root / '{uuid}' / 'client.pem' + assert transport.certificate_path(path.as_posix(), group_id, owned_agent.uuid) == ( + root / owned_agent.uuid / 'client.pem').as_posix() + with pytest.raises(ValueError, match='CA path'): + transport.validate_setting('agent_tls_ca_file', path.as_posix(), group_id) + + +def test_missing_file_is_rejected_before_launch(owned_agent): + group_id = owned_agent.server_id.group_id + set_value(owned_agent, 'agent_tls_ca_file', (transport.certificate_root(group_id) / 'missing.pem').as_posix()) + with pytest.raises(ValueError, match='missing'): + transport.inventory_settings(group_id, owned_agent.uuid, 'https') + + +def test_legacy_reconfigure_preserves_transport(owned_agent, monkeypatch): + run = Mock(return_value=42) + monkeypatch.setattr(smon_agent, 'run_ansible_thread', run) + smon_agent.reconfigure_agent(owned_agent.id) + host = run.call_args.args[0]['server']['hosts'][owned_agent.server_id.ip] + assert 'agent_transport' not in host + assert 'agent_tls_ca_file' not in host + + +def test_save_does_not_apply_and_success_records_selected_mode(owned_agent, monkeypatch): + run = Mock(return_value=42) + monkeypatch.setattr(smon_agent, 'run_ansible_thread', run) + body = RmonAgent(name=owned_agent.name, server_id=owned_agent.server_id_id, result_transport='https') + smon_agent.update_agent(owned_agent.id, body) + run.assert_not_called() + agent = SmonAgent.get_by_id(owned_agent.id) + assert agent.result_transport == 'https' and transport.pending(agent) + smon_agent.reconfigure_agent(agent.id) + inv = run.call_args.args[0] + transport.record_applied(inv) + agent = SmonAgent.get_by_id(agent.id) + assert agent.applied_result_transport == 'https' and agent.transport_checked_at is not None + assert not transport.pending(agent) + set_value(agent, 'agent_tls_ca_file', (transport.certificate_root(agent.server_id.group_id) / 'new-ca.pem').as_posix()) + assert transport.pending(agent) + + +def test_failed_task_does_not_claim_new_transport(owned_agent, monkeypatch): + owned_agent.applied_result_transport = 'http' + owned_agent.result_transport = 'https' + owned_agent.save() + task = InstallationTasks.create(service_name='Agent', server_id=owned_agent.server_id, + user_id=1, group_id=owned_agent.server_id.group_id, action='reconfigure') + inv, hosts = smon_agent.generate_agent_inv(owned_agent.server_id.ip, 'install', owned_agent.uuid, + group_id=owned_agent.server_id.group_id, result_transport='https') + monkeypatch.setattr(installation, 'run_ansible', Mock(return_value={'failures': {'host': 1}, 'dark': {}})) + installation.run_installations(inv, hosts, 'rmon_agent', task.id) + assert SmonAgent.get_by_id(owned_agent.id).applied_result_transport == 'http' + assert InstallationTasks.get_by_id(task.id).status == 'failed' + + +def test_successful_task_records_the_verified_mode(owned_agent, monkeypatch): + task = InstallationTasks.create(service_name='Agent', server_id=owned_agent.server_id, + user_id=1, group_id=owned_agent.server_id.group_id, action='reconfigure') + inv, hosts = smon_agent.generate_agent_inv(owned_agent.server_id.ip, 'install', owned_agent.uuid, + group_id=owned_agent.server_id.group_id, result_transport='https') + monkeypatch.setattr(installation, 'run_ansible', Mock(return_value={'failures': {}, 'dark': {}})) + installation.run_installations(inv, hosts, 'rmon_agent', task.id) + assert InstallationTasks.get_by_id(task.id).status == 'completed' + agent = SmonAgent.get_by_id(owned_agent.id) + assert agent.applied_result_transport == 'https' + assert agent.transport_checked_at is not None + + +def test_agent_exists_before_background_installation(owned_agent, monkeypatch): + owned_agent.delete_instance() + set_value(owned_agent, 'agent_result_transport', 'https') + monkeypatch.setattr(smon_agent, 'check_agent_limit', lambda: None) + monkeypatch.setattr(smon_agent.roxywi_common, 'logger', Mock()) + def run(inv, *args): + transport.record_applied(inv) + return 42 + monkeypatch.setattr(smon_agent, 'run_ansible_thread', run) + agent_id, task_id = smon_agent.add_agent(RmonAgent(name='New agent', server_id=owned_agent.server_id_id)) + agent = SmonAgent.get_by_id(agent_id) + assert task_id == 42 and agent.result_transport == 'https' + assert agent.applied_result_transport == 'https' + + +def test_group_settings_api_validates_paths(client, auth_headers, owned_agent): + response = client.post('/api/v1.0/settings/agent', json={'param': 'agent_tls_ca_file', 'value': '/etc/passwd'}, headers=auth_headers(1, 1)) + assert response.status_code == 400 + assert transport.group_settings(1)['agent_tls_ca_file'] == '' + + +def test_settings_api_returns_only_requested_group_section(client, auth_headers, owned_agent): + group_id = owned_agent.server_id.group_id + set_value(owned_agent, 'agent_result_transport', 'mtls') + response = client.get(f'/api/v1.0/settings/agent?group_id={group_id}', headers=auth_headers(1, 1)) + assert response.status_code == 200 + assert {row['param'] for row in response.json} == set(transport.DEFAULTS) + assert all(int(row['group_id']) == group_id for row in response.json) + assert next(row['value'] for row in response.json if row['param'] == 'agent_result_transport') == 'mtls' + + +def test_other_group_cannot_change_transport_settings(client, auth_headers, owned_agent): + group_id = owned_agent.server_id.group_id + response = client.post(f'/api/v1.0/settings/agent?group_id={group_id}', + json={'param': 'agent_result_transport', 'value': 'mtls'}, headers=auth_headers(2, 1)) + assert response.status_code == 404 + assert transport.group_settings(group_id)['agent_result_transport'] == 'http' + + +def test_transport_defaults_and_agent_status_api(client, auth_headers, owned_agent): + response = client.get('/rmon/agent/transport-settings', headers=auth_headers(1, 1)) + assert response.status_code == 200 and response.json == {'result_transport': 'http'} + response = client.get(f'/api/v1.0/rmon/agent/{owned_agent.id}', headers=auth_headers(1, 1)) + assert response.status_code == 200 + assert response.json['applied_result_transport'] is None + assert 'uuid' not in response.json and 'transport_settings_hash' not in response.json + + +def test_migration_is_repeatable_and_preserves_legacy_agents(owned_agent, monkeypatch): + path = Path(__file__).resolve().parents[2] / 'app/migrations/20260911000000_add_agent_result_transport.py' + spec = importlib.util.spec_from_file_location('agent_transport_migration', path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + db = SqliteDatabase(':memory:') + db.execute_sql('CREATE TABLE smon_agents (id INTEGER PRIMARY KEY, uuid TEXT, port INTEGER)') + db.execute_sql("INSERT INTO smon_agents VALUES (1, 'keep-uuid', 5101)") + monkeypatch.setattr(module, 'connect', lambda get_migrator=False: SqliteMigrator(db) if get_migrator else db) + module.upgrade() + module.upgrade() + row = db.execute_sql('SELECT uuid, port, result_transport, applied_result_transport FROM smon_agents').fetchone() + assert row == ('keep-uuid', 5101, None, None) + db.close() + + +@pytest.mark.parametrize('language', ['en', 'ru', 'fr', 'pt-br']) +def test_group_connection_controls_render(app, owned_agent, language): + with app.test_request_context('/'): + g.user_params = {'role': 2} + markup = render_template_string( + "{% import 'languages/' + language + '.html' as lang %}" + "{% from 'include/input_macros.html' import input, checkbox %}" + "{% include 'include/admin_settings.html' %}" + "{% include 'include/agent_transport.html' %}", + language=language, + settings=Setting.select().where(Setting.group_id == owned_agent.server_id.group_id), + agent_certificate_root=transport.certificate_root(owned_agent.server_id.group_id), + timezones=['UTC'], + ) + assert 'id="new-agent-result-transport"' in markup + assert 'id="agent_tls_ca_file"' in markup + assert 'HTTPS + mTLS' in markup + assert 'Undefined' not in markup diff --git a/tests/functional/test_agent_health_and_control.py b/tests/functional/test_agent_health_and_control.py new file mode 100644 index 0000000..b3fb6df --- /dev/null +++ b/tests/functional/test_agent_health_and_control.py @@ -0,0 +1,184 @@ +import json +import uuid +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +import requests + +import app.modules.tools.smon_agent as agents +import app.routes.smon.agent_routes as routes +import app.modules.roxywi.overview as overview +from app.modules.server.ssh_connection import SshConnection +from app.modules.db.db_model import Server, SmonAgent, InstallationTasks +from app.modules.service import installation +from app.modules.roxywi.class_models import RmonAgent + + +@pytest.mark.parametrize('description', [{}, {'description': None}, {'description': ''}]) +def test_agent_creation_accepts_empty_description(agent, description): + data = RmonAgent(name='Empty description', server_id=agent.server_id.server_id, **description) + row = SmonAgent.create(**data.model_dump(exclude={'reconfigure', 'uuid'}), uuid=str(uuid.uuid4())) + try: + assert row.description == '' + finally: + row.delete_instance() + + +def test_failed_installation_is_recorded_without_a_request_context(agent, monkeypatch): + task = InstallationTasks.create(service_name='Agent', server_id=agent.server_id, user_id=1, + group_id=1, action='install') + monkeypatch.setattr(installation, 'run_ansible', Mock(side_effect=RuntimeError('Deployment failed'))) + monkeypatch.setattr(installation.roxywi_common, 'logger', Mock(side_effect=AssertionError('Requires HTTP request'))) + try: + installation.run_installations({}, [agent.server_id.ip], 'rmon_agent', task.id) + result = InstallationTasks.get_by_id(task.id) + assert result.status == 'failed' + assert result.error == 'Deployment failed' + assert result.finish_date is not None + finally: + task.delete_instance() + + +@pytest.fixture +def agent(): + server = Server.create(hostname='health-test', ip=f'{uuid.uuid4().hex}.test', group_id='1') + row = SmonAgent.create(server_id=server, name='Health test', uuid=str(uuid.uuid4()), description='', port=5101) + yield row + row.delete_instance() + server.delete_instance() + + +@pytest.mark.parametrize('running', [True, False]) +def test_health_uses_dedicated_probe(monkeypatch, running): + get = Mock(return_value=json.dumps({'status': 'ok', 'running': running}).encode()) + monkeypatch.setattr(agents, 'send_get_request_to_agent', get) + assert agents.get_agent_health(7, '192.0.2.10')['running'] is running + get.assert_called_once_with(7, '192.0.2.10', 'health') + + +def http_error(status): + response = requests.Response() + response.status_code = status + return requests.HTTPError(response=response) + + +def test_old_agent_can_use_scheduler_status(monkeypatch): + get = Mock(side_effect=[http_error(404), b'{"running": true}']) + monkeypatch.setattr(agents, 'send_get_request_to_agent', get) + assert agents.get_agent_health(7, 'host') == {'running': True} + assert [call.args[2] for call in get.call_args_list] == ['health', 'scheduler'] + + +@pytest.mark.parametrize('status', [401, 403, 500]) +def test_health_does_not_hide_auth_or_server_errors(monkeypatch, status): + get = Mock(side_effect=http_error(status)) + monkeypatch.setattr(agents, 'send_get_request_to_agent', get) + with pytest.raises(requests.HTTPError): + agents.get_agent_health(7, 'host') + assert get.call_count == 1 + + +@pytest.mark.parametrize('payload', [b'', b'{}', b'[]', b'{"running":"true"}']) +def test_health_rejects_invalid_responses(monkeypatch, payload): + monkeypatch.setattr(agents, 'send_get_request_to_agent', Mock(return_value=payload)) + with pytest.raises(ValueError): + agents.get_agent_health(7, 'host') + + +@pytest.mark.parametrize('status', [200, 404, 500]) +def test_agent_transport_checks_http_status_and_closes(monkeypatch, status): + response = Mock(content=b'ok') + if status != 200: + response.raise_for_status.side_effect = http_error(status) + monkeypatch.setattr(agents.requests, 'get', Mock(return_value=response)) + monkeypatch.setattr(agents, 'get_agent_headers', lambda _: {}) + monkeypatch.setattr(agents.smon_sql, 'get_agent_data', lambda _: SimpleNamespace(port=5101)) + monkeypatch.setattr(agents.smon_sql, 'get_agent_ip_by_id', lambda _: '127.0.0.1') + if status == 200: + assert agents.send_get_request_to_agent(1, 'host', 'health') == b'ok' + else: + with pytest.raises(requests.HTTPError): + agents.send_get_request_to_agent(1, 'host', 'health') + response.raise_for_status.assert_called_once() + response.close.assert_called_once() + + +@pytest.mark.parametrize('endpoint,payload,expected', [ + ('status', b'{"running":true}', {'running': True}), + ('uptime', b'{"uptime":"2026-09-07 06:00:00"}', {'uptime': '2026-09-07 06:00:00'}), + ('checks', b'0', 0), + ('version', b'{"version":"1.20"}', {'version': '1.20'}), +]) +def test_agent_probes_return_json(client, auth_headers, agent, monkeypatch, endpoint, payload, expected): + monkeypatch.setattr(agents, 'send_get_request_to_agent', Mock(return_value=payload)) + response = client.get(f'/rmon/agent/{endpoint}/{agent.server_id.ip}?agent_id={agent.id}', headers=auth_headers(1, 1)) + assert response.status_code == 200 + assert response.is_json + assert response.get_json() == expected + + +@pytest.mark.parametrize('endpoint', ['status', 'uptime', 'checks', 'version']) +def test_html_upstream_is_json_failure_not_success(client, auth_headers, agent, monkeypatch, endpoint): + monkeypatch.setattr(agents, 'send_get_request_to_agent', Mock(return_value=b'

    404

    ')) + response = client.get(f'/rmon/agent/{endpoint}/{agent.server_id.ip}?agent_id={agent.id}', headers=auth_headers(1, 1)) + assert response.status_code == 502 + assert response.is_json and 'error' in response.get_json() + assert '= 1 + assert rotate_credentials() == 0 + claimed = queue.claim() + assert claimed[0] == task_id + assert queue.load_payload(*claimed)['inventory']['server']['hosts'][target.ip]['private_test_value'] == 'never-store-plaintext' + + +@pytest.mark.parametrize('runner_fails', [True, False]) +def test_ansible_releases_ssh_agent_and_inventory_on_failure_or_success(tmp_path, monkeypatch, runner_fails): + import sys + from types import SimpleNamespace + from app.modules.service import installation + original_path = Path + monkeypatch.setattr(installation, 'Path', lambda path: tmp_path if str(path).endswith('/ansible/inventory') else original_path(path)) + monkeypatch.setattr(installation, '_ensure_agent_collections', lambda: '/test/collections') + monkeypatch.setattr(installation.sql, 'get_setting', lambda _: '') + monkeypatch.setattr(installation.server_mod, 'start_ssh_agent', lambda: {'pid': 123, 'socket': 'test-socket'}) + stop = Mock() + monkeypatch.setattr(installation.server_mod, 'stop_ssh_agent', stop) + monkeypatch.setattr(installation, 'return_ssh_keys_path', lambda _: { + 'enabled': False, 'password': 'test-only-password', 'user': 'test', 'port': 22}) + cancelled = Mock(return_value=False) + def execute(**kwargs): + assert kwargs['cancel_callback'] is cancelled + assert Path(kwargs['inventory']).is_file() + if runner_fails: + raise RuntimeError('Runner failed') + return SimpleNamespace(rc=0, stats={'failures': {}, 'dark': {}}) + monkeypatch.setitem(sys.modules, 'ansible_runner', SimpleNamespace(run=execute)) + inventory = {'server': {'hosts': {'example.test': {'action': 'start'}}}} + if runner_fails: + with pytest.raises(RuntimeError, match='Runner failed'): + installation.run_ansible(inventory, ['example.test'], 'rmon_agent', cancel_callback=cancelled) + else: + assert installation.run_ansible(inventory, ['example.test'], 'rmon_agent', cancel_callback=cancelled)['failures'] == {} + stop.assert_called_once() + assert not list(tmp_path.iterdir()) diff --git a/tests/functional/test_remove_web_notifications.py b/tests/functional/test_remove_web_notifications.py new file mode 100644 index 0000000..b9d3705 --- /dev/null +++ b/tests/functional/test_remove_web_notifications.py @@ -0,0 +1,101 @@ +import importlib.util +from pathlib import Path +from unittest.mock import Mock + +import pytest +from peewee import IntegrityError, SqliteDatabase + +from app.modules.db.db_model import RoxyTool, Setting + + +@pytest.fixture +def legacy_notifications(tmp_path, monkeypatch): + path = Path(__file__).parents[2] / 'app/migrations/20260913000000_remove_web_notifications.py' + spec = importlib.util.spec_from_file_location('remove_web_notifications', path) + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + database = SqliteDatabase(tmp_path / 'legacy.db') + monkeypatch.setattr(migration, 'connect', lambda: database) + with database.bind_ctx([Setting, RoxyTool]): + database.create_tables([Setting, RoxyTool]) + for group_id in (1, 2): + for param in ('enabled', 'host', 'port', 'vhost', 'queue', 'user', 'password'): + Setting.create(param='rabbitmq_' + param, value='legacy-value', section='rabbitmq', desc='', group_id=group_id) + Setting.create(param='mail_smtp_host', value='mail.example.test', section='mail', desc='', group_id=group_id) + for service in ('rmon-socket', 'rabbitmq-server', 'rmon-server', 'fail2ban'): + RoxyTool.create(name=service, current_version='1.0', new_version='1.1', is_roxy=1, desc='') + try: + yield database, migration + finally: + database.close() + + +def test_migration_removes_only_retired_settings_and_services_and_is_repeatable(legacy_notifications): + database, migration = legacy_notifications + migration.upgrade() + migration.upgrade() + assert list(Setting.select(Setting.param, Setting.value, Setting.group_id).order_by(Setting.group_id).tuples()) == [ + ('mail_smtp_host', 'mail.example.test', 1), ('mail_smtp_host', 'mail.example.test', 2)] + assert {row.name for row in RoxyTool.select()} == {'rmon-server', 'fail2ban'} + with pytest.raises(RuntimeError, match='database backup'): + migration.downgrade() + + +def test_migration_rolls_back_settings_if_service_cleanup_fails(legacy_notifications): + database, migration = legacy_notifications + database.execute_sql("CREATE TRIGGER reject_cleanup BEFORE DELETE ON roxy_tools BEGIN SELECT RAISE(ABORT, 'blocked'); END") + with pytest.raises(IntegrityError, match='blocked'): + migration.upgrade() + assert Setting.select().where(Setting.section == 'rabbitmq').count() == 14 + assert RoxyTool.select().count() == 4 + + +def test_retired_services_are_not_exposed_or_controllable_even_before_migration(legacy_notifications, monkeypatch): + from app.modules.db import roxy as service_sql + from app.modules.roxywi import roxy + from app.modules.tools import common + assert set(service_sql.get_all_tools()) == {'rmon-server', 'fail2ban'} + assert set(service_sql.get_roxy_tools()) == {'rmon-server', 'fail2ban'} + monkeypatch.delenv('RMON_CONTAINER', raising=False) + monkeypatch.setattr(roxy.os, 'system', Mock(side_effect=AssertionError('Retired service was controlled'))) + monkeypatch.setattr(common.server_mod, 'subprocess_execute', Mock(side_effect=AssertionError('Retired package was installed'))) + for name in ('rmon-socket', 'rabbitmq-server'): + assert roxy.action_service('restart', name) == 'error: Unsupported service' + with pytest.raises(Exception, match='not part of RMON'): + common.update_roxy_wi(name) + + +@pytest.mark.functional +def test_settings_api_and_channel_page_no_longer_offer_web_notifications(client, auth_headers): + headers = auth_headers(1, 1) + response = client.get('/api/v1.0/settings', headers=headers) + assert response.status_code == 200 + assert not any(row['section'] == 'rabbitmq' for row in response.json) + assert client.get('/api/v1.0/settings/rabbitmq', headers=headers).status_code == 404 + html = client.get('/channel/load', headers=headers) + assert html.status_code == 200 + assert "sendCheckMessage('web')" not in html.get_data(as_text=True) + assert "sendCheckMessage('email')" in html.get_data(as_text=True) + + +@pytest.mark.functional +def test_email_test_remains_available_and_retired_sender_is_rejected(client, auth_headers, monkeypatch): + from app.modules.tools import alerting + send_email = Mock(return_value='ok') + monkeypatch.setattr(alerting, 'check_email_alert', send_email) + headers = auth_headers(1, 1) + rejected = client.post('/channel/check', headers=headers, json={'sender': 'web'}) + assert rejected.status_code == 400 + send_email.assert_not_called() + accepted = client.post('/channel/check', headers=headers, json={'sender': 'email'}) + assert accepted.status_code == 200 + assert accepted.json == {'status': 'success'} + send_email.assert_called_once_with() + + +@pytest.mark.functional +@pytest.mark.parametrize('payload', [{}, [], {'sender': []}, {'sender': {}}]) +def test_invalid_notification_test_sender_is_rejected(client, auth_headers, payload): + response = client.post('/channel/check', headers=auth_headers(1, 1), json=payload) + assert response.status_code == 400 + assert response.json == {'error': 'Unsupported notification channel'} diff --git a/tests/functional/test_service_versions.py b/tests/functional/test_service_versions.py new file mode 100644 index 0000000..9add0d1 --- /dev/null +++ b/tests/functional/test_service_versions.py @@ -0,0 +1,207 @@ +import copy +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from app.modules.tools import common + + +@pytest.fixture +def version_probes(monkeypatch): + monkeypatch.delenv('RMON_SERVER_INTERNAL_URL', raising=False) + monkeypatch.setattr(common.distro, 'id', lambda: 'ubuntu') + monkeypatch.setattr(common.distro, 'like', lambda: '') + writes = [] + monkeypatch.setattr(common.roxy_sql, 'update_tool_cur_version', lambda *args: writes.append(args)) + return writes + + +@pytest.mark.parametrize('status,payload,expected', [ + (200, {'service': 'rmon-server', 'version': '6.33'}, '6.33'), + (401, {}, '0'), (302, {}, '0'), (200, {'service': 'other', 'version': '6.33'}, '0'), + (200, {'service': 'rmon-server', 'version': ''}, '0'), (200, [], '0'), +]) +def test_container_version_uses_authenticated_mtls(monkeypatch, tmp_path, version_probes, status, payload, expected): + token = tmp_path / 'token' + token.write_text('test-only-token\n') + for key, value in {'URL': 'https://127.0.0.1:5100', 'TOKEN_FILE': str(token)}.items(): + monkeypatch.setenv('RMON_SERVER_INTERNAL_' + key, value) + for key, value in {'CA_FILE': '/tls/ca.crt', 'CLIENT_CERT_FILE': '/tls/ui.crt', 'CLIENT_KEY_FILE': '/tls/ui.key'}.items(): + monkeypatch.setenv('RMON_SERVER_' + key, value) + response = MagicMock(status_code=status) + response.json.return_value = payload + response.__enter__.return_value = response + session = MagicMock() + session.__enter__.return_value = session + session.get.return_value = response + monkeypatch.setattr(common.requests, 'Session', lambda: session) + monkeypatch.setattr(common, '_version_command', lambda _: pytest.fail('No package or source fallback')) + assert common.update_cur_tool_version('rmon-server')['current_version'] == expected + assert session.trust_env is False + kwargs = session.get.call_args.kwargs + assert kwargs['cert'] == ('/tls/ui.crt', '/tls/ui.key') + assert kwargs['verify'] == '/tls/ca.crt' + assert kwargs['headers']['Authorization'] == 'Bearer test-only-token' + assert kwargs['allow_redirects'] is False + + +@pytest.mark.parametrize('url', ['ftp://localhost:5100', 'https://user:secret@localhost', 'https://localhost?token=secret']) +def test_container_version_rejects_unsafe_url(monkeypatch, url): + monkeypatch.setenv('RMON_SERVER_INTERNAL_URL', url) + monkeypatch.setattr(common.requests, 'Session', lambda: pytest.fail('Request must not run')) + assert common._server_runtime_version() is None + + +@pytest.mark.parametrize('scheme,ca', [('http', ''), ('https', ''), ('https', '/tls/self-signed-server.crt')]) +def test_container_version_supports_http_and_https_without_client_certificate(monkeypatch, tmp_path, scheme, ca): + token = tmp_path / 'token' + token.write_text('test-only-token') + monkeypatch.setenv('RMON_SERVER_INTERNAL_URL', f'{scheme}://localhost:5100') + monkeypatch.setenv('RMON_SERVER_INTERNAL_TOKEN_FILE', str(token)) + monkeypatch.setenv('RMON_SERVER_CA_FILE', ca) + monkeypatch.delenv('RMON_SERVER_CLIENT_CERT_FILE', raising=False) + monkeypatch.delenv('RMON_SERVER_CLIENT_KEY_FILE', raising=False) + session = MagicMock() + session.__enter__.return_value = session + response = session.get.return_value.__enter__.return_value + response.status_code = 200 + response.json.return_value = {'service': 'rmon-server', 'version': '7.0'} + monkeypatch.setattr(common.requests, 'Session', lambda: session) + assert common._server_runtime_version() == '7.0' + assert session.get.call_args.kwargs['cert'] is None + assert session.get.call_args.kwargs['verify'] == (ca or True) + assert session.get.call_args.kwargs['allow_redirects'] is False + + +@pytest.mark.parametrize('scheme,cert,key', [('http', 'client.crt', 'client.key'), + ('https', 'client.crt', ''), ('https', '', 'client.key')]) +def test_container_version_rejects_incomplete_or_plaintext_client_identity(monkeypatch, tmp_path, scheme, cert, key): + token = tmp_path / 'token' + token.write_text('test-only-token') + monkeypatch.setenv('RMON_SERVER_INTERNAL_URL', f'{scheme}://localhost:5100') + monkeypatch.setenv('RMON_SERVER_INTERNAL_TOKEN_FILE', str(token)) + monkeypatch.setenv('RMON_SERVER_CLIENT_CERT_FILE', cert) + monkeypatch.setenv('RMON_SERVER_CLIENT_KEY_FILE', key) + monkeypatch.setattr(common.requests, 'Session', lambda: pytest.fail('Invalid client identity')) + assert common._server_runtime_version() is None + + +def test_container_cannot_be_overwritten_by_package_update(monkeypatch): + monkeypatch.setenv('RMON_SERVER_INTERNAL_URL', 'https://localhost:5100') + monkeypatch.setattr(common.server_mod, 'subprocess_execute', lambda _: pytest.fail('No package installation')) + with pytest.raises(ValueError, match='managed by Docker'): + common.update_roxy_wi('rmon-server') + + +@pytest.mark.parametrize('distro_name,package,expected', [ + ('ubuntu', 'install ok installed\t6.33-1', '6.33.1'), + ('debian', 'install ok installed\t1:6.33-2', '6.33.2'), + ('ubuntu', 'install ok installed\t1.0.2-3ubuntu1.1', '1.0.2'), + ('rocky', '1.0.2-14.el9', '1.0.2'), + ('rocky', '6.33-1', '6.33.1'), +]) +def test_exact_package_versions_for_deb_and_rpm(monkeypatch, version_probes, distro_name, package, expected): + monkeypatch.setattr(common.distro, 'id', lambda: distro_name) + calls = [] + def query(args): + calls.append(args) + return package + monkeypatch.setattr(common, '_version_command', query) + result = common.update_cur_tool_version('rmon-server') + assert result == {'current_version': expected, 'version_known': True, 'installed': True} + assert calls[0][-1] == 'rmon-server' + assert calls[0][0] == ('dpkg-query' if distro_name in ('ubuntu', 'debian') else 'rpm') + assert len(calls) == 1 + assert version_probes == [('rmon-server', expected)] + + +def test_source_install_reads_unit_working_directory_without_executing_code(monkeypatch, tmp_path, version_probes): + source = tmp_path / 'custom deployment' / 'modules' / 'common' + source.mkdir(parents=True) + (source / 'version.py').write_text('raise RuntimeError("Must never execute")\nSERVICE_VERSION = "6.33"\n') + monkeypatch.setattr(common, '_version_command', lambda args: + None if args[0] == 'dpkg-query' else f'LoadState=loaded\nWorkingDirectory={source.parents[1]}') + assert common.update_cur_tool_version('rmon-server') == { + 'current_version': '6.33', 'version_known': True, 'installed': True} + assert version_probes == [('rmon-server', '6.33')] + + +@pytest.mark.parametrize('content', [ + 'SERVICE_VERSION = read_secret()', + 'SERVICE_VERSION = "error"', + 'SERVICE_VERSION = ', + 'SERVICE_VERSION = 6.33', + '#' * 65537, +], ids=['expression', 'html', 'syntax', 'number', 'oversized']) +def test_invalid_source_metadata_is_not_a_version(tmp_path, content): + source = tmp_path / 'modules' / 'common' + source.mkdir(parents=True) + (source / 'version.py').write_text(content) + assert common._source_version(str(tmp_path)) is None + + +@pytest.mark.parametrize('properties,installed', [ + ('LoadState=not-found\nWorkingDirectory=', False), + ('LoadState=loaded\nWorkingDirectory=/nonexistent-rmon-test-path', True), + (None, None), +]) +def test_missing_package_is_not_confused_with_missing_or_stopped_source_service(monkeypatch, version_probes, properties, installed): + monkeypatch.setattr(common, '_version_command', lambda args: + 'deinstall ok config-files\t6.33' if args[0] == 'dpkg-query' else properties) + assert common.update_cur_tool_version('rmon-server') == { + 'current_version': '0', 'version_known': False, 'installed': installed} + + +def test_command_uses_argument_list_timeout_and_exit_status(monkeypatch): + calls = [] + def run(args, **kwargs): + calls.append((args, kwargs)) + return SimpleNamespace(returncode=0, stdout=' 6.33\n') + monkeypatch.setattr(common.subprocess, 'run', run) + assert common._version_command(['rpm', '-q', 'rmon-server']) == '6.33' + assert calls[0][1]['timeout'] == 3 + assert not calls[0][1].get('shell') + monkeypatch.setattr(common.subprocess, 'run', lambda *args, **kwargs: SimpleNamespace(returncode=1, stdout='6.33')) + assert common._version_command(['rpm']) is None + + +@pytest.mark.parametrize('error', [FileNotFoundError(), common.subprocess.TimeoutExpired('rpm', 3)]) +def test_unavailable_package_manager_is_handled(monkeypatch, error): + def run(*args, **kwargs): + raise error + monkeypatch.setattr(common.subprocess, 'run', run) + assert common._version_command(['rpm']) is None + + +def test_service_name_cannot_inject_commands(monkeypatch, version_probes): + monkeypatch.setattr(common, '_version_command', lambda _: pytest.fail('Command must not run')) + with pytest.raises(ValueError): + common.update_cur_tool_version('rmon-server; touch /tmp/unexpected') + assert not version_probes + + +@pytest.mark.parametrize('current,latest,expected', [ + ('6.9', '6.33', True), ('6.33', '6.9', False), ('6.33', '6.33', False), + ('1.9', '1.10', True), ('0', '6.34', False), ('6.33', '', False), +]) +def test_refresh_is_visible_on_first_request_and_versions_are_not_compared_as_floats(monkeypatch, current, latest, expected): + stored = {'rmon-server': {'current_version': '0', 'new_version': latest}} + monkeypatch.setattr(common.roxy_sql, 'get_all_tools', lambda: copy.deepcopy(stored)) + monkeypatch.setattr(common, 'is_tool_active', lambda _: 'active') + def refresh(name): + stored[name]['current_version'] = current + return {'current_version': current, 'version_known': current != '0', 'installed': True} + monkeypatch.setattr(common, 'update_cur_tool_version', refresh) + result = common.get_services_status(update_cur_ver=1) + assert result[0][2]['current_version'] == current + assert result[0][2]['update_available'] is expected + + +def test_update_tab_refreshes_installed_versions(client, auth_headers, monkeypatch): + calls = [] + monkeypatch.setattr('app.modules.roxywi.roxy.versions', + lambda: {'current_ver': '1.4.0', 'new_ver': '1.3.0', 'need_update': False}) + monkeypatch.setattr(common, 'get_services_status', lambda **kwargs: calls.append(kwargs) or []) + assert client.get('/admin/update', headers=auth_headers(1, 1)).status_code == 200 + assert calls == [{'update_cur_ver': 1}] diff --git a/tests/helm/cluster_smoke.py b/tests/helm/cluster_smoke.py new file mode 100644 index 0000000..711e690 --- /dev/null +++ b/tests/helm/cluster_smoke.py @@ -0,0 +1,157 @@ +"""Opt-in end-to-end test in a disposable Kind cluster owned by CI.""" +import base64 +import json +import os +from pathlib import Path +import secrets +import subprocess +import tempfile + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +NS = 'rmon-helm-test' +SELECTOR = 'app.kubernetes.io/instance=smoke' + + +def run(*args, input=None, timeout=900): + result = subprocess.run(args, input=input, text=True, capture_output=True, timeout=timeout) + if result.returncode: + raise RuntimeError(f'{args[0:3]} failed:\n{result.stdout[-3000:]}\n{result.stderr[-3000:]}') + return result.stdout + + +def kube(*args, **kwargs): + return run('kubectl', '-n', NS, *args, **kwargs) + + +def secret(name, data): + kube('apply', '-f', '-', input=json.dumps({'apiVersion': 'v1', 'kind': 'Secret', + 'metadata': {'name': name}, 'stringData': data})) + + +def execute(role, code): + interpreter = 'python' if role == 'server' else '/opt/rmon-venv/bin/python' + return kube('exec', '-i', 'deployment/smoke-rmon-chart-' + role, '-c', role, '--', interpreter, '-', input=code) + + +def verify(password, expected=None): + result = execute('web', f''' +import hashlib, json, os, sqlite3, urllib.request +from pathlib import Path +opener = urllib.request.build_opener(urllib.request.ProxyHandler({{}})) +base = 'http://smoke-rmon-chart-web:8080' +assert opener.open(base + '/login', timeout=15).status == 200 +request = urllib.request.Request(base + '/login', data=json.dumps({{'login': 'admin', 'pass': {password!r}}}).encode(), headers={{'Content-Type': 'application/json'}}) +with opener.open(request, timeout=45) as response: + assert json.load(response)['status'] == 'done' +os.environ['RMON_SCHEDULER_ENABLED'] = '0' +from app.modules.tools.common import _server_runtime_version +assert _server_runtime_version() == '7.0' +db = sqlite3.connect('/var/lib/rmon/rmon.db') +db.execute('CREATE TABLE IF NOT EXISTS helm_persistence_test (id INTEGER PRIMARY KEY, value TEXT)') +db.execute("INSERT OR IGNORE INTO helm_persistence_test VALUES (1, 'preserved')") +db.commit() +assert db.execute('SELECT value FROM helm_persistence_test').fetchone() == ('preserved',) +keys = {{p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in Path('/var/lib/rmon/keys').iterdir() if p.is_file()}} +assert 'rmon-key' in keys and 'rmon-key.pub' in keys +print(json.dumps(keys, sort_keys=True)) +''') + fingerprint = json.loads(result.strip().splitlines()[-1]) + if expected is not None: + assert fingerprint == expected, 'Application keys changed after pod replacement' + pods = json.loads(kube('get', 'pods', '-l', SELECTOR, '-o', 'json'))['items'] + running = [p for p in pods if p['metadata']['labels']['app.kubernetes.io/component'] != 'migration'] + assert len(running) == 4 + assert len({p['spec']['nodeName'] for p in running}) == 1, 'RWO pods must share a node' + for role in ('scheduler', 'operations'): + kube('exec', 'deployment/smoke-rmon-chart-' + role, '--', '/opt/rmon-venv/bin/python', '-m', + 'container.runtime', 'healthcheck', '--role', role) + kube('exec', 'deployment/smoke-rmon-chart-server', '--', 'python', '-m', 'modules.common.probe', 'ready') + return fingerprint + + +def main(): + # Refuse arbitrary clusters, including a developer's current production context. + assert run('kubectl', 'config', 'current-context').strip() == 'kind-rmon-chart-test' + run('kubectl', 'create', 'namespace', NS) + password = secrets.token_urlsafe(24) + secret('initial-password', {'admin-password': password}) + secret('receiver-token', {'token': secrets.token_urlsafe(32)}) + values = {'publicURL': 'http://smoke-rmon-chart-web:8080', 'cookieSecure': False, + 'config': {'main': {'secret_phrase': base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()}}, + 'image': {'repository': 'rmon-web-test', 'tag': 'ci', 'pullPolicy': 'Never'}, + 'bootstrap': {'enabled': True, 'existingSecret': 'initial-password'}, + 'server': {'existingTokenSecret': 'receiver-token', 'image': {'pullPolicy': 'Never'}}, + 'persistence': {'size': '1Gi'}} + with tempfile.TemporaryDirectory(prefix='rmon-chart-') as directory: + directory = Path(directory) + settings = directory / 'values.yaml' + settings.write_text(yaml.safe_dump(values), encoding='utf-8') + settings.chmod(0o600) + + def upgrade(*options): + return run('helm', 'upgrade', '--install', 'smoke', str(ROOT / 'helm/rmon'), '-n', NS, + '-f', str(settings), '--wait', '--wait-for-jobs', '--timeout', '10m', *options) + + upgrade() + before = verify(password) + print('Fresh installation: login, all services, receiver API and RWO placement passed', flush=True) + # A new initialization password must not reset an existing administrator. + secret('initial-password', {'admin-password': secrets.token_urlsafe(24)}) + kube('delete', 'pods', '-l', SELECTOR, '--wait=true') + kube('wait', 'deployment', '-l', SELECTOR, '--for=condition=Available', '--timeout=600s') + # rollout status also observes the new deployment generation/readiness. + for role in ('web', 'scheduler', 'operations', 'server'): + kube('rollout', 'status', 'deployment/smoke-rmon-chart-' + role, '--timeout=600s') + verify(password, before) + print('Pod replacement: original login, database marker and application keys preserved', flush=True) + upgrade('--set', 'maintenance=true', '--set', 'bootstrap.enabled=false') + kube('wait', 'pod', '-l', SELECTOR, '--for=delete', '--timeout=180s') + upgrade('--set', 'maintenance=true', '--set', 'bootstrap.enabled=false', '--set', 'migration.enabled=true') + jobs = json.loads(kube('get', 'jobs', '-l', SELECTOR, '-o', 'json'))['items'] + assert len(jobs) == 1 and jobs[0]['status'].get('succeeded') == 1 + upgrade('--set', 'bootstrap.enabled=false') + verify(password, before) + print('Explicit maintenance and app/migrate.py job passed', flush=True) + # Exercise actual certificate mounts and authenticated probes, not only rendering. + run('openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', str(directory / 'ca.key'), + '-out', str(directory / 'ca.crt'), '-subj', '/CN=RMON CI CA', '-days', '1') + for name, usage in [('server', 'serverAuth'), ('client', 'clientAuth')]: + run('openssl', 'req', '-newkey', 'rsa:2048', '-nodes', '-keyout', str(directory / f'{name}.key'), + '-out', str(directory / f'{name}.csr'), '-subj', f'/CN={name}') + extension = directory / f'{name}.ext' + extension.write_text(f'extendedKeyUsage={usage}\nsubjectAltName=DNS:smoke-rmon-chart-server,IP:127.0.0.1\n') + run('openssl', 'x509', '-req', '-in', str(directory / f'{name}.csr'), '-CA', str(directory / 'ca.crt'), + '-CAkey', str(directory / 'ca.key'), '-CAcreateserial', '-out', str(directory / f'{name}.crt'), + '-days', '1', '-extfile', str(extension)) + secret('tls-' + name, {'tls.crt': (directory / f'{name}.crt').read_text(), + 'tls.key': (directory / f'{name}.key').read_text(), + 'ca.crt': (directory / 'ca.crt').read_text()}) + for mode in ('https', 'mtls'): + upgrade('--set', 'bootstrap.enabled=false', '--set', 'server.transport=' + mode, + '--set', 'server.tls.existingServerSecret=tls-server', + '--set', 'server.tls.existingClientSecret=tls-client') + verify(password, before) + if mode == 'mtls': + execute('web', ''' +import ssl, urllib.request, urllib.error +context = ssl.create_default_context(cafile='/run/rmon-client-tls/ca.crt') +opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), urllib.request.HTTPSHandler(context=context)) +try: + opener.open('https://smoke-rmon-chart-server:5100/internal/health/live', timeout=5) +except urllib.error.HTTPError: + raise AssertionError('mTLS accepted a connection without a client certificate') +except (urllib.error.URLError, ssl.SSLError, ConnectionError): + pass +else: + raise AssertionError('mTLS did not require a client certificate') +''') + print(mode.upper() + ': receiver API and authenticated probes passed', flush=True) + run('helm', 'uninstall', 'smoke', '-n', NS, '--wait') + assert kube('get', 'pvc', 'smoke-rmon-chart-data', '-o', 'name').strip() + print('Uninstall preserved the data volume', flush=True) + + +if __name__ == '__main__': + main() diff --git a/tests/helm/validate.py b/tests/helm/validate.py new file mode 100644 index 0000000..b7a431b --- /dev/null +++ b/tests/helm/validate.py @@ -0,0 +1,105 @@ +"""Render actual chart variants with Helm and validate their Kubernetes contracts.""" +import os +from pathlib import Path +import subprocess +import tempfile + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +HELM = os.getenv('RMON_TEST_HELM_BINARY', 'helm') +BASE = {'publicURL': 'https://rmon.example.test', 'existingConfigSecret': 'settings', + 'server': {'existingTokenSecret': 'receiver'}} + + +def render(values, *, valid=True): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'values.yaml' + path.write_text(yaml.safe_dump(values), encoding='utf-8') + result = subprocess.run([HELM, 'template', 'test', str(ROOT / 'helm/rmon'), '-f', str(path)], + text=True, capture_output=True, check=False) + if not valid: + assert result.returncode != 0, values + return + assert result.returncode == 0, result.stderr + return [doc for doc in yaml.safe_load_all(result.stdout) if doc] + + +def deployments(docs): + return {d['metadata']['labels']['app.kubernetes.io/component']: d for d in docs if d['kind'] == 'Deployment'} + + +def main(): + docs = render(BASE) + apps = deployments(docs) + assert set(apps) == {'web', 'scheduler', 'operations', 'server'} + assert not any(d['kind'] == 'Secret' for d in docs) + for role, app in apps.items(): + assert app['metadata']['name'] == 'test-rmon-chart-' + role + assert app['spec']['selector']['matchLabels'] == { + 'app.kubernetes.io/name': 'rmon-chart', + 'app.kubernetes.io/instance': 'test', + 'app.kubernetes.io/component': role, + } + assert app['spec']['replicas'] == 1 + assert app['spec']['strategy']['type'] == 'Recreate' + pod = app['spec']['template']['spec'] + assert not pod['automountServiceAccountToken'] + assert pod['securityContext']['fsGroup'] == 33 + assert 'affinity' in pod + container = pod['containers'][0] + expected_image = ('ghcr.io/roxy-wi/rmon/rmon-server:7.0' if role == 'server' + else 'ghcr.io/roxy-wi/rmon/rmon-web:1.4.0') + assert container['image'] == expected_image + if role != 'server': + env = {v['name']: v.get('value') for v in container['env']} + assert env['RMON_AGENT_IMAGE'] == 'ghcr.io/roxy-wi/rmon/rmon-agent:2.0' + assert all(p in container for p in ('livenessProbe', 'readinessProbe', 'startupProbe')) + volumes = {v['name'] for v in pod['volumes']} + assert all(m['name'] in volumes for m in container['volumeMounts']) + claim = next(d for d in docs if d['kind'] == 'PersistentVolumeClaim') + assert claim['metadata']['name'] == 'test-rmon-chart-data' + assert apps['operations']['spec']['template']['spec']['terminationGracePeriodSeconds'] == 1800 + maintenance = deployments(render({**BASE, 'maintenance': True})) + assert all(a['spec']['replicas'] == 0 for a in maintenance.values()) + migration = render({**BASE, 'maintenance': True, 'migration': {'enabled': True}}) + job = next(d for d in migration if d['kind'] == 'Job') + assert job['spec']['template']['spec']['containers'][0]['args'] == ['/var/www/rmon/app/migrate.py', 'migrate'] + assert 'affinity' not in job['spec']['template']['spec'] + config = {'main': {'secret_phrase': 'A' * 43 + '='}, 'pgsql': {'enable': 1, 'host': 'db.example'}} + docs = render({**BASE, 'existingConfigSecret': '', 'config': config, + 'persistence': {'accessModes': ['ReadWriteMany']}, + 'bootstrap': {'enabled': True, 'existingSecret': 'initial-password'}, + 'ingress': {'enabled': True, 'host': 'rmon.example.test', 'tls': [{'secretName': 'web-tls', 'hosts': ['rmon.example.test']}]}}) + secret = next(d for d in docs if d['kind'] == 'Secret') + assert 'port = 5432' in secret['stringData']['rmon.cfg'] + assert all('affinity' not in d['spec']['template']['spec'] for d in deployments(docs).values()) + assert deployments(docs)['web']['spec']['template']['spec']['initContainers'][0]['args'] == ['bootstrap'] + assert any(d['kind'] == 'Ingress' for d in docs) + for mode in ('https', 'mtls'): + docs = render({**BASE, 'server': {**BASE['server'], 'transport': mode, + 'tls': {'existingServerSecret': 'identity', 'existingClientSecret': 'client'}}}) + for role, app in deployments(docs).items(): + volumes = app['spec']['template']['spec']['volumes'] + assert any(v['name'] == 'server-tls' for v in volumes) == (role == 'server') + assert any(v['name'] == 'client-tls' for v in volumes) + docs = render({**BASE, 'server': {'enabled': False, 'externalURL': 'https://receiver.test', 'existingTokenSecret': 'token'}, + 'persistence': {'existingClaim': 'restored-data'}, + 'image': {'digest': 'sha256:' + 'a' * 64}}) + assert 'server' not in deployments(docs) + assert not any(d['kind'] == 'PersistentVolumeClaim' for d in docs) + assert '@sha256:' in deployments(docs)['web']['spec']['template']['spec']['containers'][0]['image'] + for invalid in ( + {}, {**BASE, 'publicURL': ''}, {**BASE, 'web': {'replicaCount': 2}}, + {**BASE, 'migration': {'enabled': True}}, + {**BASE, 'bootstrap': {'enabled': True}}, {**BASE, 'server': {'transport': 'mtls'}}, + {**BASE, 'persistence': {'accessModes': ['ReadWriteOncePod']}}, + {**BASE, 'existingConfigSecret': '', 'config': {'main': {'secret_phrase': 'short'}}}, + {**BASE, 'existingConfigSecret': '', 'config': {'main': {'secret_phrase': 'A' * 43 + '='}}, 'persistence': {'accessModes': ['ReadWriteMany']}}, + ): + render(invalid, valid=False) + print('Helm rendering: default, bootstrap, maintenance, PostgreSQL/RWX, ingress, HTTPS/mTLS, external server, restored PVC, digest and invalid settings passed') + + +if __name__ == '__main__': + main() diff --git a/tests/operations/database_smoke.py b/tests/operations/database_smoke.py new file mode 100644 index 0000000..68893ab --- /dev/null +++ b/tests/operations/database_smoke.py @@ -0,0 +1,150 @@ +"""Exercise the real queue against disposable, private PostgreSQL/MariaDB containers.""" +import argparse +import concurrent.futures +from datetime import datetime, timedelta +import importlib.util +import os +from pathlib import Path +import subprocess +import time +from uuid import uuid4 + + +def inside(backend): + root = Path('/tmp/rmon-queue-contract') + root.mkdir() + (root / 'prometheus').mkdir() + config = root / 'rmon.cfg' + config.write_text(f'''[main] +lib_path = {root} +log_path = {root} +secret_phrase = E2nCq8NnECvPQ5zUQntL_-Nt-qBncYkrEmMkYGzVpyM= +[mysql] +enable = {int(backend == 'mysql')} +mysql_host = database +mysql_port = 3306 +mysql_user = root +mysql_password = +mysql_db = rmon_queue_test +[pgsql] +enable = {int(backend == 'postgres')} +host = database +port = 5432 +user = rmon_test +password = +db = rmon_queue_test +''') + os.environ.update(RMON_TESTING='1', RMON_SCHEDULER_ENABLED='0', RMON_CONFIG_FILE=str(config), + RMON_SECRET_KEY='queue-contract-test-only-secret-key-32-chars', + RMON_JWT_ALGORITHM='HS256', RMON_JWT_SECRET_KEY='queue-contract-test-only-jwt-key-32-chars', + RMON_PROMETHEUS_MULTIPROC_DIR=str(root / 'prometheus')) + from app.modules.db.db_model import Groups, User, Server, InstallationTasks, OperationJob, OperationLock, conn + from app.modules.operations import queue + assert conn.database == 'rmon_queue_test' + deadline = time.monotonic() + 120 + while True: + try: + conn.connect(reuse_if_open=True) + break + except Exception: + if time.monotonic() >= deadline: + raise + time.sleep(2) + assert not conn.get_tables(), 'Use a fresh disposable test database' + conn.create_tables([Groups, User, Server, InstallationTasks]) + spec = importlib.util.spec_from_file_location('queue_migration', + Path('/var/www/rmon/app/migrations/20260916000000_add_operation_queue.py')) + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + migration.upgrade() + migration.upgrade() + group = Groups.create(name='Queue test') + user = User.create(username='queue-test', email='queue@example.test', role='1', group_id=group.group_id) + server = Server.create(hostname='queue-test', ip='192.0.2.10', group_id=str(group.group_id)) + inventory = {'server': {'hosts': {server.ip: {'action': 'start', 'secret': 'test-only-value'}}}} + def enqueue(): + return queue.enqueue(inventory, [server.ip], 'rmon_agent', 'Agent', 'start', user.user_id) + first, second = enqueue(), enqueue() + assert 'test-only-value' not in OperationJob.get_by_id(first).payload + def claim(): + try: + return queue.claim() + finally: + conn.close() + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool: + claims = [result for result in pool.map(lambda _: claim(), range(6)) if result] + assert len(claims) == 1 and claims[0][0] == first + assert queue.load_payload(*claims[0])['inventory'] == inventory + assert queue.renew(*claims[0]) + assert queue.renew(*claims[0]) + assert queue.finish(*claims[0]) + previous = queue.claim() + assert previous[0] == second + OperationJob.update(lease_until=queue.database_time() - timedelta(seconds=1)).where(OperationJob.task == second).execute() + assert not queue.renew(*previous) + assert not queue.finish(*previous) + third = enqueue() + assert queue.recover_expired() == 1 + assert queue.claim() is None + OperationJob.update(available_at=queue.database_time()).where(OperationJob.task == second).execute() + replacement = queue.claim() + assert replacement[0] == second and replacement[1] != previous[1] + assert not queue.finish(*previous) + assert queue.finish(*replacement) + InstallationTasks.update(finish_date=datetime.now() - timedelta(days=31)).where( + InstallationTasks.id.in_([first, second])).execute() + assert queue.cleanup(batch_size=1) == 2 + assert not OperationJob.select().where(OperationJob.task.in_([first, second])).exists() + assert queue.claim()[0] == third + assert queue.cleanup() == 0 + assert OperationLock.get_by_id(server.server_id).owner + try: + migration.downgrade() + except RuntimeError: + pass + else: + raise AssertionError('Rollback removed a running operation') + print(f'{backend}: migration, concurrent claims, FIFO, leases, recovery and cleanup passed', flush=True) + + +def outside(image, source=None, selected_backend=None): + def docker(*args, check=True, timeout=300): + return subprocess.run(['docker', *args], check=check, capture_output=True, text=True, timeout=timeout) + for backend, db_image in [('postgres', 'postgres:16-alpine'), ('mysql', 'mariadb:11.4')]: + if selected_backend and backend != selected_backend: + continue + print(f'{backend}: starting database queue check', flush=True) + name = 'rmon-queue-test-' + uuid4().hex[:12] + database, client = name + '-db', name + '-client' + try: + docker('pull', db_image) + docker('network', 'create', '--internal', name) + env = (['-e', 'POSTGRES_HOST_AUTH_METHOD=trust', '-e', 'POSTGRES_USER=rmon_test', '-e', 'POSTGRES_DB=rmon_queue_test'] + if backend == 'postgres' else ['-e', 'MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1', '-e', 'MARIADB_DATABASE=rmon_queue_test']) + settings = (['-c', 'shared_buffers=16MB', '-c', 'max_connections=20'] if backend == 'postgres' + else ['--innodb-buffer-pool-size=32M', '--performance-schema=OFF', '--max-connections=20']) + docker('run', '-d', '--name', database, '--network', name, '--network-alias', 'database', + '--memory', '192m', '--memory-swap', '384m', *env, db_image, *settings) + mounts = ['-v', str(Path(__file__).resolve()) + ':/queue-smoke.py:ro'] + if source: + mounts += ['-v', str(Path(source).resolve()) + ':/var/www/rmon:ro'] + result = docker('run', '--name', client, '--network', name, '--memory', '128m', '--memory-swap', '256m', + *mounts, '-e', 'PYTHONPATH=/var/www/rmon', '--entrypoint', 'python', + image, '/queue-smoke.py', '--inside', backend, check=False) + print(result.stdout, end='', flush=True) + if result.returncode: + print(result.stderr, flush=True) + raise RuntimeError(f'{backend} queue contract failed') + finally: + docker('rm', '-fv', client, database, check=False) + docker('network', 'rm', name, check=False) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--inside', choices=['postgres', 'mysql']) + parser.add_argument('--image', default='rmon-web:test') + parser.add_argument('--source') + parser.add_argument('--backend', choices=['postgres', 'mysql']) + args = parser.parse_args() + inside(args.inside) if args.inside else outside(args.image, args.source, args.backend) diff --git a/tests/security/test_auth_and_scheduler.py b/tests/security/test_auth_and_scheduler.py index 35c04c9..1b5a8fe 100644 --- a/tests/security/test_auth_and_scheduler.py +++ b/tests/security/test_auth_and_scheduler.py @@ -31,6 +31,6 @@ def test_protected_api_requires_a_token(client): def test_dedicated_scheduler_runner_matches_application_configuration(): source = (PROJECT_ROOT / 'scheduler_runner.py').read_text(encoding='utf-8') - assert "os.environ.setdefault('RMON_SCHEDULER_ENABLED', '1')" in source + assert "os.environ['RMON_SCHEDULER_ENABLED'] = '1'" in source assert "from app import scheduler" in source assert 'threading.Event().wait()' in source diff --git a/tests/security/test_command_and_ssrf.py b/tests/security/test_command_and_ssrf.py index bab0092..f7abf61 100644 --- a/tests/security/test_command_and_ssrf.py +++ b/tests/security/test_command_and_ssrf.py @@ -42,7 +42,7 @@ def test_agent_http_request_ignores_caller_supplied_host(monkeypatch): def get(url, **kwargs): captured['url'] = url captured['kwargs'] = kwargs - return SimpleNamespace(content=b'ok') + return SimpleNamespace(content=b'ok', raise_for_status=lambda: None, close=lambda: None) monkeypatch.setattr(smon_agent, 'get_agent_headers', lambda _agent_id: {'Agent-UUID': 'uuid'}) monkeypatch.setattr(smon_agent.smon_sql, 'get_agent_data', lambda _agent_id: SimpleNamespace(port=5101)) diff --git a/tests/ui/test_admin_tables.py b/tests/ui/test_admin_tables.py new file mode 100644 index 0000000..bd5a1dc --- /dev/null +++ b/tests/ui/test_admin_tables.py @@ -0,0 +1,180 @@ +from html.parser import HTMLParser +from types import SimpleNamespace + +import pytest +from flask import g, render_template + + +class Rows(HTMLParser): + def __init__(self, html): + super().__init__() + self.rows = [] + self.cell = None + self.feed(html) + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == 'tr': + self.rows.append([]) + if tag in ('th', 'td'): + self.cell = {'attrs': attrs, 'actions': [], 'text': ''} + self.rows[-1].append(self.cell) + if self.cell is not None and (tag == 'button' or attrs.get('onclick') or tag == 'a'): + self.cell['actions'].append((tag, attrs)) + + def handle_endtag(self, tag): + if tag in ('th', 'td'): + self.cell = None + + def handle_data(self, data): + if self.cell is not None: + self.cell['text'] += data + + +def render(app, template, *, role=1, language='en', **context): + with app.test_request_context('/admin'): + g.user_params = {'role': role, 'group_id': 1} + lang = language if template.startswith('ajax/load_') else app.jinja_env.get_template( + f'languages/{language}.html').module + return render_template(template, lang=lang, **context) + + +def assert_actions(rows, columns): + assert rows + for row in rows: + assert len(row) == columns + assert 'actions-column' in row[-1]['attrs'].get('class', '') + # Editable fields are data; non-data buttons and links belong to Actions. + assert not any(cell['actions'] for cell in row[:-1]) + + +@pytest.mark.ui +@pytest.mark.parametrize('role,columns', [(1, 5), (2, 6)]) +@pytest.mark.parametrize('language', ['en', 'ru', 'fr', 'pt-br']) +def test_users_have_one_actions_column_and_keep_field_ids(app, role, columns, language): + users = [SimpleNamespace(user_id=9, username='alice', email='alice@example.test', enabled=1, ldap_user=0)] + html = render(app, 'include/admin_users.html', role=role, language=language, users=users, + users_roles=[], user_roles=[], roles=[]) + rows = Rows(html).rows + assert_actions(rows, columns) + assert rows[0][-1]['text'].strip() == 'Actions' + for field in ['login-9', 'enabled-9', 'email-9']: + assert f'id="{field}"' in html + assert 'openChangeUserPasswordDialog' in html + assert ('confirmChangeGroupsAndRoles' in html) == (role == 1) + fragment = render(app, 'include/admin_users.html', role=role, language=language, adding=True, + users=users, users_roles=[], user_roles=[], roles=[]) + assert_actions(Rows(fragment).rows, columns) + + +@pytest.mark.ui +def test_ldap_and_protected_superadmin_do_not_get_password_actions(app): + user = SimpleNamespace(user_id=9, username='alice', email='alice@example.test', enabled=1, ldap_user=1) + html = render(app, 'include/admin_users.html', users=[user], users_roles=[], user_roles=[], roles=[]) + assert 'openChangeUserPasswordDialog' not in html + user.ldap_user = 0 + html = render(app, 'include/admin_users.html', role=2, users=[user], + users_roles=[SimpleNamespace(user_role_id=1, user_id=9)], user_roles=[], roles=[]) + assert 'openChangeUserPasswordDialog' not in html + assert 'confirmChangeGroupsAndRoles' not in html + + +@pytest.mark.ui +@pytest.mark.parametrize('role,columns', [(1, 9), (2, 8)]) +def test_server_actions_share_a_column_in_full_page_and_ajax_fragment(app, role, columns): + context = {'servers': [(9, 'test-host', '192.0.2.9', '1', 1, 1, None, 22, 'Test')], + 'groups': [], 'sshs': []} + for adding in [False, True]: + html = render(app, 'include/admin_servers.html', role=role, adding=adding, **context) + rows = Rows(html).rows + assert all(len(row) == columns for row in rows) + action_cell = rows[-1][-1] + assert 'actions-column' in action_cell['attrs']['class'] + assert len(action_cell['actions']) == 6 + for handler in ['checkSshConnect', 'viewFirewallRules', 'showServerInfo', 'cloneServer', 'confirmDeleteServer']: + assert any(handler in attrs.get('onclick', '') for _, attrs in action_cell['actions']) + for field in ['hostname-9', 'ip-9', 'port-9', 'server_enabled-9', 'credentials-9', 'desc-9', + 'server_info_link-9', 'clone-9']: + assert f'id="{field}"' in html + + +@pytest.mark.ui +@pytest.mark.parametrize('shared,group,action_count', [(0, 1, 1), (1, 1, 1), (1, 2, 0)]) +def test_ssh_delete_menu_respects_shared_credential_ownership(app, shared, group, action_count): + ssh = SimpleNamespace(id=9, name='test-ssh', key_enabled=1, group_id=group, shared=shared, username='ubuntu') + html = render(app, 'ajax/new_ssh.html', sshs=[ssh], groups=[]) + rows = Rows(html).rows + assert_actions(rows, 6) + assert len(rows[0][-1]['actions']) == action_count + + +@pytest.mark.ui +def test_services_have_one_actions_column_and_native_disabled_controls(app): + services = [('rmon-server', 'active', {'current_version': '6.33'}), + ('fail2ban', 'inactive', {'current_version': '1.0'})] + html = render(app, 'ajax/load_services.html', services=services) + rows = Rows(html).rows + assert_actions(rows, 4) + assert 'disabled' in rows[0][-1]['actions'][0][1] + assert 'disabled' in rows[1][-1]['actions'][2][1] + missing = render(app, 'ajax/load_services.html', services=[ + ('fail2ban', 'missing', {'current_version': '* is not installed'})]) + assert len(Rows(missing).rows[0][-1]['actions']) == 1 + assert 'id="restart-rmon-server"' in html + + +@pytest.mark.ui +def test_update_rows_have_matching_columns_with_and_without_available_updates(app): + for need_update in [False, True]: + html = render(app, 'ajax/load_updateroxywi.html', + versions={'need_update': need_update, 'current_ver': '1.4.0', 'new_ver': '1.4.1'}, + services=[('rmon-server', 'active', {'current_version': '6.33', 'new_version': '6.34', 'update_available': True})]) + rows = Rows(html).rows + assert all(len(row) == 5 for row in rows) + assert len(rows[0][-1]['actions']) == int(need_update) + assert len(rows[1][-1]['actions']) == 1 + assert all('actions-column' in row[-1]['attrs']['class'] for row in rows) + + +@pytest.mark.ui +@pytest.mark.parametrize('language,missing,unknown', [ + ('en', 'Not installed', 'Version unavailable'), + ('ru', 'Не установлен', 'Версия недоступна'), + ('fr', 'Non installé', 'Version indisponible'), + ('pt-br', 'Não instalado', 'Versão indisponível'), +]) +@pytest.mark.parametrize('template', ['ajax/load_updateroxywi.html', 'ajax/load_services.html']) +def test_service_versions_distinguish_source_install_missing_and_unknown(app, language, missing, unknown, template): + def details(version, installed, update=False): + return {'current_version': version, 'new_version': '6.34', 'installed': installed, + 'version_known': version != '0', 'update_available': update} + kwargs = {'versions': {'current_ver': '1.4.0', 'new_ver': '1.3.0', 'need_update': False}} + html = render(app, template, language=language, services=[ + ('rmon-server', 'active', details('6.33', True, True)), + ], **kwargs) + assert '6.33' in html + assert "updateService('rmon-server', 'install')" not in html + html = render(app, template, language=language, + services=[('rmon-server', 'inactive', details('0', False))], **kwargs) + assert missing in html + assert "updateService('rmon-server', 'install')" in html + html = render(app, template, language=language, + services=[('rmon-server', 'active', details('0', True))], **kwargs) + assert unknown in html + assert "updateService('rmon-server', 'install')" not in html + + +@pytest.mark.ui +def test_admin_page_buttons_have_their_own_inset_toolbar(client, auth_headers): + html = client.get('/admin', headers=auth_headers(1, 1)).get_data(as_text=True) + assert html.count('class="admin-page-actions"') == 2 + + +@pytest.mark.ui +def test_container_update_links_to_docker_instead_of_installing_packages(app): + html = render(app, 'ajax/load_updateroxywi.html', + versions={'need_update': False, 'current_ver': '1.4.0', 'new_ver': '1.4.0'}, + services=[('rmon-server', 'active', {'current_version': '6.33', 'new_version': '6.34', + 'update_available': True, 'managed_by': 'docker', 'installed': True})]) + assert 'https://rmon.io/installation#standalone-server' in html + assert "updateService('rmon-server'" not in html diff --git a/tests/ui/test_pages.py b/tests/ui/test_pages.py index a468252..61e7b39 100644 --- a/tests/ui/test_pages.py +++ b/tests/ui/test_pages.py @@ -1,5 +1,6 @@ from collections import Counter from html.parser import HTMLParser +from pathlib import Path from types import SimpleNamespace from urllib.parse import parse_qs, urlsplit @@ -31,6 +32,15 @@ SUPPORTED_LANGUAGES = ('en', 'ru', 'fr', 'pt-br') +@pytest.mark.ui +@pytest.mark.parametrize('language', SUPPORTED_LANGUAGES) +def test_admin_settings_remain_available_without_broker_in_every_language(client, auth_headers, language): + client.set_cookie('lang', language) + document = _document(client.get('/admin', headers=auth_headers(1, 1))) + assert {'main-section-head', 'smon-section-head', 'mail-section-head', 'agent-section-head'} <= set(document.ids) + assert document.find(element_id='rabbitmq-section-head') is None + + class HtmlDocument(HTMLParser): def __init__(self): super().__init__(convert_charrefs=True) @@ -146,8 +156,10 @@ def test_authenticated_core_ui_pages_render( assert expected_ids <= set(document.ids) assert document.find(element_id='top-link') is not None - assert document.find(tag='input', element_id='user_group_socket')['value'] == '1' - assert document.find(tag='input', element_id='user_id_socket')['value'] == '1' + assert document.find(element_id='disable_alerting') is None + assert document.find(element_id='user_group_socket') is None + assert document.find(element_id='user_id_socket') is None + assert not any('reconnecting-websocket' in asset or 'ion.sound' in asset for asset in document.local_assets) for asset in document.local_assets: asset_response = client.get(asset) @@ -293,3 +305,101 @@ def test_ui_templates_and_language_catalogs_compile(app): assert app.jinja_env.get_template(template) for language in SUPPORTED_LANGUAGES: assert app.jinja_env.get_template(f'languages/{language}.html') + + +@pytest.mark.ui +@pytest.mark.parametrize('path', ('/login',) + PRIVATE_UI_PATHS) +def test_pages_support_mobile_viewport_and_load_shared_interactions(client, auth_headers, monkeypatch, path): + monkeypatch.setattr('app.modules.tools.common.is_tool_active', lambda _name: 'active') + document = _document(client.get(path, headers=auth_headers(1, 1))) + viewport = [a for a in document.find_all(tag='meta') if a.get('name') == 'viewport'] + assert len(viewport) == 1 + assert 'width=device-width' in viewport[0]['content'] + assert 'user-scalable=no' not in viewport[0]['content'] + assert {'/static/css/ux.css', '/static/js/ux.js'} <= document.local_assets + + +@pytest.mark.ui +def test_login_controls_have_labels_password_manager_hints_and_live_errors(client): + document = _document(client.get('/login')) + assert {'login', 'pass'} <= {a.get('for') for a in document.find_all(tag='label')} + assert document.find(element_id='login')['autocomplete'] == 'username' + assert document.find(element_id='pass')['autocomplete'] == 'current-password' + toggle, = document.find_all(tag='button', class_name='password-toggle') + assert toggle['type'] == 'button' + assert toggle['aria-controls'] == 'pass' + assert toggle['aria-pressed'] == 'false' + assert toggle['aria-label'] + assert document.find(element_id='wrong-login')['role'] == 'alert' + + +@pytest.mark.ui +def test_app_shell_exposes_keyboard_navigation_and_correct_overview_links(client, auth_headers): + document = _document(client.get('/overview', headers=auth_headers(1, 1))) + toggle = document.find(tag='button', element_id='menu-toggle') + assert document.find(tag='aside', element_id=toggle['aria-controls']) + assert document.find(tag='main', element_id='main-content')['tabindex'] == '-1' + assert document.find_all(tag='a', class_name='skip-link')[0]['href'] == '#main-content' + links = {a.get('href') for a in document.find_all(tag='a')} + assert {'/admin#servers', '/admin#users', '/admin#groups', '/admin#tools', '/logs/internal'} <= links + assert not any(link and link.startswith('/app/') for link in links) + assert document.find(tag='button', element_id='show-user-settings-button') + for button in document.find_all(tag='button', class_name='icon-button'): + assert button.get('aria-label') or button.get('id') == 'show-user-settings-button' + + +@pytest.mark.ui +def test_status_editor_preview_matches_public_route(client, auth_headers, monkeypatch): + monkeypatch.setattr('app.modules.tools.common.is_tool_active', lambda _name: 'active') + document = _document(client.get('/rmon/status-page', headers=auth_headers(1, 1))) + assert document.find(element_id='status-page-url-preview')['data-prefix'] == '/rmon/status/' + assert document.find(element_id='status-pages-empty') is not None + assert {'new-status-page-name', 'new-status-page-slug'} <= { + a.get('for') for a in document.find_all(tag='label') + } + + +@pytest.mark.ui +@pytest.mark.parametrize('language', SUPPORTED_LANGUAGES) +def test_ux_messages_are_translated_consistently(app, language): + with app.app_context(): + english = app.jinja_env.get_template('languages/en.html').module.ux + translated = app.jinja_env.get_template(f'languages/{language}.html').module.ux + assert translated.keys() == english.keys() + assert all(isinstance(message, str) and message.strip() for message in translated.values()) + + +@pytest.mark.ui +def test_check_editor_fixture_matches_the_real_rendered_template(app): + """Keep the DOM used by JavaScript workflow tests aligned with the actual form.""" + with app.test_request_context(): + env = app.jinja_env + macros = env.get_template('include/input_macros.html').module + rendered = env.get_template('include/smon/add_form.html').render( + lang=env.get_template('languages/en.html').module, chosen_lang='en', + input=macros.input, select=macros.select, checkbox=macros.checkbox, + telegrams=[], slacks=[], pds=[], mms=[], emails=[], incidentrelay=[], + ) + fixture = Path(__file__).resolve().parents[1] / 'frontend/fixtures/check-editor.html' + assert rendered.strip() == fixture.read_text(encoding='utf-8').strip() + document = HtmlDocument() + document.feed(rendered) + _assert_unique_ids(document, set(document.ids)) + assert len([a for a in document.find_all(tag='section') if a.get('role') == 'tabpanel']) == 3 + labels = {label.get('for') for label in document.find_all(tag='label')} + assert {'new-smon-name', 'new-smon-url', 'new-smon-place', 'check_type', + 'new-smon-port', 'new-smon-interval', 'new-smon-timeout'} <= labels + for tab in document.find_all(tag='button'): + if tab.get('role') == 'tab': + panel = document.find(element_id=tab['aria-controls']) + assert panel['aria-labelledby'] == tab['id'] + + +@pytest.mark.ui +@pytest.mark.parametrize('language', SUPPORTED_LANGUAGES) +def test_check_editor_messages_are_translated_consistently(app, language): + with app.app_context(): + english = app.jinja_env.get_template('languages/en.html').module.check_editor + translated = app.jinja_env.get_template(f'languages/{language}.html').module.check_editor + assert translated.keys() == english.keys() + assert all(isinstance(message, str) and message.strip() for message in translated.values())