printers low-toner alerts; geenforce display readiness + token scope-binding; air-gap docker deploy kit
This commit is contained in:
@@ -49,6 +49,10 @@ MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD
|
||||
MYSQL_PASSWORD=CHANGE_ME_APP_PASSWORD
|
||||
MYSQL_PORT=3306
|
||||
API_PORT=5001
|
||||
# Air-gapped deploy only (docker-compose.airgap.yml): the loaded image tag,
|
||||
# which MUST match what build-offline-bundle.ps1 -Version produced. Ignored by
|
||||
# the connected build template (docker-compose.yml builds from source).
|
||||
IMAGE_TAG=0.7.0
|
||||
|
||||
# ---- Zabbix integration (optional, for printer supply monitoring) ----
|
||||
ZABBIX_URL=
|
||||
|
||||
90
docker-compose.airgap.yml
Normal file
90
docker-compose.airgap.yml
Normal file
@@ -0,0 +1,90 @@
|
||||
# shopdb-flask AIR-GAPPED single-site stack.
|
||||
#
|
||||
# For a site with NO internet. Nothing is built or pulled here: the images are
|
||||
# built on a connected box (scripts/build-offline-bundle.ps1), shipped as a
|
||||
# tarball, and `docker load`ed at the site. This file only RUNS pre-loaded
|
||||
# images. See docs/DEPLOY-AIRGAP.md for the full runbook.
|
||||
#
|
||||
# Differences from docker-compose.yml (the connected/build template):
|
||||
# - api uses `image:` (a loaded image), never `build: .` (build needs the net).
|
||||
# - NO ./plugins bind mount. The image already carries every plugin baked in;
|
||||
# binding a host ./plugins (which does not exist at an image-only site) would
|
||||
# mask the baked plugins with an empty dir and load ZERO plugins.
|
||||
# - a one-shot `migrate` service runs db upgrade + plugin upgrade-all + seed
|
||||
# BEFORE api starts, so `up -d` alone brings up a working site (no manual
|
||||
# `docker compose exec ... flask db upgrade` to forget).
|
||||
#
|
||||
# Usage at the site:
|
||||
# docker load < shopdb-stack-<version>.tar.gz
|
||||
# cp .env.example .env # then edit: secrets, CORS_ORIGINS, IMAGE_TAG
|
||||
# docker compose -f docker-compose.airgap.yml up -d
|
||||
# docker compose -f docker-compose.airgap.yml exec api flask seed admin <user> <email> <password>
|
||||
|
||||
# Shared application environment, reused by the migrate one-shot and the api
|
||||
# service so the two never drift. A YAML anchor, not a container.
|
||||
x-app-env: &app-env
|
||||
FLASK_APP: wsgi.py
|
||||
FLASK_ENV: production
|
||||
DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set}
|
||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
ZABBIX_URL: ${ZABBIX_URL:-}
|
||||
ZABBIX_TOKEN: ${ZABBIX_TOKEN:-}
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mysql:8.0
|
||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set}
|
||||
MYSQL_DATABASE: shopdb_flask
|
||||
MYSQL_USER: shopdb
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
ports:
|
||||
- "127.0.0.1:${MYSQL_PORT:-3306}:3306"
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# One-shot schema + seed. Runs to completion and exits; api waits for it.
|
||||
# Every step is idempotent, so it is safe to run on every `up`.
|
||||
migrate:
|
||||
image: shopdb-flask:${IMAGE_TAG:-0.7.0}
|
||||
restart: "no"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
<<: *app-env
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >
|
||||
flask db upgrade &&
|
||||
flask plugin upgrade-all &&
|
||||
flask seed permissions &&
|
||||
flask seed settings &&
|
||||
flask seed reference-data
|
||||
|
||||
api:
|
||||
image: shopdb-flask:${IMAGE_TAG:-0.7.0}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
<<: *app-env
|
||||
ports:
|
||||
- "${API_PORT:-5001}:5001"
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
36
migrations/versions/7d30_apitoken_resourcescopes.py
Normal file
36
migrations/versions/7d30_apitoken_resourcescopes.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""apitokens.resourcescopes: pin a service token to specific resource scopes.
|
||||
|
||||
A geenforce.fetch token handed to a fleet (e.g. displays) should reach only its
|
||||
own manifest scope(s) and the blobs those scopes ship, not every scope by name
|
||||
or every blob by hash. This nullable JSON column carries that allowlist; NULL =
|
||||
unrestricted, so every existing token keeps working unchanged. Idempotent.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '7d30_apitoken_resourcescopes'
|
||||
down_revision = '7d29_supportteam_webhookurl'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_column(bind, table, column):
|
||||
inspector = sa.inspect(bind)
|
||||
if table not in inspector.get_table_names():
|
||||
return False
|
||||
return column in {c['name'] for c in inspector.get_columns(table)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
if not _has_column(bind, 'apitokens', 'resourcescopes'):
|
||||
op.add_column('apitokens',
|
||||
sa.Column('resourcescopes', sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
if _has_column(bind, 'apitokens', 'resourcescopes'):
|
||||
op.drop_column('apitokens', 'resourcescopes')
|
||||
@@ -9,15 +9,16 @@ Two audiences:
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, request, Response, send_file
|
||||
from flask import Blueprint, request, Response, send_file, current_app, g
|
||||
from flask_jwt_extended import jwt_required
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shopdb.api import (
|
||||
db, success_response, error_response, ErrorCodes, require_permission,
|
||||
service_token_authorized, Setting, Application,
|
||||
db, cache, success_response, error_response, ErrorCodes, require_permission,
|
||||
authorized_service_token, Setting, Application,
|
||||
)
|
||||
|
||||
SHAREROOT_SETTING = 'geenforce_share_root'
|
||||
@@ -50,7 +51,12 @@ def _require_service_token(scope):
|
||||
def wrapper(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if service_token_authorized(scope):
|
||||
token = authorized_service_token(scope)
|
||||
if token is not None:
|
||||
# Stash for the route so it can honor the token's resource
|
||||
# binding (token.resourcescopelist restricts which manifest
|
||||
# scopes + blobs this token may pull; None = unrestricted).
|
||||
g.geenforce_token = token
|
||||
return f(*args, **kwargs)
|
||||
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
||||
http_code=401)
|
||||
@@ -58,6 +64,14 @@ def _require_service_token(scope):
|
||||
return wrapper
|
||||
|
||||
|
||||
def _token_resource_scopes():
|
||||
"""Resource-scope allowlist for the authorized token, or None if the token
|
||||
may reach any scope. A bound token (e.g. a display's) is pinned to its own
|
||||
manifest scope(s) so a leaked key cannot pull every scope's manifest+blobs."""
|
||||
token = getattr(g, 'geenforce_token', None)
|
||||
return token.resourcescopelist if token is not None else None
|
||||
|
||||
|
||||
require_fetch_token = _require_service_token(FETCH_SCOPE)
|
||||
require_report_token = _require_service_token(REPORT_SCOPE)
|
||||
|
||||
@@ -79,6 +93,11 @@ def get_manifest():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'pctype is required', http_code=400)
|
||||
|
||||
allowed = _token_resource_scopes()
|
||||
if allowed is not None and scopename not in allowed:
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'token is not allowed this scope', http_code=403)
|
||||
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
@@ -101,6 +120,59 @@ def get_manifest():
|
||||
|
||||
# -- client payload download (share-less installer delivery) ------------------
|
||||
|
||||
# GET /payload hardening. This endpoint is reachable with only a read-only
|
||||
# geenforce.fetch token, so a leaked display token must not be able to pull
|
||||
# unbounded bytes or hammer it. Two bounds cap the blast radius:
|
||||
# - a per-IP fixed-window rate limit (same shape and cache extension as the
|
||||
# login limiter in shopdb.core.api.auth, so no new dependency), and
|
||||
# - a served-size ceiling: refuse to stream a blob larger than the cap.
|
||||
# Both are overridable via app config for a site that ships bigger installers.
|
||||
PAYLOAD_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024
|
||||
PAYLOAD_DOWNLOAD_RATELIMIT_MAX = 120
|
||||
PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS = 60
|
||||
|
||||
|
||||
def _client_ip():
|
||||
"""Caller IP for rate limiting, honoring the first X-Forwarded-For hop
|
||||
(mirrors shopdb.core.api.auth._login_ip)."""
|
||||
forwarded = request.headers.get('X-Forwarded-For')
|
||||
if forwarded:
|
||||
return forwarded.split(',')[0].strip()
|
||||
return request.remote_addr or 'unknown'
|
||||
|
||||
|
||||
def _payload_max_bytes():
|
||||
return current_app.config.get('GEENFORCE_PAYLOAD_MAX_BYTES',
|
||||
PAYLOAD_DOWNLOAD_MAX_BYTES)
|
||||
|
||||
|
||||
def _payload_download_ratelimited():
|
||||
"""Fixed-window per-IP limiter for the payload download endpoint.
|
||||
|
||||
Backed by the existing cache extension (no new dependency), same shape as
|
||||
the login limiter. Under the default SimpleCache the counter is per-process,
|
||||
so with N gunicorn workers the effective budget is N x the configured max; a
|
||||
shared cache backend (Redis/memcached) tightens it to a true global budget.
|
||||
Returns True when the caller is over budget for the current window.
|
||||
"""
|
||||
if not current_app.config.get('GEENFORCE_PAYLOAD_RATELIMIT_ENABLED', True):
|
||||
return False
|
||||
window = current_app.config.get(
|
||||
'GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS',
|
||||
PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS)
|
||||
maxhits = current_app.config.get(
|
||||
'GEENFORCE_PAYLOAD_RATELIMIT_MAX', PAYLOAD_DOWNLOAD_RATELIMIT_MAX)
|
||||
# Time bucket makes this a fixed window: the key rolls over at each window
|
||||
# boundary, so a per-hit set() cannot turn it into a sliding window.
|
||||
bucket = int(time.time() // window) if window > 0 else 0
|
||||
key = f'geenforcepayloadratelimit:{_client_ip()}:{bucket}'
|
||||
count = cache.get(key) or 0
|
||||
if count >= maxhits:
|
||||
return True
|
||||
cache.set(key, count + 1, timeout=window)
|
||||
return False
|
||||
|
||||
|
||||
@geenforce_bp.route('/payload/<sha256>', methods=['GET'])
|
||||
@require_fetch_token
|
||||
def get_payload(sha256):
|
||||
@@ -111,17 +183,40 @@ def get_payload(sha256):
|
||||
http payloads) first, then an inline DB payload with this hash. The client
|
||||
re-verifies the sha256, so the hash IS the integrity guarantee. ETag = the
|
||||
hash (content is immutable).
|
||||
|
||||
Hardened: per-IP rate limited, and a blob over the served-size ceiling is
|
||||
refused (413) rather than streamed.
|
||||
"""
|
||||
if _payload_download_ratelimited():
|
||||
return error_response('RATE_LIMITED',
|
||||
'Too many payload downloads. Try again later.',
|
||||
http_code=429)
|
||||
|
||||
sha = (sha256 or '').strip().lower()
|
||||
if len(sha) != 64 or any(c not in '0123456789abcdef' for c in sha):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'bad sha256',
|
||||
http_code=400)
|
||||
|
||||
# A resource-bound token may only pull a blob its own scope(s) reference.
|
||||
# Return 404 (not 403) so it cannot probe which hashes exist. Checked before
|
||||
# the 304 shortcut so a bound token cannot even confirm a hash via ETag.
|
||||
allowed = _token_resource_scopes()
|
||||
if allowed is not None and not service.blob_referenced_by_scopes(sha, allowed):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'no such payload',
|
||||
http_code=404)
|
||||
|
||||
etag = f'"{sha}"'
|
||||
if request.headers.get('If-None-Match') == etag:
|
||||
return Response(status=304, headers={'ETag': etag})
|
||||
|
||||
maxbytes = _payload_max_bytes()
|
||||
|
||||
blob = db.session.get(ManifestBlob, sha)
|
||||
if blob and os.path.isfile(service.blob_path(sha)):
|
||||
if blob.sizebytes is not None and blob.sizebytes > maxbytes:
|
||||
return error_response('PAYLOAD_TOO_LARGE',
|
||||
'payload exceeds the download size limit',
|
||||
http_code=413)
|
||||
response = send_file(
|
||||
service.blob_path(sha),
|
||||
mimetype=blob.contenttype or 'application/octet-stream',
|
||||
@@ -131,6 +226,10 @@ def get_payload(sha256):
|
||||
|
||||
inline = ManifestPayload.query.filter_by(payloadsha256=sha).first()
|
||||
if inline:
|
||||
if inline.payloadbytes is not None and len(inline.payloadbytes) > maxbytes:
|
||||
return error_response('PAYLOAD_TOO_LARGE',
|
||||
'payload exceeds the download size limit',
|
||||
http_code=413)
|
||||
return Response(
|
||||
inline.payloadbytes,
|
||||
mimetype=inline.contenttype or 'application/octet-stream',
|
||||
|
||||
@@ -24,6 +24,17 @@
|
||||
.PARAMETER ShadowMode
|
||||
Fetch + compare + report, but install from the share (no behavior change).
|
||||
|
||||
.PARAMETER IncludeCommon
|
||||
Also fetch the fleet-wide CommonScope and merge it into -Scope, so the PC
|
||||
enforces its own scope entries PLUS common's (on a Name conflict the -Scope
|
||||
entry wins). OFF by default: a scope is enforced ALONE (self-sufficient).
|
||||
Displays are self-sufficient and do NOT set this. Only a share-less non-display
|
||||
PC that genuinely needs the fleet-wide common entries over HTTPS turns it on.
|
||||
|
||||
.PARAMETER CommonScope
|
||||
Name of the fleet-wide scope merged when -IncludeCommon is set (default
|
||||
'common'). Ignored without -IncludeCommon.
|
||||
|
||||
.NOTES
|
||||
Fail-safe: any error exits 0 so a bad web app never blocks or breaks a PC.
|
||||
Config comes from HKLM:\SOFTWARE\GE\ShopDB (BaseUrl, ApiToken) - see the psm1.
|
||||
@@ -34,6 +45,8 @@ param(
|
||||
[Parameter(Mandatory)] [string]$EnginePath,
|
||||
[string]$ShareManifestPath,
|
||||
[switch]$ShadowMode,
|
||||
[switch]$IncludeCommon,
|
||||
[string]$CommonScope = 'common',
|
||||
[string]$BaseUrl,
|
||||
[string]$ApiToken,
|
||||
[string]$LogFile = "C:\Logs\Shopfloor\shopdb-enforce-$(Get-Date -Format yyyyMMdd).log"
|
||||
@@ -46,6 +59,27 @@ function Write-Log {
|
||||
Write-Host $line
|
||||
}
|
||||
|
||||
function Write-ShopdbEventLog {
|
||||
<#
|
||||
Write a Windows Application event-log entry under source 'ShopdbEnforce'.
|
||||
Used to make an otherwise silent fail-safe (no manifest and an empty cache)
|
||||
observable to whoever watches the display. Best-effort: registering the
|
||||
source needs admin, which the SYSTEM scheduled task has; any failure is
|
||||
swallowed so it can never break the fail-safe.
|
||||
#>
|
||||
param([string]$Message,
|
||||
[string]$EntryType = 'Warning',
|
||||
[int]$EventId = 1001)
|
||||
$source = 'ShopdbEnforce'
|
||||
try {
|
||||
if (-not [System.Diagnostics.EventLog]::SourceExists($source)) {
|
||||
New-EventLog -LogName Application -Source $source -ErrorAction Stop
|
||||
}
|
||||
Write-EventLog -LogName Application -Source $source -EntryType $EntryType `
|
||||
-EventId $EventId -Message $Message -ErrorAction Stop
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
Import-Module (Join-Path $PSScriptRoot 'ShopdbEnforceClient.psm1') -Force
|
||||
|
||||
@@ -57,7 +91,21 @@ try {
|
||||
|
||||
$sync = Sync-ShopdbManifest -Scope $Scope -Config $config
|
||||
if (-not $sync.Path) {
|
||||
Write-Log "No manifest available for $Scope (shopdb unreachable, no cache)." 'WARN'
|
||||
# Fail-safe stays (exit 0), but a fresh display with an empty cache would
|
||||
# otherwise enforce nothing SILENTLY. Surface it: a Windows event-log
|
||||
# entry plus a best-effort report ping so it shows in Enforcement Reports.
|
||||
$reason = if ($sync.Error) { $sync.Error } else { 'shopdb unreachable and no cached manifest' }
|
||||
Write-Log "No manifest available for $Scope ($reason)." 'WARN'
|
||||
Write-ShopdbEventLog -Message ("GE-Enforce could not fetch a manifest for scope '$Scope' and has no cached copy; nothing was enforced this cycle. Reason: $reason") -EntryType 'Error' -EventId 1001
|
||||
try {
|
||||
$failReport = New-ShopdbReport -Scope $Scope -AppliedVersion 0 -Summary @{
|
||||
Installed = 0; Skipped = 0; Failed = 1; Filtered = 0; EnforcerVersion = '2.6'
|
||||
Results = @(@{ Name = '(manifest-fetch)'; Action = 'failed'; Message = $reason })
|
||||
}
|
||||
if (Send-ShopdbReport -Config $config -Report $failReport) {
|
||||
Write-Log 'Reported empty-cache fetch failure to shopdb.'
|
||||
}
|
||||
} catch {}
|
||||
exit 0
|
||||
}
|
||||
Write-Log "Manifest for $Scope from $($sync.Source) (v$($sync.Version))."
|
||||
@@ -75,29 +123,50 @@ try {
|
||||
|
||||
# Which manifest the engine actually runs against.
|
||||
if ($ShadowMode -and $ShareManifestPath) {
|
||||
# Shadow: install from the share exactly as today (no payload resolve).
|
||||
# Shadow: install from the share exactly as today (no payload resolve,
|
||||
# no common merge - the share already carries its own common scope).
|
||||
$manifestToRun = $ShareManifestPath
|
||||
} else {
|
||||
# Optional common-scope inheritance (OFF by default; displays are
|
||||
# self-sufficient). Only when -IncludeCommon is set do we fetch the
|
||||
# fleet-wide common scope (best-effort, same fail-safe cache) and merge
|
||||
# it in with the pctype winning on conflict. Skipped when this run IS
|
||||
# the common scope.
|
||||
$manifestToMerge = $sync.Path
|
||||
if ($IncludeCommon -and $CommonScope -and ($CommonScope -ine $Scope)) {
|
||||
$commonSync = Sync-ShopdbManifest -Scope $CommonScope -Config $config
|
||||
if ($commonSync.Path) {
|
||||
$manifestToMerge = Merge-ShopdbManifests -PrimaryManifestPath $sync.Path -CommonManifestPath $commonSync.Path
|
||||
if ($manifestToMerge -ne $sync.Path) {
|
||||
Write-Log "Merged common scope '$CommonScope' (from $($commonSync.Source), v$($commonSync.Version)) into $Scope."
|
||||
}
|
||||
} else {
|
||||
Write-Log "Common scope '$CommonScope' unavailable (no fetch, no cache) - enforcing $Scope alone." 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
# Cutover: stage any http/inline payloads to local files and rewrite the
|
||||
# manifest to point at them, so the UNCHANGED engine installs from local
|
||||
# (no SMB needed for share-less PCs).
|
||||
$manifestToRun = Resolve-ShopdbPayloads -ManifestPath $sync.Path -Config $config
|
||||
if ($manifestToRun -ne $sync.Path) {
|
||||
$manifestToRun = Resolve-ShopdbPayloads -ManifestPath $manifestToMerge -Config $config
|
||||
if ($manifestToRun -ne $manifestToMerge) {
|
||||
Write-Log "Resolved http/inline payloads to local files: $manifestToRun"
|
||||
}
|
||||
}
|
||||
|
||||
# --- INTEGRATION POINT ---------------------------------------------------
|
||||
# Run the engine. Install-FromManifest.ps1 is expected to return (or you
|
||||
# adapt it to return) a summary carrying Installed/Skipped/Failed/Filtered
|
||||
# and a Results list of @{Name;Action;SelfHealed;ExitCode;Message}. Wire this
|
||||
# to your engine's actual return/parse; the shape below is the contract.
|
||||
# Run the engine. EXPECTED ENGINE CONTRACT: Install-FromManifest.ps1 returns
|
||||
# a summary object (hashtable or PSCustomObject) carrying integer counts
|
||||
# Installed / Skipped / Failed / Filtered
|
||||
# a string EnforcerVersion, and a Results list of per-entry outcomes
|
||||
# @{ Name; Action; SelfHealed; ExitCode; Message }.
|
||||
# The engine may not honor that yet: it might return $null, a bare return
|
||||
# code, or emit several objects. ConvertTo-ShopdbSummary adapts whatever it
|
||||
# returns into a well-formed summary hashtable so the report stage always
|
||||
# gets clean input (we do NOT assume the engine was fixed).
|
||||
Write-Log "Running engine against $manifestToRun"
|
||||
$summary = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope
|
||||
if (-not $summary) {
|
||||
$summary = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0; Results = @() }
|
||||
}
|
||||
if (-not $summary.EnforcerVersion) { $summary.EnforcerVersion = '2.6' }
|
||||
$engineResult = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope
|
||||
$summary = ConvertTo-ShopdbSummary -EngineResult $engineResult
|
||||
|
||||
# Report the result (best-effort).
|
||||
$appliedVersion = 0
|
||||
|
||||
@@ -21,6 +21,43 @@
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Set-ShopdbTls {
|
||||
<#
|
||||
Force TLS 1.2 for the process-wide ServicePointManager. Windows PowerShell
|
||||
5.1 (what runs as SYSTEM on the display image) does not always negotiate
|
||||
TLS 1.2 by default, so every network helper calls this first. Mirrors the
|
||||
pattern in docs/COLLECTOR-INTEGRATION.md. Best-effort: never throws.
|
||||
#>
|
||||
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}
|
||||
}
|
||||
|
||||
function Get-ShopdbProperty {
|
||||
<#
|
||||
Read a property/key from either a hashtable or a PSCustomObject, matching
|
||||
any of the given names case-insensitively. Returns $null when absent
|
||||
instead of throwing under Set-StrictMode. The engine's per-entry outcomes
|
||||
and summary may arrive as either shape, so all normalization goes through
|
||||
this.
|
||||
#>
|
||||
param($InputObject, [Parameter(Mandatory)][string[]]$Names)
|
||||
if ($null -eq $InputObject) { return $null }
|
||||
if ($InputObject -is [System.Collections.IDictionary]) {
|
||||
foreach ($wanted in $Names) {
|
||||
foreach ($key in @($InputObject.Keys)) {
|
||||
if ($key -is [string] -and $key -ieq $wanted) { return $InputObject[$key] }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
$properties = $InputObject.PSObject.Properties
|
||||
foreach ($wanted in $Names) {
|
||||
foreach ($property in $properties) {
|
||||
if ($property.Name -ieq $wanted) { return $property.Value }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-ShopdbConfig {
|
||||
param([string]$BaseUrl, [string]$ApiToken)
|
||||
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
|
||||
@@ -46,6 +83,7 @@ function Sync-ShopdbManifest {
|
||||
[Parameter(Mandatory)] [hashtable]$Config,
|
||||
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
|
||||
)
|
||||
Set-ShopdbTls
|
||||
if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null }
|
||||
$manifestPath = Join-Path $CacheDir "$Scope.json"
|
||||
$etagPath = Join-Path $CacheDir "$Scope.etag"
|
||||
@@ -80,7 +118,12 @@ function Sync-ShopdbManifest {
|
||||
if (Test-Path $manifestPath) {
|
||||
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-lastgood' }
|
||||
}
|
||||
return @{ Path = $null; Version = $null; Source = $null }
|
||||
# Nothing cached (e.g. a fresh display): report why so the failure is not
|
||||
# silent. Include the HTTP status (401 auth, TLS-trust surfaces as a
|
||||
# non-status transport error) and the exception message.
|
||||
$errText = $_.Exception.Message
|
||||
if ($status) { $errText = "HTTP $status - $errText" }
|
||||
return @{ Path = $null; Version = $null; Source = $null; Error = $errText }
|
||||
}
|
||||
# 304 without exception (some PS versions) -> use cache.
|
||||
if (Test-Path $manifestPath) {
|
||||
@@ -123,6 +166,7 @@ function Send-ShopdbReport {
|
||||
#>
|
||||
param([Parameter(Mandatory)][hashtable]$Config,
|
||||
[Parameter(Mandatory)][hashtable]$Report)
|
||||
Set-ShopdbTls
|
||||
try {
|
||||
$body = ($Report | ConvertTo-Json -Depth 6)
|
||||
Invoke-RestMethod -Uri "$($Config.BaseUrl)/api/geenforce/report" `
|
||||
@@ -137,14 +181,31 @@ function Send-ShopdbReport {
|
||||
function New-ShopdbReport {
|
||||
<#
|
||||
Build a report payload from an engine summary. `Summary` is expected to
|
||||
carry Installed/Skipped/Failed/Filtered counts and a Results list of
|
||||
@{ Name; Action; SelfHealed; ExitCode; Message }. Shape the engine's own
|
||||
per-entry outcomes into this at the call site.
|
||||
carry Installed/Skipped/Failed/Filtered counts and a Results list whose
|
||||
per-entry outcomes carry name/action and optionally selfhealed/exitcode/
|
||||
message. The engine emits these in PascalCase (Name/Action/SelfHealed/
|
||||
ExitCode/Message); the shopdb report contract is entirely lowercase, so
|
||||
this function maps every per-entry key down to lowercase. Case is matched
|
||||
case-insensitively, so a caller that already lowercased still works.
|
||||
#>
|
||||
param([string]$Hostname = $env:COMPUTERNAME,
|
||||
[Parameter(Mandatory)][string]$Scope,
|
||||
[int]$AppliedVersion,
|
||||
[Parameter(Mandatory)][hashtable]$Summary)
|
||||
$results = @(foreach ($entry in @($Summary.Results)) {
|
||||
if ($null -eq $entry) { continue }
|
||||
$mapped = @{
|
||||
name = [string](Get-ShopdbProperty -InputObject $entry -Names 'name')
|
||||
action = [string](Get-ShopdbProperty -InputObject $entry -Names 'action')
|
||||
}
|
||||
$selfHealed = Get-ShopdbProperty -InputObject $entry -Names 'selfhealed'
|
||||
if ($null -ne $selfHealed) { $mapped['selfhealed'] = [bool]$selfHealed }
|
||||
$exitCode = Get-ShopdbProperty -InputObject $entry -Names 'exitcode'
|
||||
if ($null -ne $exitCode) { $mapped['exitcode'] = [int]$exitCode }
|
||||
$message = Get-ShopdbProperty -InputObject $entry -Names 'message'
|
||||
if ($message) { $mapped['message'] = [string]$message }
|
||||
$mapped
|
||||
})
|
||||
return @{
|
||||
hostname = $Hostname
|
||||
scopename = $Scope
|
||||
@@ -156,7 +217,7 @@ function New-ShopdbReport {
|
||||
failed = [int]$Summary.Failed
|
||||
filtered = [int]$Summary.Filtered
|
||||
}
|
||||
results = @($Summary.Results)
|
||||
results = $results
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +234,7 @@ function Get-ShopdbPayload {
|
||||
[string]$Filename,
|
||||
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
|
||||
)
|
||||
Set-ShopdbTls
|
||||
$sha = $Sha256.Trim().ToLower()
|
||||
$payloadDir = Join-Path $CacheDir 'payloads'
|
||||
if (-not (Test-Path $payloadDir)) { New-Item -ItemType Directory -Path $payloadDir -Force | Out-Null }
|
||||
@@ -241,6 +303,125 @@ function Resolve-ShopdbPayloads {
|
||||
return $out
|
||||
}
|
||||
|
||||
function Merge-ShopdbManifests {
|
||||
<#
|
||||
Merge the fleet-wide 'common' scope manifest into a pctype (display) scope
|
||||
manifest and write the merged result to a sibling file. This mirrors how
|
||||
the real GE-Enforce.ps1 applies scopes in order (common first, then the
|
||||
pctype), except we produce a single merged manifest for the unchanged
|
||||
engine to run once. Merge rules:
|
||||
|
||||
- Entries are keyed by Name (case-insensitive).
|
||||
- common's unique entries come first, then all pctype entries, so
|
||||
common's own apps enforce ahead of the pctype's, matching the real
|
||||
script's common-then-pctype ordering.
|
||||
- On a Name conflict the PCTYPE entry wins (the pctype override replaces
|
||||
common's version, and keeps common's slot out of the list).
|
||||
|
||||
Returns the merged manifest path. If there is no common manifest, returns
|
||||
the primary path unchanged. The merged manifest keeps the pctype
|
||||
manifest's top-level Version (that is the version the display reports as
|
||||
applied).
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$PrimaryManifestPath,
|
||||
[string]$CommonManifestPath
|
||||
)
|
||||
if (-not $CommonManifestPath -or -not (Test-Path $CommonManifestPath)) { return $PrimaryManifestPath }
|
||||
|
||||
$primary = Get-Content -LiteralPath $PrimaryManifestPath -Raw | ConvertFrom-Json
|
||||
$common = Get-Content -LiteralPath $CommonManifestPath -Raw | ConvertFrom-Json
|
||||
|
||||
$primaryApps = @($primary.Applications)
|
||||
$commonApps = @($common.Applications)
|
||||
|
||||
$primaryNames = @{}
|
||||
foreach ($app in $primaryApps) {
|
||||
$name = [string]$app.Name
|
||||
if ($name) { $primaryNames[$name.ToLower()] = $true }
|
||||
}
|
||||
|
||||
# common entries the pctype does not override, then all pctype entries.
|
||||
$merged = @()
|
||||
foreach ($app in $commonApps) {
|
||||
$name = [string]$app.Name
|
||||
if ($name -and $primaryNames.ContainsKey($name.ToLower())) { continue }
|
||||
$merged += $app
|
||||
}
|
||||
foreach ($app in $primaryApps) { $merged += $app }
|
||||
|
||||
$primary.Applications = $merged
|
||||
$out = [System.IO.Path]::ChangeExtension($PrimaryManifestPath, '.merged.json')
|
||||
($primary | ConvertTo-Json -Depth 20) | Set-Content -LiteralPath $out -Encoding UTF8
|
||||
return $out
|
||||
}
|
||||
|
||||
function ConvertTo-ShopdbSummary {
|
||||
<#
|
||||
Normalize whatever the engine returns into a well-formed summary hashtable
|
||||
so New-ShopdbReport always has clean input, without assuming the engine was
|
||||
fixed.
|
||||
|
||||
Expected engine contract (what a compliant engine returns):
|
||||
@{ Installed=<int>; Skipped=<int>; Failed=<int>; Filtered=<int>;
|
||||
EnforcerVersion=<string>;
|
||||
Results=@( @{ Name; Action; SelfHealed; ExitCode; Message } ... ) }
|
||||
|
||||
This adapter tolerates any of:
|
||||
- $null / empty -> a zeroed summary.
|
||||
- a hashtable or PSCustomObject with those keys (any casing).
|
||||
- an array / multiple emitted objects -> the last element that looks
|
||||
like a summary (has any count or a Results list) is used.
|
||||
- a bare return code (int) or unrecognized object -> a zeroed summary.
|
||||
|
||||
Counts are coerced to int; a missing EnforcerVersion is left for the caller
|
||||
to default. Always returns a hashtable.
|
||||
#>
|
||||
param($EngineResult, [string]$DefaultEnforcerVersion = '2.6')
|
||||
|
||||
$zero = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0;
|
||||
Results = @(); EnforcerVersion = $DefaultEnforcerVersion }
|
||||
|
||||
$candidate = $EngineResult
|
||||
if ($candidate -is [System.Array]) {
|
||||
$picked = $null
|
||||
foreach ($item in $candidate) {
|
||||
if ($null -eq $item) { continue }
|
||||
$looksLikeSummary = $false
|
||||
foreach ($key in @('Installed', 'Skipped', 'Failed', 'Filtered', 'Results')) {
|
||||
if ($null -ne (Get-ShopdbProperty -InputObject $item -Names $key)) { $looksLikeSummary = $true; break }
|
||||
}
|
||||
if ($looksLikeSummary) { $picked = $item }
|
||||
}
|
||||
$candidate = $picked
|
||||
}
|
||||
|
||||
if ($null -eq $candidate) { return $zero }
|
||||
# A bare return code (int) or any object without the expected members reads
|
||||
# as all-null through Get-ShopdbProperty below, which yields the zeroed
|
||||
# summary - exactly the fail-open behavior we want for a non-compliant engine.
|
||||
$results = Get-ShopdbProperty -InputObject $candidate -Names 'Results'
|
||||
$enforcerVersion = [string](Get-ShopdbProperty -InputObject $candidate -Names 'EnforcerVersion')
|
||||
if (-not $enforcerVersion) { $enforcerVersion = $DefaultEnforcerVersion }
|
||||
|
||||
$toInt = {
|
||||
param($value)
|
||||
$parsed = 0
|
||||
if ($null -ne $value -and [int]::TryParse([string]$value, [ref]$parsed)) { return $parsed }
|
||||
return 0
|
||||
}
|
||||
|
||||
return @{
|
||||
Installed = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Installed'))
|
||||
Skipped = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Skipped'))
|
||||
Failed = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Failed'))
|
||||
Filtered = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Filtered', 'PCFiltered'))
|
||||
Results = @($results)
|
||||
EnforcerVersion = $enforcerVersion
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, `
|
||||
Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion, `
|
||||
Get-ShopdbPayload, Resolve-ShopdbPayloads
|
||||
Get-ShopdbPayload, Resolve-ShopdbPayloads, Merge-ShopdbManifests, `
|
||||
ConvertTo-ShopdbSummary
|
||||
|
||||
264
plugins/geenforce/seed_display_scope.py
Normal file
264
plugins/geenforce/seed_display_scope.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""Author the gea-shopfloor-display runtime scope programmatically.
|
||||
|
||||
Displays are Intune/Entra-joined, credential-less kiosk PCs that pull their
|
||||
manifest over HTTPS (no SMB share). The kiosk engine and the kiosk browser are
|
||||
BAKED INTO THE DISPLAY IMAGE, so this scope does not ship any EXE payloads; it
|
||||
heals POLICY / CONFIG drift only, plus one dispatcher that points the kiosk at
|
||||
the right target for the display subtype.
|
||||
|
||||
What this scope contains:
|
||||
- Four Registry drift-heal entries that re-assert the Microsoft Edge kiosk
|
||||
relaunch policies from the imaging script 09-Setup-Display.ps1 (so a display
|
||||
that loses those policies self-heals on the next enforce cycle without a
|
||||
keyboard or mouse on site).
|
||||
- One inline PS1 dispatcher that reads C:\\Enrollment\\display-type.txt and
|
||||
launches the kiosk target for the subtype. The display-type -> target map is
|
||||
a data-driven table (DISPLAY_TYPE_TARGETS) so the targets are easy to edit.
|
||||
|
||||
Self-sufficient: the display scope carries EVERYTHING a display enforces and
|
||||
does NOT inherit the fleet-wide 'common' scope. Displays run the enforcer with
|
||||
common-merge off (the client default), so common's SMB-backed fleet entries
|
||||
never reach a share-less display. This keeps the display path simple and needs
|
||||
no common-payload repackaging.
|
||||
|
||||
Authoring path mirrors how every other scope is created: build a manifest dict
|
||||
and hand it to service.replace_scope_draft (the same call the import-share CLI
|
||||
uses), then attach the inline dispatcher payload and optionally publish. Re-
|
||||
running replace_scope_draft is an idempotent draft rebuild.
|
||||
"""
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
from . import service
|
||||
|
||||
|
||||
SCOPE_NAME = 'gea-shopfloor-display'
|
||||
SCOPE_PHASE = 'runtime'
|
||||
SCOPE_VERSION = '2.0'
|
||||
|
||||
# Edge kiosk relaunch policy key. Values below mirror 09-Setup-Display.ps1
|
||||
# exactly so the imaging-time state and the enforced state never disagree.
|
||||
EDGE_POLICY_PATH = 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Edge'
|
||||
RELAUNCH_WINDOW_JSON = (
|
||||
'{"entries":[{"start":{"hour":2,"minute":0},"duration_mins":120}]}')
|
||||
|
||||
# Data-driven display-type -> kiosk target map. The value of
|
||||
# C:\Enrollment\display-type.txt selects the row; the target is a route the
|
||||
# kiosk browser opens against the local kiosk base URL. Edit here to retarget a
|
||||
# subtype. Keys are matched case-insensitively by the dispatcher.
|
||||
#
|
||||
# TODO-confirm: 3DPrintRoom points at the printedparts /parts-kiosk route as a
|
||||
# PLACEHOLDER. Confirm the real 3D-print-room kiosk target with the floor team
|
||||
# before this scope is published to production displays.
|
||||
DISPLAY_TYPE_TARGETS = {
|
||||
'Dashboard': '/shopfloor',
|
||||
'Lobby': '/tv',
|
||||
'3DPrintRoom': '/parts-kiosk',
|
||||
}
|
||||
|
||||
DISPATCHER_FILENAME = 'Invoke-DisplayKioskDispatch.ps1'
|
||||
|
||||
|
||||
def _relaunch_window_targets_comment():
|
||||
"""Human note that lists the data-driven targets, for the manifest comment."""
|
||||
pairs = ', '.join(f'{name}={route}'
|
||||
for name, route in DISPLAY_TYPE_TARGETS.items())
|
||||
return pairs
|
||||
|
||||
|
||||
def build_dispatcher_script():
|
||||
"""Return the inline dispatcher PowerShell as text.
|
||||
|
||||
The display-type -> target map is emitted as a hashtable at the top of the
|
||||
script (generated from DISPLAY_TYPE_TARGETS) so the on-PC script and the
|
||||
manifest metadata agree and both stay easy to edit.
|
||||
"""
|
||||
table_lines = []
|
||||
for display_type, route in DISPLAY_TYPE_TARGETS.items():
|
||||
table_lines.append(f" '{display_type}' = '{route}'")
|
||||
table_body = ';\n'.join(table_lines)
|
||||
|
||||
return f"""# Invoke-DisplayKioskDispatch.ps1 -- gea-shopfloor-display dispatcher.
|
||||
#
|
||||
# Reads C:\\Enrollment\\display-type.txt and launches the kiosk browser at the
|
||||
# route mapped for that display subtype. The kiosk browser is baked into the
|
||||
# display image; this script only points it at the right target.
|
||||
#
|
||||
# The DisplayTypeTargets table below is the single source of truth for the
|
||||
# subtype -> route map. Edit a row to retarget a subtype.
|
||||
#
|
||||
# TODO-confirm: 3DPrintRoom uses the printedparts /parts-kiosk route as a
|
||||
# PLACEHOLDER. Confirm the real 3D-print-room target before production use.
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
# --- Data-driven subtype -> kiosk route map ---
|
||||
$DisplayTypeTargets = @{{
|
||||
{table_body}
|
||||
}}
|
||||
|
||||
# Base URL the kiosk browser opens; the route from the table is appended. Edit
|
||||
# to point at this site's shopdb host. Kept here so the map above stays pure.
|
||||
$KioskBaseUrl = 'https://localhost'
|
||||
|
||||
$displayTypeFile = 'C:\\Enrollment\\display-type.txt'
|
||||
if (-not (Test-Path -LiteralPath $displayTypeFile)) {{
|
||||
Write-Host "display-type.txt not found at $displayTypeFile; nothing to launch."
|
||||
return
|
||||
}}
|
||||
|
||||
$displayType = (Get-Content -LiteralPath $displayTypeFile -Raw).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($displayType)) {{
|
||||
Write-Host 'display-type.txt is empty; nothing to launch.'
|
||||
return
|
||||
}}
|
||||
|
||||
# Case-insensitive lookup so 'lobby' and 'Lobby' both resolve.
|
||||
$matchedKey = $DisplayTypeTargets.Keys |
|
||||
Where-Object {{ $_ -ieq $displayType }} |
|
||||
Select-Object -First 1
|
||||
if (-not $matchedKey) {{
|
||||
Write-Host "Unknown display-type '$displayType'; known types: $($DisplayTypeTargets.Keys -join ', ')."
|
||||
return
|
||||
}}
|
||||
|
||||
$targetRoute = $DisplayTypeTargets[$matchedKey]
|
||||
$kioskUrl = "$KioskBaseUrl$targetRoute"
|
||||
Write-Host "display-type '$displayType' -> kiosk target $kioskUrl"
|
||||
|
||||
# Idempotent: if an Edge kiosk process is already serving this URL, leave it be
|
||||
# so an enforce cycle does not relaunch the kiosk every run.
|
||||
$alreadyRunning = Get-CimInstance Win32_Process -Filter "Name='msedge.exe'" -ErrorAction SilentlyContinue |
|
||||
Where-Object {{ $_.CommandLine -and $_.CommandLine.Contains($kioskUrl) }}
|
||||
if ($alreadyRunning) {{
|
||||
Write-Host 'Kiosk already running for this target; leaving it in place.'
|
||||
return
|
||||
}}
|
||||
|
||||
$edgeArguments = @("--kiosk", $kioskUrl, "--edge-kiosk-type=fullscreen", "--no-first-run")
|
||||
Start-Process -FilePath 'msedge.exe' -ArgumentList $edgeArguments
|
||||
Write-Host 'Launched kiosk browser.'
|
||||
"""
|
||||
|
||||
|
||||
def _registry_drift_heal_entry(name, regname, regvalue, regtype, comment):
|
||||
"""One Type=Registry entry that writes a value and detects drift via
|
||||
ValueMatches against that same path/name.
|
||||
|
||||
DetectionValue is stored as a string; the engine string-coerces for
|
||||
ValueMatches, so a DWord value of 2 detects against '2'.
|
||||
"""
|
||||
return {
|
||||
'_comment': comment,
|
||||
'Name': name,
|
||||
'Type': 'Registry',
|
||||
'RegPath': EDGE_POLICY_PATH,
|
||||
'RegName': regname,
|
||||
'RegValue': regvalue,
|
||||
'RegType': regtype,
|
||||
'DetectionMethod': 'ValueMatches',
|
||||
'DetectionPath': EDGE_POLICY_PATH,
|
||||
'DetectionName': regname,
|
||||
'DetectionValue': str(regvalue),
|
||||
}
|
||||
|
||||
|
||||
def build_display_manifest():
|
||||
"""Return the gea-shopfloor-display manifest dict (Applications in order).
|
||||
|
||||
Four Edge kiosk relaunch-policy drift-heal entries, then the one dispatcher
|
||||
entry. The dispatcher is declared PayloadSource=inline with no hash yet; the
|
||||
seed attaches the real payload bytes (and its sha256) after the draft rows
|
||||
exist. Kept payload-free otherwise: the kiosk engine and browser are baked
|
||||
into the display image, not shipped over HTTPS.
|
||||
"""
|
||||
applications = [
|
||||
_registry_drift_heal_entry(
|
||||
'Edge kiosk RelaunchNotification (Required auto-restart)',
|
||||
'RelaunchNotification', 2, 'DWord',
|
||||
'RelaunchNotification=2 (Required): Edge auto-restarts after the '
|
||||
'notification period. Displays have no operator to dismiss the '
|
||||
'update dialog, so this is the only mode that recovers unattended. '
|
||||
'Heals drift of the policy set at imaging by 09-Setup-Display.ps1.'),
|
||||
_registry_drift_heal_entry(
|
||||
'Edge kiosk RelaunchNotificationPeriod (1 hour)',
|
||||
'RelaunchNotificationPeriod', 3600000, 'DWord',
|
||||
'Milliseconds before the forced auto-restart. 3600000 ms = 1 hour.'),
|
||||
_registry_drift_heal_entry(
|
||||
'Edge kiosk RelaunchHeadsUpPeriod (1 minute)',
|
||||
'RelaunchHeadsUpPeriod', 60000, 'DWord',
|
||||
'Milliseconds of final warning before auto-restart. 60000 ms = 1 '
|
||||
'minute.'),
|
||||
_registry_drift_heal_entry(
|
||||
'Edge kiosk RelaunchWindow (02:00-04:00)',
|
||||
'RelaunchWindow', RELAUNCH_WINDOW_JSON, 'String',
|
||||
'Overnight forced-restart window (02:00 start, 120 minute '
|
||||
'duration) so business-hour updates wait until off-hours and the '
|
||||
'dialog stays invisible during the day.'),
|
||||
{
|
||||
'_comment': (
|
||||
'Kiosk dispatcher. Reads C:\\Enrollment\\display-type.txt and '
|
||||
'launches the kiosk target for the subtype. Data-driven map: '
|
||||
+ _relaunch_window_targets_comment()
|
||||
+ '. 3DPrintRoom target is a PLACEHOLDER (/parts-kiosk); '
|
||||
'TODO-confirm the real target. Delivered inline over HTTPS '
|
||||
'(share-less displays); DetectionMethod Always so it re-asserts '
|
||||
'each cycle, but the script is idempotent (skips if the kiosk is '
|
||||
'already serving the target URL).'),
|
||||
'Name': 'Display kiosk dispatcher (display-type.txt)',
|
||||
'Type': 'PS1',
|
||||
'Script': DISPATCHER_FILENAME,
|
||||
'PayloadSource': 'inline',
|
||||
'PayloadRef': DISPATCHER_FILENAME,
|
||||
'DetectionMethod': 'Always',
|
||||
},
|
||||
]
|
||||
return {
|
||||
'Version': SCOPE_VERSION,
|
||||
'_comment': (
|
||||
'gea-shopfloor-display runtime scope. Heals Edge kiosk relaunch '
|
||||
'policy drift and dispatches the kiosk to the subtype target. No '
|
||||
'EXE payloads: kiosk engine and browser are baked into the display '
|
||||
'image. Self-sufficient: displays do NOT inherit the common '
|
||||
'scope.'),
|
||||
'Applications': applications,
|
||||
}
|
||||
|
||||
|
||||
def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
|
||||
"""Create/refresh the gea-shopfloor-display draft scope and its entries.
|
||||
|
||||
Idempotent for the draft: replace_scope_draft rebuilds the draft rows, and
|
||||
the inline dispatcher payload is content-addressed (a re-run stores the same
|
||||
bytes to the same sha256). Set publish=True to also freeze a published
|
||||
snapshot (that step is NOT idempotent: it always creates a new version).
|
||||
|
||||
Commits the session. Returns a summary dict:
|
||||
{scopeid, entrycount, entrytypes, dispatchersha256, publishedversion}.
|
||||
"""
|
||||
manifest = build_display_manifest()
|
||||
scope = service.replace_scope_draft(SCOPE_NAME, SCOPE_PHASE, manifest)
|
||||
# Flush so the new entries get entryids before the inline payload attaches.
|
||||
db.session.flush()
|
||||
|
||||
dispatcher = next(entry for entry in scope.entries
|
||||
if entry.name == 'Display kiosk dispatcher (display-type.txt)')
|
||||
scriptbytes = build_dispatcher_script().encode('utf-8')
|
||||
payload = service.store_inline_payload(
|
||||
dispatcher, DISPATCHER_FILENAME,
|
||||
'text/plain; charset=utf-8', scriptbytes)
|
||||
|
||||
publishedversion = None
|
||||
if publish:
|
||||
publishedversion = service.publish_scope(
|
||||
SCOPE_NAME, SCOPE_PHASE, notes=notes)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'scopeid': scope.scopeid,
|
||||
'entrycount': len(scope.entries),
|
||||
'entrytypes': [entry.entrytype for entry in scope.entries],
|
||||
'dispatchersha256': payload.payloadsha256,
|
||||
'publishedversion': publishedversion,
|
||||
}
|
||||
@@ -8,6 +8,7 @@ Kept out of the CLI and routes so both share one implementation:
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
@@ -288,6 +289,34 @@ def blob_path(sha256):
|
||||
return os.path.join(_payload_dir(), sha256)
|
||||
|
||||
|
||||
def blob_referenced_by_scopes(sha256, scopenames):
|
||||
"""True when any of these scopes' CURRENT published manifest references
|
||||
sha256 as an entry payload.
|
||||
|
||||
Backs the resource-bound token check on GET /payload: a token pinned to its
|
||||
own scope(s) may only pull blobs those scopes actually ship, not any blob by
|
||||
hash. Empty scopenames -> False (a bound-but-empty token reaches nothing).
|
||||
"""
|
||||
if not scopenames:
|
||||
return False
|
||||
rows = db.session.query(ManifestPublishedVersion).join(
|
||||
ManifestScope,
|
||||
ManifestPublishedVersion.scopeid == ManifestScope.scopeid,
|
||||
).filter(
|
||||
ManifestScope.scopename.in_(list(scopenames)),
|
||||
ManifestPublishedVersion.iscurrent == True, # noqa: E712
|
||||
).all()
|
||||
for row in rows:
|
||||
try:
|
||||
doc = json.loads(row.manifestjson)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
for entry in doc.get('Applications', []) or []:
|
||||
if entry.get('PayloadSha256') == sha256:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def store_blob(rawbytes, filename, contenttype=None):
|
||||
"""Store bytes in the content-addressed payload store; return the sha256.
|
||||
|
||||
|
||||
@@ -380,7 +380,7 @@ def _alert_team_webhook():
|
||||
if not team_id:
|
||||
return None
|
||||
try:
|
||||
from shopdb.core.models import SupportTeam
|
||||
from shopdb.api import SupportTeam
|
||||
team = db.session.get(SupportTeam, int(team_id))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
@@ -20,6 +20,12 @@ export default [
|
||||
component: () => import('./views/ZabbixSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/printer-alerts',
|
||||
name: 'printer-alerts',
|
||||
component: () => import('./views/PrinterAlertsSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'printers',
|
||||
name: 'printers',
|
||||
|
||||
192
plugins/printers/frontend/views/PrinterAlertsSettings.vue
Normal file
192
plugins/printers/frontend/views/PrinterAlertsSettings.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Low-Toner Alerts</h2>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<p class="field-hint">
|
||||
A scheduled poll checks Zabbix toner levels. A warning email fires when
|
||||
a toner crosses to the warning threshold or below, a critical email at
|
||||
the critical threshold. Alerts fire once per crossing; a refill re-arms
|
||||
them.
|
||||
</p>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Warning threshold (% remaining)</label>
|
||||
<input v-model.number="values.printers_alert_warning_threshold"
|
||||
type="number" min="0" max="100" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Critical threshold (% remaining)</label>
|
||||
<input v-model.number="values.printers_alert_critical_threshold"
|
||||
type="number" min="0" max="100" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Alert shopdb users</label>
|
||||
<div class="user-picker">
|
||||
<label v-for="candidate in users" :key="candidate.userid" class="user-row">
|
||||
<input type="checkbox" :value="String(candidate.userid)"
|
||||
v-model="selectedUserids" />
|
||||
<span>{{ candidate.username }}</span>
|
||||
<span class="user-email">{{ candidate.email }}</span>
|
||||
</label>
|
||||
<p v-if="users.length === 0" class="field-hint">No users loaded</p>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Selected users receive low-toner alerts at their account email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Alert roles</label>
|
||||
<div class="user-picker">
|
||||
<label v-for="role in roles" :key="role.roleid" class="user-row">
|
||||
<input type="checkbox" :value="String(role.roleid)"
|
||||
v-model="selectedRoleids" />
|
||||
<span>{{ role.rolename }}</span>
|
||||
<span class="user-email">{{ role.description }}</span>
|
||||
</label>
|
||||
<p v-if="roles.length === 0" class="field-hint">No roles loaded</p>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Every active member of a selected role receives low-toner alerts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Additional alert emails</label>
|
||||
<input v-model="values.printers_alert_email" type="text"
|
||||
class="form-control" placeholder="print-team@example.com, lead@example.com" />
|
||||
<p class="field-hint">
|
||||
Comma-separated. Empty uses the site-wide alert recipients
|
||||
(Settings > System > Email).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Alert support team (Teams webhook)</label>
|
||||
<select v-model="values.printers_alert_supportteamid" class="form-control">
|
||||
<option value="">Site default webhook</option>
|
||||
<option v-for="team in supportTeams" :key="team.supportteamid" :value="String(team.supportteamid)">
|
||||
{{ team.teamname }}{{ team.webhookurl ? '' : ' (no webhook set)' }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="field-hint">
|
||||
Low-toner alerts post to this team's webhook (set on Settings >
|
||||
Support Teams). Empty uses the site-wide alert webhook.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi, usersApi, supportteamsApi } from '@/api'
|
||||
|
||||
const KEYS = [
|
||||
'printers_alert_email',
|
||||
'printers_alert_userids',
|
||||
'printers_alert_roleids',
|
||||
'printers_alert_supportteamid',
|
||||
'printers_alert_warning_threshold',
|
||||
'printers_alert_critical_threshold'
|
||||
]
|
||||
|
||||
const values = ref({
|
||||
printers_alert_email: '',
|
||||
printers_alert_userids: '',
|
||||
printers_alert_roleids: '',
|
||||
printers_alert_supportteamid: '',
|
||||
printers_alert_warning_threshold: 5,
|
||||
printers_alert_critical_threshold: 0
|
||||
})
|
||||
const supportTeams = ref([])
|
||||
const users = ref([])
|
||||
const selectedUserids = ref([])
|
||||
const roles = ref([])
|
||||
const selectedRoleids = ref([])
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await settingsApi.list({ category: 'printers' })
|
||||
const rows = response.data.data || []
|
||||
for (const row of rows) {
|
||||
if (KEYS.includes(row.key)) values.value[row.key] = row.value
|
||||
}
|
||||
values.value.printers_alert_warning_threshold =
|
||||
parseInt(values.value.printers_alert_warning_threshold, 10) || 0
|
||||
values.value.printers_alert_critical_threshold =
|
||||
parseInt(values.value.printers_alert_critical_threshold, 10) || 0
|
||||
selectedUserids.value = (values.value.printers_alert_userids || '')
|
||||
.split(',').map(id => id.trim()).filter(Boolean)
|
||||
const usersResponse = await usersApi.list()
|
||||
users.value = (usersResponse.data.data || []).filter(
|
||||
candidate => candidate.isactive && candidate.email)
|
||||
selectedRoleids.value = (values.value.printers_alert_roleids || '')
|
||||
.split(',').map(id => id.trim()).filter(Boolean)
|
||||
const rolesResponse = await usersApi.roles.list()
|
||||
roles.value = rolesResponse.data.data || []
|
||||
const teamsResponse = await supportteamsApi.list()
|
||||
supportTeams.value = teamsResponse.data.data || []
|
||||
} catch (loadError) {
|
||||
error.value = 'Could not load settings'
|
||||
console.error(loadError)
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
values.value.printers_alert_userids = selectedUserids.value.join(',')
|
||||
values.value.printers_alert_roleids = selectedRoleids.value.join(',')
|
||||
for (const key of KEYS) {
|
||||
await settingsApi.update(key, String(values.value[key] ?? ''))
|
||||
}
|
||||
message.value = 'Settings saved'
|
||||
} catch (saveError) {
|
||||
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
|
||||
.form-row { display: flex; gap: 1rem; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
.user-picker {
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.user-email { color: var(--text-light); font-size: 0.85rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
"""printers: printersupplyalerts table (toner alert crossing state).
|
||||
|
||||
Stores the last tier alerted per (printer, supply) so the poller fires once
|
||||
on a downward crossing and re-arms after a refill. Idempotent create.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'printers0002supplyalerts'
|
||||
down_revision = 'printers0001anchor'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if 'printersupplyalerts' in inspector.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
'printersupplyalerts',
|
||||
sa.Column('printersupplyalertid', sa.Integer(), primary_key=True),
|
||||
sa.Column('printerid', sa.Integer(), nullable=False),
|
||||
sa.Column('supplykey', sa.String(length=64), nullable=False),
|
||||
sa.Column('lasttier', sa.String(length=16), nullable=False,
|
||||
server_default='ok'),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False,
|
||||
server_default=sa.true()),
|
||||
sa.ForeignKeyConstraint(['printerid'], ['printers.printerid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.UniqueConstraint('printerid', 'supplykey',
|
||||
name='uq_printersupplyalert_printer_key'),
|
||||
)
|
||||
op.create_index('ix_printersupplyalerts_printerid',
|
||||
'printersupplyalerts', ['printerid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if 'printersupplyalerts' not in inspector.get_table_names():
|
||||
return
|
||||
op.drop_table('printersupplyalerts')
|
||||
@@ -8,12 +8,14 @@ from .model_supply import ( # data-driven model -> toner/drum/waste mapping
|
||||
SUPPLY_COLORS,
|
||||
CAPACITY_TIERS,
|
||||
)
|
||||
from .supply_alert import PrinterSupplyAlert # per-printer toner alert state
|
||||
|
||||
__all__ = [
|
||||
'Printer',
|
||||
'PrinterType',
|
||||
'PrinterDriver',
|
||||
'ModelSupply',
|
||||
'PrinterSupplyAlert',
|
||||
'SUPPLY_TYPES',
|
||||
'SUPPLY_COLORS',
|
||||
'CAPACITY_TIERS',
|
||||
|
||||
40
plugins/printers/models/supply_alert.py
Normal file
40
plugins/printers/models/supply_alert.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Per-printer supply alert state.
|
||||
|
||||
The supply poller (flask printers check-toner-alerts) is stateless: it reads
|
||||
live levels from Zabbix each run. To alert once per downward crossing (and
|
||||
re-arm after a refill) it needs to remember the last tier it alerted for each
|
||||
printer + supply. One row per (printerid, supplykey); supplykey is the toner
|
||||
color (black/cyan/magenta/yellow) or the raw item name when color is unknown.
|
||||
"""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
class PrinterSupplyAlert(BaseModel):
|
||||
"""Last-alerted tier for one printer supply. lasttier in ok/warning/critical."""
|
||||
|
||||
__tablename__ = 'printersupplyalerts'
|
||||
|
||||
printersupplyalertid = db.Column(db.Integer, primary_key=True)
|
||||
printerid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('printers.printerid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
supplykey = db.Column(
|
||||
db.String(64),
|
||||
nullable=False,
|
||||
comment='Toner color, or Zabbix item name when color is unknown',
|
||||
)
|
||||
lasttier = db.Column(
|
||||
db.String(16),
|
||||
nullable=False,
|
||||
default='ok',
|
||||
comment='Last tier alerted: ok, warning, or critical',
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('printerid', 'supplykey',
|
||||
name='uq_printersupplyalert_printer_key'),
|
||||
)
|
||||
@@ -9,9 +9,11 @@ from flask import Flask, Blueprint
|
||||
import click
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, AssetType
|
||||
from shopdb.api import db, AssetType, Setting
|
||||
|
||||
from .models import Printer, PrinterType, ModelSupply, PrinterDriver
|
||||
from .models import (
|
||||
Printer, PrinterType, ModelSupply, PrinterDriver, PrinterSupplyAlert
|
||||
)
|
||||
from .api import printers_asset_bp
|
||||
from .services import ZabbixService
|
||||
|
||||
@@ -76,6 +78,7 @@ class PrintersPlugin(BasePlugin):
|
||||
PrinterType, # printer type classification
|
||||
PrinterDriver, # driver links (SMB/HTTP)
|
||||
ModelSupply, # model -> toner/drum/waste part numbers
|
||||
PrinterSupplyAlert, # per-printer toner alert crossing state
|
||||
]
|
||||
|
||||
def get_services(self) -> Dict[str, Type]:
|
||||
@@ -103,8 +106,41 @@ class PrintersPlugin(BasePlugin):
|
||||
with app.app_context():
|
||||
self._ensure_asset_type()
|
||||
self._ensure_printer_types()
|
||||
self._seed_settings()
|
||||
logger.info("Printers plugin installed")
|
||||
|
||||
def on_enable(self, app: Flask) -> None:
|
||||
# Idempotent re-seed so settings added in later versions reach sites
|
||||
# that installed earlier (enable runs on every upgrade cycle).
|
||||
with app.app_context():
|
||||
self._seed_settings()
|
||||
|
||||
def _seed_settings(self) -> None:
|
||||
"""Seed low-toner alert recipient settings (idempotent)."""
|
||||
defaults = [
|
||||
('printers_alert_email', '', 'string',
|
||||
'Comma-separated low-toner alert recipients; empty uses the '
|
||||
'site alert_recipients'),
|
||||
('printers_alert_userids', '', 'string',
|
||||
'Comma-separated shopdb user ids whose account emails receive '
|
||||
'low-toner alerts'),
|
||||
('printers_alert_roleids', '', 'string',
|
||||
'Comma-separated role ids; every active member of these roles '
|
||||
'receives low-toner alerts'),
|
||||
('printers_alert_supportteamid', '', 'string',
|
||||
'Support team whose webhook receives low-toner alerts; empty '
|
||||
'uses the site alert_webhook_url'),
|
||||
('printers_alert_warning_threshold', '5', 'integer',
|
||||
'Toner percent remaining at or below which a warning email fires'),
|
||||
('printers_alert_critical_threshold', '0', 'integer',
|
||||
'Toner percent remaining at or below which a critical email fires'),
|
||||
]
|
||||
for key, value, valuetype, description in defaults:
|
||||
if Setting.get(key) is None:
|
||||
Setting.set(key, value, valuetype=valuetype,
|
||||
category='printers', description=description)
|
||||
db.session.commit()
|
||||
|
||||
def _ensure_asset_type(self) -> None:
|
||||
"""Ensure printer asset type exists."""
|
||||
existing = AssetType.query.filter_by(assettype='printer').first()
|
||||
@@ -151,6 +187,19 @@ class PrintersPlugin(BasePlugin):
|
||||
"""Called when plugin is uninstalled."""
|
||||
logger.info("Printers plugin uninstalled")
|
||||
|
||||
def get_settings_cards(self) -> List[dict]:
|
||||
return [
|
||||
{
|
||||
'group': 'Printers',
|
||||
'to': '/settings/printer-alerts',
|
||||
'icon': 'bell',
|
||||
'title': 'Low-Toner Alerts',
|
||||
'description': 'Who gets warning (5%) and critical (0%) toner '
|
||||
'emails, and which support team webhook',
|
||||
'position': 48,
|
||||
},
|
||||
]
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return CLI commands for this plugin."""
|
||||
|
||||
@@ -159,6 +208,22 @@ class PrintersPlugin(BasePlugin):
|
||||
"""Printers plugin commands."""
|
||||
pass
|
||||
|
||||
@printerscli.command('check-toner-alerts')
|
||||
def checktoneralerts():
|
||||
"""Poll Zabbix for all printers and email/webhook low-toner crossings.
|
||||
|
||||
Run on a schedule (scheduled task / cron). Fires a warning at or
|
||||
below 5 percent and a critical at 0 percent, once per crossing."""
|
||||
from flask import current_app
|
||||
from .services import check_supplies
|
||||
|
||||
with current_app.app_context():
|
||||
summary = check_supplies()
|
||||
click.echo(
|
||||
f"Toner poll: {summary['polled']}/{summary['printers']} "
|
||||
f"printers reachable, {summary['alerts']} alert(s) sent, "
|
||||
f"{summary['rearmed']} re-armed.")
|
||||
|
||||
@printerscli.command('check-supplies')
|
||||
@click.argument('ip')
|
||||
def checksupplies(ip):
|
||||
|
||||
@@ -6,7 +6,9 @@ from .supply_parts import (
|
||||
derivesupplytype,
|
||||
derivecolor,
|
||||
lookupsupplies,
|
||||
alerttier,
|
||||
)
|
||||
from .supply_alerts import check_supplies
|
||||
from .seed_supplies import seedsupplies
|
||||
|
||||
__all__ = [
|
||||
@@ -15,5 +17,7 @@ __all__ = [
|
||||
'derivesupplytype',
|
||||
'derivecolor',
|
||||
'lookupsupplies',
|
||||
'alerttier',
|
||||
'check_supplies',
|
||||
'seedsupplies',
|
||||
]
|
||||
|
||||
190
plugins/printers/services/supply_alerts.py
Normal file
190
plugins/printers/services/supply_alerts.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Low-toner email/webhook alerting.
|
||||
|
||||
The report shows live levels; this fires an alert when a toner crosses a
|
||||
threshold on its way DOWN. A warning email fires at or below 5 percent, a
|
||||
critical email at 0 percent (empty). State lives in printersupplyalerts so an
|
||||
alert fires once per crossing and re-arms only after a refill.
|
||||
|
||||
Recipients mirror the printedparts pattern: a plugin-scoped set of shopdb
|
||||
users + roles + a free-text email list, falling back to the site-wide
|
||||
alert_recipients; the webhook routes to a chosen support team's webhook,
|
||||
falling back to the site-wide alert_webhook_url.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from shopdb.api import db, Setting
|
||||
|
||||
from ..models import Printer, PrinterSupplyAlert
|
||||
from .supply_parts import derivesupplytype, derivecolor, alerttier, TIER_RANK
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def alert_recipients():
|
||||
"""Merge selected shopdb users' account emails with the free-text list.
|
||||
|
||||
Empty result means fall back to the site-wide alert_recipients."""
|
||||
from shopdb.api import User, Role
|
||||
recipients = []
|
||||
userids = (Setting.get('printers_alert_userids') or '').strip()
|
||||
for rawid in userids.split(','):
|
||||
rawid = rawid.strip()
|
||||
if not rawid.isdigit():
|
||||
continue
|
||||
user = db.session.get(User, int(rawid))
|
||||
if user and user.isactive and user.email:
|
||||
recipients.append(user.email)
|
||||
roleids = (Setting.get('printers_alert_roleids') or '').strip()
|
||||
for rawid in roleids.split(','):
|
||||
rawid = rawid.strip()
|
||||
if not rawid.isdigit():
|
||||
continue
|
||||
role = db.session.get(Role, int(rawid))
|
||||
if role:
|
||||
for user in role.users:
|
||||
if user.isactive and user.email:
|
||||
recipients.append(user.email)
|
||||
freetext = Setting.get('printers_alert_email') or ''
|
||||
for addr in freetext.replace(';', ',').split(','):
|
||||
addr = addr.strip()
|
||||
if addr:
|
||||
recipients.append(addr)
|
||||
# de-dupe, preserve order
|
||||
seen = set()
|
||||
unique = []
|
||||
for addr in recipients:
|
||||
low = addr.lower()
|
||||
if low not in seen:
|
||||
seen.add(low)
|
||||
unique.append(addr)
|
||||
return unique
|
||||
|
||||
|
||||
def _threshold(key, default):
|
||||
"""Read a numeric threshold setting, tolerating blank/garbage values."""
|
||||
try:
|
||||
raw = Setting.get(key)
|
||||
return float(raw) if raw not in (None, '') else float(default)
|
||||
except (ValueError, TypeError):
|
||||
return float(default)
|
||||
|
||||
|
||||
def site_alert_recipients():
|
||||
"""Site-wide alert_recipients setting as a clean list (email fallback)."""
|
||||
raw = Setting.get('alert_recipients') or ''
|
||||
return [r.strip() for r in raw.replace(';', ',').split(',') if r.strip()]
|
||||
|
||||
|
||||
def alert_team_webhook():
|
||||
"""Webhook URL of the support team chosen for toner alerts, or None.
|
||||
|
||||
printers_alert_supportteamid selects a SupportTeam; alerts route to that
|
||||
team's webhook. None -> send_webhook uses the site-wide default."""
|
||||
team_id = Setting.get('printers_alert_supportteamid')
|
||||
if not team_id:
|
||||
return None
|
||||
try:
|
||||
from shopdb.api import SupportTeam
|
||||
team = db.session.get(SupportTeam, int(team_id))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return (team.webhookurl or None) if team else None
|
||||
|
||||
|
||||
def _printer_ipaddress(printer):
|
||||
"""Primary (else any) communication IP for a printer's asset, or None."""
|
||||
from shopdb.api import Communication
|
||||
asset = printer.asset
|
||||
if not asset:
|
||||
return None
|
||||
comm = Communication.query.filter_by(
|
||||
assetid=asset.assetid, isprimary=True).first() \
|
||||
or Communication.query.filter_by(assetid=asset.assetid).first()
|
||||
return comm.ipaddress if comm else None
|
||||
|
||||
|
||||
def _fire_alert(printer, color, remaining, tier):
|
||||
"""Best-effort email + webhook for one toner crossing. Never raises."""
|
||||
from shopdb.api import send_email, send_webhook
|
||||
name = printer.asset.name or printer.asset.assetnumber if printer.asset \
|
||||
else f'Printer {printer.printerid}'
|
||||
label = 'CRITICAL - empty' if tier == 'critical' else 'Low'
|
||||
subject = f'{label} toner: {name} ({color}) - {remaining:.0f}% remaining'
|
||||
base = (Setting.get('site_base_url') or '').rstrip('/')
|
||||
link = f'{base}/printers/{printer.printerid}' if base else ''
|
||||
link_html = f'<p><a href="{link}">View {name}</a></p>' if link else ''
|
||||
html = (f'<p><strong>{name}</strong> {color} toner is at '
|
||||
f'<strong>{remaining:.0f}%</strong> ({tier}).</p>{link_html}')
|
||||
webhook_text = f'{name} {color} toner at {remaining:.0f}% ({tier})'
|
||||
try:
|
||||
send_webhook(subject, webhook_text, url=alert_team_webhook())
|
||||
recipients = alert_recipients() or site_alert_recipients()
|
||||
if recipients:
|
||||
send_email(recipients, subject, html)
|
||||
except Exception:
|
||||
logger.exception('Toner alert failed for printer %s', printer.printerid)
|
||||
|
||||
|
||||
def check_supplies(service=None):
|
||||
"""Poll Zabbix for all active printers' toner and alert on crossings.
|
||||
|
||||
Returns a summary dict. Intended to run on a schedule (a scheduled task or
|
||||
cron calling `flask printers check-toner-alerts`)."""
|
||||
from shopdb.api import Asset
|
||||
from .zabbix_service import ZabbixService
|
||||
|
||||
service = service or ZabbixService()
|
||||
summary = {'printers': 0, 'polled': 0, 'alerts': 0, 'rearmed': 0}
|
||||
if not service.isconfigured:
|
||||
logger.warning('Zabbix not configured; skipping toner alert poll')
|
||||
return summary
|
||||
|
||||
warning_threshold = _threshold('printers_alert_warning_threshold', 5)
|
||||
critical_threshold = _threshold('printers_alert_critical_threshold', 0)
|
||||
|
||||
printers = db.session.query(Printer).join(Asset).filter(
|
||||
Asset.isactive == True).all() # noqa: E712
|
||||
for printer in printers:
|
||||
summary['printers'] += 1
|
||||
ipaddress = _printer_ipaddress(printer)
|
||||
if not ipaddress or ipaddress == 'USB':
|
||||
continue
|
||||
supplies = service.getsuppliesbyip(ipaddress)
|
||||
if not supplies:
|
||||
continue
|
||||
summary['polled'] += 1
|
||||
for supply in supplies:
|
||||
supplyname = supply.get('name', '')
|
||||
if derivesupplytype(supplyname) != 'toner':
|
||||
continue
|
||||
try:
|
||||
remaining = float(supply.get('level', 0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
color = supply.get('color') or derivecolor(supplyname)
|
||||
supplykey = color if color and color != 'none' else supplyname
|
||||
supplykey = supplykey[:64]
|
||||
tier = alerttier(remaining, warning_threshold, critical_threshold)
|
||||
|
||||
row = PrinterSupplyAlert.query.filter_by(
|
||||
printerid=printer.printerid, supplykey=supplykey).first()
|
||||
prior = row.lasttier if row else 'ok'
|
||||
if tier == prior:
|
||||
continue
|
||||
if row is None:
|
||||
row = PrinterSupplyAlert(
|
||||
printerid=printer.printerid, supplykey=supplykey,
|
||||
lasttier=tier)
|
||||
db.session.add(row)
|
||||
else:
|
||||
row.lasttier = tier
|
||||
|
||||
if TIER_RANK[tier] > TIER_RANK[prior]:
|
||||
_fire_alert(printer, color, remaining, tier)
|
||||
summary['alerts'] += 1
|
||||
else:
|
||||
summary['rearmed'] += 1
|
||||
|
||||
db.session.commit()
|
||||
return summary
|
||||
@@ -10,10 +10,34 @@ and reading the matching part numbers out of the database.
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
# alert thresholds (percent remaining)
|
||||
# display thresholds (percent remaining) for the ok/low/critical report badge
|
||||
CRITICAL_THRESHOLD = 5
|
||||
LOW_THRESHOLD = 10
|
||||
|
||||
# toner email-alert tiers (percent remaining). Distinct from the display
|
||||
# badge above: a warning email fires at or below TONER_WARNING_THRESHOLD, a
|
||||
# critical email at or below TONER_CRITICAL_THRESHOLD (empty).
|
||||
TONER_WARNING_THRESHOLD = 5
|
||||
TONER_CRITICAL_THRESHOLD = 0
|
||||
|
||||
|
||||
def alerttier(remaining: float, warning: float = TONER_WARNING_THRESHOLD,
|
||||
critical: float = TONER_CRITICAL_THRESHOLD) -> str:
|
||||
"""Map a toner percent-remaining to an email-alert tier.
|
||||
|
||||
critical at or below the critical threshold (default 0 = empty), warning at
|
||||
or below the warning threshold (default 5), else ok. Both are configurable
|
||||
(printers_alert_critical_threshold / printers_alert_warning_threshold)."""
|
||||
if remaining <= critical:
|
||||
return 'critical'
|
||||
if remaining <= warning:
|
||||
return 'warning'
|
||||
return 'ok'
|
||||
|
||||
|
||||
# tier severity rank; an alert fires only when the rank increases (worsens)
|
||||
TIER_RANK = {'ok': 0, 'warning': 1, 'critical': 2}
|
||||
|
||||
|
||||
def derivesupplytype(name: str) -> str:
|
||||
"""Map a Zabbix item name to a supply type."""
|
||||
|
||||
108
scripts/build-offline-bundle.ps1
Normal file
108
scripts/build-offline-bundle.ps1
Normal file
@@ -0,0 +1,108 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Build the shopdb-flask image on a CONNECTED box and bundle it (plus the MySQL
|
||||
image) into one tarball for transfer to an air-gapped site.
|
||||
|
||||
.DESCRIPTION
|
||||
Air-gapped sites cannot pip install / npm ci / docker pull. This builds the
|
||||
fully self-contained application image where connectivity exists, pulls the
|
||||
MySQL image the stack needs, and `docker save`s both into a single gzipped
|
||||
tarball with a SHA-256 checksum. Carry the .tar.gz to the site and follow
|
||||
docs/DEPLOY-AIRGAP.md (docker load + docker-compose.airgap.yml).
|
||||
|
||||
Run from the repo root. Requires Docker Desktop. The connected build box needs
|
||||
clean access to Docker Hub + PyPI + npm. If it is behind GE Zscaler and the
|
||||
build fails on pip/npm SSL (CERTIFICATE_VERIFY_FAILED), build from a box with
|
||||
clean internet, or trust the corp root CA in the build - see
|
||||
docs/DEPLOY-AIRGAP.md "Building behind Zscaler".
|
||||
|
||||
.PARAMETER Version
|
||||
Image tag for shopdb-flask (default 0.7.0). This is the IMAGE_TAG the site
|
||||
sets in its .env so docker-compose.airgap.yml runs the matching image.
|
||||
|
||||
.PARAMETER MysqlImage
|
||||
MySQL image the stack runs (default mysql:8.0). Must match db.image in
|
||||
docker-compose.airgap.yml.
|
||||
|
||||
.PARAMETER OutDir
|
||||
Where to write the tarball + checksum (default the repo root).
|
||||
|
||||
.PARAMETER SkipBuild
|
||||
Reuse an already-built shopdb-flask:<Version> image; only pull + save.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh scripts/build-offline-bundle.ps1 -Version 0.7.0
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Version = '0.7.0',
|
||||
[string]$MysqlImage = 'mysql:8.0',
|
||||
[string]$OutDir = '.',
|
||||
[switch]$SkipBuild
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Assert-LastExit([string]$What) {
|
||||
if ($LASTEXITCODE -ne 0) { throw "$What failed (exit $LASTEXITCODE)" }
|
||||
}
|
||||
|
||||
# Fail early if docker is missing rather than midway through a long build.
|
||||
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
|
||||
throw 'docker not found on PATH. Install Docker Desktop and retry.'
|
||||
}
|
||||
|
||||
$appImage = "shopdb-flask:$Version"
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
if (-not $SkipBuild) {
|
||||
Write-Host "==> Building $appImage" -ForegroundColor Cyan
|
||||
docker build -t $appImage .
|
||||
Assert-LastExit 'docker build'
|
||||
} else {
|
||||
Write-Host "==> Skipping build; reusing $appImage" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host "==> Pulling $MysqlImage" -ForegroundColor Cyan
|
||||
docker pull $MysqlImage
|
||||
Assert-LastExit 'docker pull'
|
||||
|
||||
if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null }
|
||||
$stem = "shopdb-stack-$Version"
|
||||
$tar = Join-Path $OutDir "$stem.tar"
|
||||
$gz = Join-Path $OutDir "$stem.tar.gz"
|
||||
|
||||
Write-Host "==> Saving $appImage + $MysqlImage" -ForegroundColor Cyan
|
||||
docker save -o $tar $appImage $MysqlImage
|
||||
Assert-LastExit 'docker save'
|
||||
|
||||
Write-Host "==> Compressing to $gz" -ForegroundColor Cyan
|
||||
$inStream = [System.IO.File]::OpenRead($tar)
|
||||
$outStream = [System.IO.File]::Create($gz)
|
||||
$gzip = New-Object System.IO.Compression.GzipStream($outStream, [System.IO.Compression.CompressionMode]::Compress)
|
||||
try {
|
||||
$inStream.CopyTo($gzip)
|
||||
} finally {
|
||||
$gzip.Dispose(); $outStream.Dispose(); $inStream.Dispose()
|
||||
}
|
||||
Remove-Item $tar
|
||||
|
||||
$hash = (Get-FileHash $gz -Algorithm SHA256).Hash.ToLower()
|
||||
$sumFile = "$gz.sha256"
|
||||
"$hash $stem.tar.gz" | Set-Content -Path $sumFile -NoNewline
|
||||
$sizeMb = [math]::Round((Get-Item $gz).Length / 1MB, 1)
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "Bundle ready: $gz ($sizeMb MB)" -ForegroundColor Green
|
||||
Write-Host "SHA-256: $hash"
|
||||
Write-Host "Checksum: $sumFile"
|
||||
Write-Host ''
|
||||
Write-Host 'At the air-gapped site (verify the checksum first):'
|
||||
Write-Host " docker load -i $stem.tar.gz"
|
||||
Write-Host " # set IMAGE_TAG=$Version in .env, then:"
|
||||
Write-Host ' docker compose -f docker-compose.airgap.yml up -d'
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -36,7 +36,7 @@ from .plugins import plugin_manager
|
||||
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
|
||||
# managed service token without importing core token internals. Additive name
|
||||
# on the import surface, minor bump.
|
||||
__contract_version__ = '0.14.0'
|
||||
__contract_version__ = '0.15.0'
|
||||
|
||||
# Product release version (see ADR-007). The product version and the
|
||||
# plugin-contract version above are distinct series with independent
|
||||
|
||||
@@ -46,6 +46,7 @@ from shopdb.core.models import (
|
||||
RelationshipType,
|
||||
User,
|
||||
Role,
|
||||
SupportTeam,
|
||||
)
|
||||
|
||||
# Response + pagination helpers for plugin API blueprints
|
||||
@@ -63,7 +64,9 @@ from shopdb.utils.authz import require_permission, require_role
|
||||
# Service-token authorization for unattended plugin endpoints (collector,
|
||||
# GE-Enforce fetch, ...): checks a scoped managed token without exposing token
|
||||
# internals.
|
||||
from shopdb.utils.apitoken_auth import service_token_authorized
|
||||
from shopdb.utils.apitoken_auth import (
|
||||
service_token_authorized, authorized_service_token,
|
||||
)
|
||||
|
||||
# Import-mode helpers: preserve legacy timestamps during a bulk data import
|
||||
from shopdb.utils.import_mode import (
|
||||
@@ -263,6 +266,8 @@ __all__ = [
|
||||
'require_permission',
|
||||
'require_role',
|
||||
'service_token_authorized',
|
||||
'authorized_service_token',
|
||||
'SupportTeam',
|
||||
# Import-mode helpers
|
||||
'apply_import_timestamps',
|
||||
'import_mode_active',
|
||||
|
||||
@@ -45,6 +45,24 @@ def _validate_scopes(scopes, owner):
|
||||
return scopes, None
|
||||
|
||||
|
||||
def _validate_resourcescopes(resourcescopes):
|
||||
"""Validate a resourcescopes payload. Return (list_or_none, error_response).
|
||||
|
||||
resourcescopes is a DIFFERENT axis from scopes: it pins the token to a set
|
||||
of resource names (today geenforce manifest scope names, e.g.
|
||||
gea-shopfloor-display), not permission names, so there is no catalog to
|
||||
check against - a plugin owns the meaning. None/absent = unrestricted.
|
||||
"""
|
||||
if resourcescopes is None:
|
||||
return None, None
|
||||
if not isinstance(resourcescopes, list) \
|
||||
or not all(isinstance(s, str) for s in resourcescopes):
|
||||
return None, error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'resourcescopes must be a list of resource-scope names')
|
||||
return [s.strip() for s in resourcescopes if s.strip()], None
|
||||
|
||||
|
||||
@apitokens_bp.route('', methods=['GET'])
|
||||
@jwt_required()
|
||||
def list_apitokens():
|
||||
@@ -93,6 +111,11 @@ def create_apitoken():
|
||||
if scope_error is not None:
|
||||
return scope_error
|
||||
|
||||
resourcescopelist, resource_error = _validate_resourcescopes(
|
||||
data.get('resourcescopes'))
|
||||
if resource_error is not None:
|
||||
return resource_error
|
||||
|
||||
secret = ApiToken.generate_secret()
|
||||
token = ApiToken(
|
||||
userid=current_user.userid,
|
||||
@@ -102,6 +125,7 @@ def create_apitoken():
|
||||
expiresat=expiresat,
|
||||
)
|
||||
token.scopelist = scopelist
|
||||
token.resourcescopelist = resourcescopelist
|
||||
db.session.add(token)
|
||||
db.session.flush()
|
||||
|
||||
@@ -147,6 +171,12 @@ def update_apitoken(tokenid: int):
|
||||
if scope_error is not None:
|
||||
return scope_error
|
||||
token.scopelist = scopelist
|
||||
if 'resourcescopes' in data:
|
||||
resourcescopelist, resource_error = _validate_resourcescopes(
|
||||
data.get('resourcescopes'))
|
||||
if resource_error is not None:
|
||||
return resource_error
|
||||
token.resourcescopelist = resourcescopelist
|
||||
|
||||
db.session.commit()
|
||||
return success_response(token.to_dict(), message='Token updated')
|
||||
|
||||
@@ -53,6 +53,13 @@ class ApiToken(BaseModel):
|
||||
# authority). A scoped token grants ONLY these, intersected with what the
|
||||
# owner holds, and suspends the admin bypass. See scopelist below.
|
||||
scopes = db.Column(db.Text, nullable=True)
|
||||
# JSON array of RESOURCE-scope names (a different axis from `scopes`, which
|
||||
# is what the token may DO). Today these are geenforce manifest scope names
|
||||
# (e.g. gea-shopfloor-display): a resource-bound geenforce.fetch token may
|
||||
# only pull those scopes' manifests and only blobs those manifests
|
||||
# reference. NULL = unrestricted (any resource), for back-compat. See
|
||||
# resourcescopelist below.
|
||||
resourcescopes = db.Column(db.Text, nullable=True)
|
||||
|
||||
user = db.relationship('User', backref=db.backref('apitokens', lazy='dynamic'))
|
||||
|
||||
@@ -98,6 +105,25 @@ class ApiToken(BaseModel):
|
||||
else:
|
||||
self.scopes = json.dumps(list(names))
|
||||
|
||||
@property
|
||||
def resourcescopelist(self):
|
||||
"""Parsed resource-scope names, or None when the token is unrestricted."""
|
||||
if self.resourcescopes is None:
|
||||
return None
|
||||
try:
|
||||
value = json.loads(self.resourcescopes)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return value if isinstance(value, list) else None
|
||||
|
||||
@resourcescopelist.setter
|
||||
def resourcescopelist(self, names):
|
||||
"""Store a resource-scope list, or None to clear the restriction."""
|
||||
if names is None:
|
||||
self.resourcescopes = None
|
||||
else:
|
||||
self.resourcescopes = json.dumps(list(names))
|
||||
|
||||
@staticmethod
|
||||
def unknown_scope_names(names) -> list:
|
||||
"""Return the subset of names that are not in the permission catalog.
|
||||
@@ -119,6 +145,7 @@ class ApiToken(BaseModel):
|
||||
'expiresat': self.expiresat.isoformat() + 'Z' if self.expiresat else None,
|
||||
'lastusedat': self.lastusedat.isoformat() + 'Z' if self.lastusedat else None,
|
||||
'scopes': self.scopelist,
|
||||
'resourcescopes': self.resourcescopelist,
|
||||
'isactive': self.isactive,
|
||||
'isexpired': self.is_expired,
|
||||
'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
|
||||
|
||||
@@ -46,6 +46,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'manifestentryhostnames', 'manifestentrymachinenumbers',
|
||||
'manifestinusechecks', 'manifestinusecheckprocesses',
|
||||
'manifestpublishedversions', 'manifestpayloads',
|
||||
'manifestblobs',
|
||||
'manifestenforcementreports', 'manifestenforcementresults',
|
||||
'pctypealiases'),
|
||||
'knowledgebase': ('knowledgebase',),
|
||||
@@ -55,7 +56,8 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'notifications': ('notificationtypes', 'notifications'),
|
||||
'printedparts': ('printeditems', 'printeditemtransactions',
|
||||
'printeditemfiles'),
|
||||
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'),
|
||||
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers',
|
||||
'printersupplyalerts'),
|
||||
'slides': ('tvslides',),
|
||||
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
|
||||
'warranty': ('warranties', 'warrantyassets'),
|
||||
|
||||
@@ -76,16 +76,13 @@ def touch_apitoken_lastused(token):
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def service_token_authorized(scope):
|
||||
"""True when the current request carries a managed token scoped for `scope`
|
||||
whose owner is active and holds that permission. Accepts X-API-Key or a
|
||||
Bearer PAT (the before_request shim resolves Bearer into g.apitokenid).
|
||||
Touches lastusedat on success.
|
||||
def authorized_service_token(scope):
|
||||
"""Return the ApiToken authorizing this request for `scope`, or None.
|
||||
|
||||
The single contract-surface entry point for unattended SERVICE tokens
|
||||
(collector.ingest, geenforce.fetch, ...), so plugins authorize a service
|
||||
token without reaching into core token internals. Returns False on any
|
||||
miss; the caller returns its own 401.
|
||||
Same checks as service_token_authorized (managed token scoped for `scope`,
|
||||
active owner holding the permission), but hands back the token itself so a
|
||||
caller can read its resource binding (token.resourcescopelist) without
|
||||
reaching into core token internals. Touches lastusedat on success.
|
||||
"""
|
||||
from shopdb.core.models import User
|
||||
|
||||
@@ -99,15 +96,29 @@ def service_token_authorized(scope):
|
||||
if tokenid is not None:
|
||||
token = db.session.get(ApiToken, tokenid)
|
||||
if token is None:
|
||||
return False
|
||||
return None
|
||||
scopelist = token.scopelist
|
||||
if not scopelist or scope not in scopelist:
|
||||
return False
|
||||
return None
|
||||
user = db.session.get(User, token.userid)
|
||||
if user is None or not user.isactive or not user.haspermission(scope):
|
||||
return False
|
||||
return None
|
||||
touch_apitoken_lastused(token)
|
||||
return True
|
||||
return token
|
||||
|
||||
|
||||
def service_token_authorized(scope):
|
||||
"""True when the current request carries a managed token scoped for `scope`
|
||||
whose owner is active and holds that permission. Accepts X-API-Key or a
|
||||
Bearer PAT (the before_request shim resolves Bearer into g.apitokenid).
|
||||
Touches lastusedat on success.
|
||||
|
||||
The single contract-surface entry point for unattended SERVICE tokens
|
||||
(collector.ingest, geenforce.fetch, ...), so plugins authorize a service
|
||||
token without reaching into core token internals. Returns False on any
|
||||
miss; the caller returns its own 401.
|
||||
"""
|
||||
return authorized_service_token(scope) is not None
|
||||
|
||||
|
||||
def install_apitoken_auth(app):
|
||||
|
||||
@@ -46,7 +46,11 @@ CUTOVER_PLUGINS = (
|
||||
# stamp '<plugin>0001anchor'; measuringtools stamps its real baseline id.
|
||||
EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS}
|
||||
EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
|
||||
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0001baseline'
|
||||
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
|
||||
# baseline.
|
||||
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0002blobs'
|
||||
# printers adds the printersupplyalerts crossing-state table on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['printers'] = 'printers0002supplyalerts'
|
||||
# machines (renamed from equipment) keeps its original anchor id and adds the
|
||||
# rename revision on top, so its head is not the f-string default.
|
||||
EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
|
||||
@@ -54,8 +58,9 @@ EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
|
||||
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
|
||||
# usb drops the dead usbcheckouts.machineid column on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
|
||||
# printedparts is post-cutover: its 0001 really creates its tables.
|
||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0003gagetag'
|
||||
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
|
||||
# the per-transaction revision column.
|
||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
||||
# notifications indexes businessunitid on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx'
|
||||
|
||||
|
||||
108
tests/test_plugins/test_geenforce_blob_parity.py
Normal file
108
tests/test_plugins/test_geenforce_blob_parity.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""GE-Enforce manifestblobs model-vs-migration parity gate.
|
||||
|
||||
manifestblobs is created by the SECOND geenforce migration
|
||||
(0002_manifest_blobs.py), and the existing DDL-parity test only diffs the
|
||||
baseline (0001) tables - it does not even list ManifestBlob. So the http-blob
|
||||
store table has had NO parity coverage: a column added to the ManifestBlob model
|
||||
but not the migration (or vice versa) would ship a broken schema silently.
|
||||
|
||||
This gate builds manifestblobs two ways and diffs it column-by-column:
|
||||
1. the ManifestBlob model via metadata.create_all (desired shape), and
|
||||
2. the 0002 migration's upgrade() run against a fresh engine (shipped shape).
|
||||
Both go through the same SQLite dialect so reflected types render identically.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
|
||||
_BLOBS_MIGRATION = (Path(__file__).resolve().parent.parent.parent / 'plugins' /
|
||||
'geenforce' / 'migrations' / 'versions' /
|
||||
'0002_manifest_blobs.py')
|
||||
|
||||
_EXPECTED_COLUMNS = ('sha256', 'filename', 'contenttype', 'sizebytes',
|
||||
'createdat')
|
||||
|
||||
|
||||
def _migration_columns():
|
||||
"""Run the 0002 migration upgrade() on a fresh engine; return the reflected
|
||||
manifestblobs columns as {name: column-dict}."""
|
||||
spec = importlib.util.spec_from_file_location('geenforce_blobs',
|
||||
str(_BLOBS_MIGRATION))
|
||||
migration = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(migration)
|
||||
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
operations._install_proxy()
|
||||
try:
|
||||
migration.upgrade()
|
||||
finally:
|
||||
operations._remove_proxy()
|
||||
connection.commit()
|
||||
inspector = inspect(connection)
|
||||
assert 'manifestblobs' in inspector.get_table_names(), \
|
||||
'migration did not create manifestblobs'
|
||||
return {column['name']: column
|
||||
for column in inspector.get_columns('manifestblobs')}
|
||||
|
||||
|
||||
def _model_columns(db):
|
||||
"""Build manifestblobs from the ManifestBlob model; return {name: column}."""
|
||||
from plugins.geenforce.models.manifest import ManifestBlob
|
||||
|
||||
engine = create_engine('sqlite://')
|
||||
db.metadata.create_all(engine, tables=[ManifestBlob.__table__])
|
||||
return {column['name']: column
|
||||
for column in inspect(engine).get_columns('manifestblobs')}
|
||||
|
||||
|
||||
def _colkey(column):
|
||||
"""Normalize a reflected column to the facets we gate on (type, nullable)."""
|
||||
return (str(column['type']), bool(column['nullable']))
|
||||
|
||||
|
||||
def test_manifestblob_model_matches_migration(app, db):
|
||||
"""Every ManifestBlob column matches what the 0002 migration builds."""
|
||||
with app.app_context():
|
||||
modelcols = _model_columns(db)
|
||||
migrationcols = _migration_columns()
|
||||
|
||||
drift = []
|
||||
for name in sorted(set(modelcols) - set(migrationcols)):
|
||||
drift.append(f'{name}: in model, not in migration')
|
||||
for name in sorted(set(migrationcols) - set(modelcols)):
|
||||
drift.append(f'{name}: in migration, not in model')
|
||||
for name in sorted(set(modelcols) & set(migrationcols)):
|
||||
if _colkey(modelcols[name]) != _colkey(migrationcols[name]):
|
||||
drift.append(
|
||||
f'{name}: model {_colkey(modelcols[name])} != '
|
||||
f'migration {_colkey(migrationcols[name])}')
|
||||
|
||||
assert not drift, ('manifestblobs model/migration DDL drift:\n'
|
||||
+ '\n'.join(drift))
|
||||
|
||||
|
||||
def test_manifestblob_expected_columns_present_both_sides(app, db):
|
||||
"""Lock the load-bearing columns (sha256 primary key + registry fields)
|
||||
exist in BOTH the model and the migration."""
|
||||
with app.app_context():
|
||||
modelcols = _model_columns(db)
|
||||
migrationcols = _migration_columns()
|
||||
for name in _EXPECTED_COLUMNS:
|
||||
assert name in modelcols, f'{name} missing from ManifestBlob model'
|
||||
assert name in migrationcols, f'{name} missing from migration'
|
||||
|
||||
|
||||
def test_manifestblob_sha256_is_not_nullable(app, db):
|
||||
"""The content hash is the primary key: it must be NOT NULL on both sides."""
|
||||
with app.app_context():
|
||||
modelcols = _model_columns(db)
|
||||
migrationcols = _migration_columns()
|
||||
assert modelcols['sha256']['nullable'] is False
|
||||
assert migrationcols['sha256']['nullable'] is False
|
||||
109
tests/test_plugins/test_geenforce_display_seed.py
Normal file
109
tests/test_plugins/test_geenforce_display_seed.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Cover the gea-shopfloor-display authoring seed.
|
||||
|
||||
Running seed_display_scope must produce the display scope with the expected
|
||||
entry count and types: four Registry drift-heal entries (Edge kiosk relaunch
|
||||
policies) plus one inline PS1 dispatcher. Also checks the dispatcher payload is
|
||||
stored inline and the display-type map drives the dispatcher script.
|
||||
"""
|
||||
|
||||
from plugins.geenforce.models import (
|
||||
ManifestScope, ManifestEntry, ManifestPayload,
|
||||
)
|
||||
from plugins.geenforce.seed_display_scope import (
|
||||
seed_display_scope, build_display_manifest, build_dispatcher_script,
|
||||
DISPLAY_TYPE_TARGETS, SCOPE_NAME, DISPATCHER_FILENAME,
|
||||
)
|
||||
|
||||
|
||||
def test_seed_creates_display_scope(db):
|
||||
summary = seed_display_scope()
|
||||
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=SCOPE_NAME, phase='runtime').first()
|
||||
assert scope is not None
|
||||
# A plain runtime scope, not the common scope. Displays are self-sufficient
|
||||
# and do NOT inherit common.
|
||||
assert scope.iscommon is False
|
||||
|
||||
# Four Registry drift-heal entries + one PS1 dispatcher, in order.
|
||||
assert summary['entrycount'] == 5
|
||||
assert summary['entrytypes'] == ['Registry', 'Registry', 'Registry',
|
||||
'Registry', 'PS1']
|
||||
|
||||
|
||||
def test_registry_entries_use_valuematches_detection(db):
|
||||
seed_display_scope()
|
||||
|
||||
registry_entries = ManifestEntry.query.filter_by(
|
||||
entrytype='Registry').order_by(ManifestEntry.sortorder).all()
|
||||
assert len(registry_entries) == 4
|
||||
regnames = {entry.regname for entry in registry_entries}
|
||||
assert regnames == {
|
||||
'RelaunchNotification', 'RelaunchNotificationPeriod',
|
||||
'RelaunchHeadsUpPeriod', 'RelaunchWindow',
|
||||
}
|
||||
for entry in registry_entries:
|
||||
assert entry.detectionmethod == 'ValueMatches'
|
||||
assert entry.regpath == 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Edge'
|
||||
# Detection targets the same value it writes, coerced to string.
|
||||
assert entry.detectionname == entry.regname
|
||||
assert entry.detectionvalue is not None
|
||||
|
||||
|
||||
def test_dispatcher_is_inline_and_data_driven(db):
|
||||
summary = seed_display_scope()
|
||||
|
||||
dispatcher = ManifestEntry.query.filter_by(entrytype='PS1').one()
|
||||
assert dispatcher.payloadsource == 'inline'
|
||||
assert dispatcher.payloadref == DISPATCHER_FILENAME
|
||||
assert dispatcher.payloadsha256 == summary['dispatchersha256']
|
||||
|
||||
payload = ManifestPayload.query.filter_by(
|
||||
entryid=dispatcher.entryid).one()
|
||||
scripttext = payload.payloadbytes.decode('utf-8')
|
||||
# The data-driven map surfaces every display-type and its target route.
|
||||
for display_type, route in DISPLAY_TYPE_TARGETS.items():
|
||||
assert display_type in scripttext
|
||||
assert route in scripttext
|
||||
assert 'display-type.txt' in scripttext
|
||||
|
||||
|
||||
def test_seed_publish_freezes_a_version(db):
|
||||
summary = seed_display_scope(publish=True)
|
||||
assert summary['publishedversion'] == 1
|
||||
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=SCOPE_NAME, phase='runtime').first()
|
||||
published = scope.publishedversions.filter_by(iscurrent=True).first()
|
||||
assert published is not None
|
||||
assert published.versionnumber == 1
|
||||
assert '"Version": "2.0"' in published.manifestjson
|
||||
|
||||
|
||||
def test_seed_draft_is_idempotent(db):
|
||||
first = seed_display_scope()
|
||||
second = seed_display_scope()
|
||||
# Re-running rebuilds the draft to the same shape (same scope, same count).
|
||||
assert first['scopeid'] == second['scopeid']
|
||||
assert first['entrycount'] == second['entrycount']
|
||||
assert first['dispatchersha256'] == second['dispatchersha256']
|
||||
|
||||
entries = ManifestEntry.query.filter_by(scopeid=second['scopeid']).all()
|
||||
assert len(entries) == 5
|
||||
|
||||
|
||||
def test_build_manifest_has_no_smb_exe_payloads(db):
|
||||
manifest = build_display_manifest()
|
||||
# No entry ships an EXE/MSI/File payload from a share: the only payload is
|
||||
# the inline dispatcher; everything else is a Registry policy heal.
|
||||
for entry in manifest['Applications']:
|
||||
assert entry.get('Installer') is None
|
||||
assert entry.get('Source') is None
|
||||
if entry['Type'] == 'PS1':
|
||||
assert entry.get('PayloadSource') == 'inline'
|
||||
|
||||
|
||||
def test_dispatcher_script_is_ascii():
|
||||
# Plain ASCII only (no smart quotes / em-dashes) so the naming gate stays
|
||||
# green and the on-PC script parses cleanly.
|
||||
build_dispatcher_script().encode('ascii')
|
||||
135
tests/test_plugins/test_geenforce_payload_hardening.py
Normal file
135
tests/test_plugins/test_geenforce_payload_hardening.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""GE-Enforce payload-download hardening: rate limit + served-size ceiling.
|
||||
|
||||
GET /api/geenforce/payload/<sha256> is reachable with only a read-only
|
||||
geenforce.fetch token, so a leaked display token must not be able to hammer it
|
||||
or pull unbounded bytes. These tests lock the two bounds added to the route:
|
||||
a per-IP fixed-window rate limit (same cache extension as the login limiter)
|
||||
and a size cap that refuses (413) a blob/inline payload over the configured max.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
from contextlib import contextmanager
|
||||
|
||||
from plugins.geenforce import service
|
||||
from plugins.geenforce.models import ManifestBlob
|
||||
|
||||
|
||||
def _fetch_key(client, auth_headers):
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'svc', 'scopes': ['geenforce.fetch']},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return {'X-API-Key': resp.get_json()['data']['secret']}
|
||||
|
||||
|
||||
def _store(app, raw, filename='setup.exe'):
|
||||
with app.app_context():
|
||||
sha = service.store_blob(raw, filename, 'application/octet-stream')
|
||||
service.db.session.commit()
|
||||
return sha
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _config(app, **overrides):
|
||||
"""Temporarily override app.config keys (the app fixture is session-scoped,
|
||||
so every key is restored to its prior value/absence on exit)."""
|
||||
missing = object()
|
||||
saved = {key: app.config.get(key, missing) for key in overrides}
|
||||
app.config.update(overrides)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for key, value in saved.items():
|
||||
if value is missing:
|
||||
app.config.pop(key, None)
|
||||
else:
|
||||
app.config[key] = value
|
||||
|
||||
|
||||
def test_blob_within_cap_is_served(client, app, auth_headers):
|
||||
raw = b'x' * 64
|
||||
sha = _store(app, raw, 'ok.exe')
|
||||
headers = _fetch_key(client, auth_headers)
|
||||
with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=1024):
|
||||
resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == raw
|
||||
|
||||
|
||||
def test_oversized_blob_refused_413(client, app, auth_headers):
|
||||
raw = b'x' * 64
|
||||
sha = _store(app, raw, 'big.exe')
|
||||
headers = _fetch_key(client, auth_headers)
|
||||
with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=16):
|
||||
resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
|
||||
assert resp.status_code == 413
|
||||
# The blob registry row still exists; the cap gates delivery, not storage.
|
||||
with app.app_context():
|
||||
assert service.db.session.get(ManifestBlob, sha) is not None
|
||||
|
||||
|
||||
def test_oversized_inline_payload_refused_413(client, app, db, auth_headers):
|
||||
scopeid = client.post(
|
||||
'/api/geenforce/scopes',
|
||||
json={'scopename': 'gea-shopfloor-cmm', 'phase': 'runtime'},
|
||||
headers=auth_headers).get_json()['data']['scopeid']
|
||||
entryid = client.post(
|
||||
f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
json={'Name': 'eDNC config', 'Type': 'PS1', 'Script': 's.ps1'},
|
||||
headers=auth_headers).get_json()['data']['entryid']
|
||||
content = b'inline config bytes here'
|
||||
up = client.post(f'/api/geenforce/entries/{entryid}/payload',
|
||||
data={'file': (io.BytesIO(content), 'config.reg')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert up.status_code == 201, up.get_json()
|
||||
sha = hashlib.sha256(content).hexdigest()
|
||||
|
||||
headers = _fetch_key(client, auth_headers)
|
||||
with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=4):
|
||||
resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
|
||||
assert resp.status_code == 413
|
||||
|
||||
|
||||
def test_payload_download_is_rate_limited(client, app, auth_headers):
|
||||
sha = _store(app, b'small blob')
|
||||
key = _fetch_key(client, auth_headers)
|
||||
# A unique caller IP isolates this bucket from other tests' shared counter.
|
||||
headers = {**key, 'X-Forwarded-For': '203.0.113.201'}
|
||||
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=3,
|
||||
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
|
||||
for _ in range(3):
|
||||
ok = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
|
||||
assert ok.status_code == 200, ok.get_json()
|
||||
# The 4th request in the same window is over budget.
|
||||
over = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
|
||||
assert over.status_code == 429
|
||||
|
||||
|
||||
def test_rate_limit_is_per_ip(client, app, auth_headers):
|
||||
"""A second caller (different X-Forwarded-For) has its own budget: one IP
|
||||
being throttled must not throttle everyone."""
|
||||
sha = _store(app, b'per-ip blob')
|
||||
key = _fetch_key(client, auth_headers)
|
||||
hot = {**key, 'X-Forwarded-For': '203.0.113.202'}
|
||||
cool = {**key, 'X-Forwarded-For': '203.0.113.203'}
|
||||
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=1,
|
||||
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
|
||||
assert client.get(f'/api/geenforce/payload/{sha}',
|
||||
headers=hot).status_code == 200
|
||||
assert client.get(f'/api/geenforce/payload/{sha}',
|
||||
headers=hot).status_code == 429
|
||||
# Fresh IP still gets its first request through.
|
||||
assert client.get(f'/api/geenforce/payload/{sha}',
|
||||
headers=cool).status_code == 200
|
||||
|
||||
|
||||
def test_rate_limit_can_be_disabled(client, app, auth_headers):
|
||||
sha = _store(app, b'unthrottled blob')
|
||||
key = _fetch_key(client, auth_headers)
|
||||
headers = {**key, 'X-Forwarded-For': '203.0.113.204'}
|
||||
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_ENABLED=False,
|
||||
GEENFORCE_PAYLOAD_RATELIMIT_MAX=1):
|
||||
for _ in range(5):
|
||||
assert client.get(f'/api/geenforce/payload/{sha}',
|
||||
headers=headers).status_code == 200
|
||||
137
tests/test_plugins/test_geenforce_report_contract.py
Normal file
137
tests/test_plugins/test_geenforce_report_contract.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""GE-Enforce enforcement-report per-entry key contract.
|
||||
|
||||
The server reads LOWERCASE keys from each results[] item -
|
||||
name / action / selfhealed / exitcode / message (see
|
||||
service.record_enforcement_report) - and maps them onto the
|
||||
ManifestEnforcementResult columns entryname / action / selfhealed / exitcode /
|
||||
message. This is the exact contract the PowerShell client fix targets: the
|
||||
client emits lowercase keys, so if the server ever silently switched to reading
|
||||
uppercase keys the client's entryname/selfhealed would drop to empty/false.
|
||||
|
||||
These tests POST a realistic multi-entry cycle with the lowercase keys and lock
|
||||
that the result columns populate, plus the negative case (uppercase keys do NOT
|
||||
populate) so a future contract flip fails loudly here.
|
||||
"""
|
||||
|
||||
from plugins.geenforce import service
|
||||
|
||||
|
||||
SCOPE = {
|
||||
'Version': '2.6',
|
||||
'Applications': [
|
||||
{'Name': 'VNC firewall rule', 'Type': 'PS1', 'Script': 'scripts/vnc.ps1'},
|
||||
{'Name': 'PC-DMIS 2023.1', 'Type': 'MSI', 'Installer': 'apps/pcdmis.msi'},
|
||||
{'Name': 'eDNC config', 'Type': 'PS1', 'Script': 'scripts/ednc.ps1'},
|
||||
{'Name': 'Blancco agent', 'Type': 'MSI', 'Installer': 'apps/blancco.msi'},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _seed_and_publish(app, scopename='gea-shopfloor-cmm'):
|
||||
with app.app_context():
|
||||
service.replace_scope_draft(scopename, 'runtime', SCOPE)
|
||||
service.publish_scope(scopename, 'runtime', notes='v')
|
||||
service.db.session.commit()
|
||||
|
||||
|
||||
def _report_token(client, auth_headers):
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'svc', 'scopes': ['geenforce.report']},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['secret']
|
||||
|
||||
|
||||
def test_lowercase_keys_populate_result_columns(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _report_token(client, auth_headers)
|
||||
|
||||
post = client.post('/api/geenforce/report', json={
|
||||
'hostname': 'WJDISPLAY07',
|
||||
'scopename': 'gea-shopfloor-cmm',
|
||||
'phase': 'runtime',
|
||||
'appliedversion': 1,
|
||||
'enforcerversion': '2.6',
|
||||
'counts': {'installed': 2, 'skipped': 1, 'failed': 1, 'filtered': 0},
|
||||
'results': [
|
||||
{'name': 'VNC firewall rule', 'action': 'installed',
|
||||
'selfhealed': True, 'exitcode': 0, 'message': 'rule re-added'},
|
||||
{'name': 'PC-DMIS 2023.1', 'action': 'skipped',
|
||||
'selfhealed': False, 'exitcode': 0, 'message': ''},
|
||||
{'name': 'eDNC config', 'action': 'installed',
|
||||
'selfhealed': True, 'exitcode': 0},
|
||||
{'name': 'Blancco agent', 'action': 'failed',
|
||||
'selfhealed': False, 'exitcode': 1603, 'message': 'MSI 1603'},
|
||||
],
|
||||
}, headers={'X-API-Key': secret})
|
||||
assert post.status_code == 200, post.get_json()
|
||||
reportid = post.get_json()['data']['reportid']
|
||||
|
||||
detail = client.get(f'/api/geenforce/reports/{reportid}',
|
||||
headers=auth_headers).get_json()['data']
|
||||
byname = {r['entryname']: r for r in detail['results']}
|
||||
|
||||
# Every lowercase 'name' mapped onto entryname (nothing dropped).
|
||||
assert set(byname) == {'VNC firewall rule', 'PC-DMIS 2023.1',
|
||||
'eDNC config', 'Blancco agent'}
|
||||
|
||||
vnc = byname['VNC firewall rule']
|
||||
assert vnc['action'] == 'installed'
|
||||
assert vnc['selfhealed'] is True
|
||||
assert vnc['exitcode'] == 0
|
||||
assert vnc['message'] == 'rule re-added'
|
||||
|
||||
assert byname['PC-DMIS 2023.1']['action'] == 'skipped'
|
||||
assert byname['PC-DMIS 2023.1']['selfhealed'] is False
|
||||
|
||||
ednc = byname['eDNC config']
|
||||
assert ednc['selfhealed'] is True
|
||||
assert ednc['message'] is None # omitted key -> None
|
||||
|
||||
blancco = byname['Blancco agent']
|
||||
assert blancco['action'] == 'failed'
|
||||
assert blancco['exitcode'] == 1603
|
||||
assert blancco['message'] == 'MSI 1603'
|
||||
|
||||
# A failure is present, so the cycle status is 'failed' (a self-heal without
|
||||
# any failure would be 'selfhealed').
|
||||
assert detail['status'] == 'failed'
|
||||
|
||||
|
||||
def test_selfhealed_defaults_false_when_key_omitted(client, db, app,
|
||||
auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _report_token(client, auth_headers)
|
||||
post = client.post('/api/geenforce/report', json={
|
||||
'hostname': 'WJDISPLAY08',
|
||||
'scopename': 'gea-shopfloor-cmm',
|
||||
'results': [{'name': 'asset report', 'action': 'installed'}],
|
||||
}, headers={'X-API-Key': secret})
|
||||
reportid = post.get_json()['data']['reportid']
|
||||
|
||||
detail = client.get(f'/api/geenforce/reports/{reportid}',
|
||||
headers=auth_headers).get_json()['data']
|
||||
result = detail['results'][0]
|
||||
assert result['entryname'] == 'asset report'
|
||||
assert result['selfhealed'] is False
|
||||
|
||||
|
||||
def test_uppercase_keys_do_not_populate(client, db, app, auth_headers):
|
||||
"""Negative lock: the server reads LOWERCASE keys, so an uppercase-keyed
|
||||
item does NOT populate entryname/selfhealed. Guards against a silent flip to
|
||||
uppercase that would break the client's lowercase payload."""
|
||||
_seed_and_publish(app)
|
||||
secret = _report_token(client, auth_headers)
|
||||
post = client.post('/api/geenforce/report', json={
|
||||
'hostname': 'WJDISPLAY09',
|
||||
'scopename': 'gea-shopfloor-cmm',
|
||||
'results': [{'Name': 'wrong casing', 'Action': 'installed',
|
||||
'SelfHealed': True}],
|
||||
}, headers={'X-API-Key': secret})
|
||||
reportid = post.get_json()['data']['reportid']
|
||||
|
||||
detail = client.get(f'/api/geenforce/reports/{reportid}',
|
||||
headers=auth_headers).get_json()['data']
|
||||
result = detail['results'][0]
|
||||
assert result['entryname'] == '' # 'Name' not read -> default ''
|
||||
assert result['selfhealed'] is False # 'SelfHealed' not read -> default
|
||||
128
tests/test_plugins/test_geenforce_resource_binding.py
Normal file
128
tests/test_plugins/test_geenforce_resource_binding.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Resource-scope binding on geenforce fetch tokens.
|
||||
|
||||
A geenforce.fetch token MAY be pinned to specific manifest scopes
|
||||
(ApiToken.resourcescopes). A bound token can only pull those scopes' manifests
|
||||
and only the blobs those scopes reference; an unbound token (NULL) keeps the
|
||||
original behavior. This is what lets a fleet-wide display key be handed out
|
||||
without exposing every scope + every blob in the content store.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
from plugins.geenforce import service
|
||||
|
||||
DISPLAY = 'gea-shopfloor-display'
|
||||
OTHER = 'gea-shopfloor-cmm'
|
||||
|
||||
KIOSK_BYTES = b'kiosk-installer-payload-bytes'
|
||||
KIOSK_SHA = hashlib.sha256(KIOSK_BYTES).hexdigest()
|
||||
|
||||
|
||||
def _publish_display_scope(app):
|
||||
"""Publish a display scope whose one entry references KIOSK_SHA over http."""
|
||||
with app.app_context():
|
||||
service.store_blob(KIOSK_BYTES, 'kiosk.exe', 'application/octet-stream')
|
||||
manifest = {
|
||||
'Version': '2.0',
|
||||
'Applications': [
|
||||
{'Name': 'Kiosk', 'Type': 'EXE', 'Installer': 'apps/kiosk.exe',
|
||||
'DetectionMethod': 'Always',
|
||||
'PayloadSource': 'http', 'PayloadSha256': KIOSK_SHA},
|
||||
],
|
||||
}
|
||||
service.replace_scope_draft(DISPLAY, 'runtime', manifest)
|
||||
service.publish_scope(DISPLAY, 'runtime', notes='display')
|
||||
# A second, unrelated scope + blob the display token must NOT reach.
|
||||
service.store_blob(b'secret-other-scope-bytes', 'other.exe')
|
||||
service.replace_scope_draft(OTHER, 'runtime', {
|
||||
'Version': '2.6',
|
||||
'Applications': [{'Name': 'Beta', 'Type': 'PS1',
|
||||
'Script': 'scripts/beta.ps1',
|
||||
'DetectionMethod': 'Always'}]})
|
||||
service.publish_scope(OTHER, 'runtime', notes='other')
|
||||
service.db.session.commit()
|
||||
|
||||
|
||||
def _mint(client, auth_headers, resourcescopes=None):
|
||||
payload = {'name': 'display svc', 'scopes': ['geenforce.fetch']}
|
||||
if resourcescopes is not None:
|
||||
payload['resourcescopes'] = resourcescopes
|
||||
resp = client.post('/api/apitokens', json=payload, headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']
|
||||
|
||||
|
||||
def _key(client, auth_headers, resourcescopes=None):
|
||||
return {'X-API-Key': _mint(client, auth_headers, resourcescopes)['secret']}
|
||||
|
||||
|
||||
# -- manifest binding ---------------------------------------------------------
|
||||
|
||||
def test_bound_token_gets_its_own_scope(client, app, db, auth_headers):
|
||||
_publish_display_scope(app)
|
||||
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
|
||||
resp = client.get(f'/api/geenforce/manifest?pctype={DISPLAY}', headers=headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_bound_token_forbidden_other_scope(client, app, db, auth_headers):
|
||||
_publish_display_scope(app)
|
||||
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
|
||||
resp = client.get(f'/api/geenforce/manifest?pctype={OTHER}', headers=headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_unbound_token_reaches_any_scope(client, app, db, auth_headers):
|
||||
_publish_display_scope(app)
|
||||
headers = _key(client, auth_headers) # no resourcescopes -> unrestricted
|
||||
assert client.get(f'/api/geenforce/manifest?pctype={DISPLAY}',
|
||||
headers=headers).status_code == 200
|
||||
assert client.get(f'/api/geenforce/manifest?pctype={OTHER}',
|
||||
headers=headers).status_code == 200
|
||||
|
||||
|
||||
# -- payload binding ----------------------------------------------------------
|
||||
|
||||
def test_bound_token_gets_referenced_blob(client, app, db, auth_headers):
|
||||
_publish_display_scope(app)
|
||||
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
|
||||
resp = client.get(f'/api/geenforce/payload/{KIOSK_SHA}', headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == KIOSK_BYTES
|
||||
|
||||
|
||||
def test_bound_token_404_on_unreferenced_blob(client, app, db, auth_headers):
|
||||
_publish_display_scope(app)
|
||||
# A blob the display scope does NOT reference (belongs to OTHER).
|
||||
other_sha = hashlib.sha256(b'secret-other-scope-bytes').hexdigest()
|
||||
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
|
||||
resp = client.get(f'/api/geenforce/payload/{other_sha}', headers=headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_unbound_token_gets_any_blob(client, app, db, auth_headers):
|
||||
_publish_display_scope(app)
|
||||
other_sha = hashlib.sha256(b'secret-other-scope-bytes').hexdigest()
|
||||
headers = _key(client, auth_headers)
|
||||
assert client.get(f'/api/geenforce/payload/{other_sha}',
|
||||
headers=headers).status_code == 200
|
||||
|
||||
|
||||
# -- token model / API --------------------------------------------------------
|
||||
|
||||
def test_create_persists_resourcescopes(client, app, db, auth_headers):
|
||||
data = _mint(client, auth_headers, resourcescopes=[DISPLAY])
|
||||
assert data['resourcescopes'] == [DISPLAY]
|
||||
|
||||
|
||||
def test_resourcescopes_null_by_default(client, app, db, auth_headers):
|
||||
data = _mint(client, auth_headers)
|
||||
assert data['resourcescopes'] is None
|
||||
|
||||
|
||||
def test_bad_resourcescopes_rejected(client, app, db, auth_headers):
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'x', 'scopes': ['geenforce.fetch'],
|
||||
'resourcescopes': 'not-a-list'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
165
tests/test_plugins/test_printers_supply_alerts.py
Normal file
165
tests/test_plugins/test_printers_supply_alerts.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Low-toner alert tests.
|
||||
|
||||
Invariants: the alert tier boundaries (5% warning, 0% critical), the
|
||||
fire-once-per-crossing state machine (re-arm on refill), toner-only scope,
|
||||
and recipient routing to a support team's webhook.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from shopdb.api import db
|
||||
from shopdb.core.models import AssetType, Setting, SupportTeam
|
||||
from plugins.printers.models import PrinterSupplyAlert
|
||||
from plugins.printers.services.supply_parts import alerttier
|
||||
from plugins.printers.services.supply_alerts import check_supplies
|
||||
|
||||
|
||||
class FakeZabbix:
|
||||
"""Stand-in Zabbix service: canned supplies keyed by IP."""
|
||||
|
||||
def __init__(self, byip):
|
||||
self._byip = byip
|
||||
self.isconfigured = True
|
||||
|
||||
def getsuppliesbyip(self, ip):
|
||||
return self._byip.get(ip, [])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def printer_assettype(db):
|
||||
at = AssetType(assettype='printer', pluginname='printers',
|
||||
tablename='printers', description='Printers')
|
||||
db.session.add(at)
|
||||
db.session.commit()
|
||||
return at
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ip_comtype(db):
|
||||
from shopdb.core.models import CommunicationType
|
||||
row = CommunicationType.query.filter_by(comtype='IP').first()
|
||||
if not row:
|
||||
row = CommunicationType(comtype='IP', description='IP address')
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
return row
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def network_printer(client, auth_headers, printer_assettype, ip_comtype):
|
||||
"""A network printer at 10.0.0.5 with a black toner, via the real API."""
|
||||
resp = client.post('/api/printers', json={
|
||||
'assetnumber': 'CSF01-LaserJet',
|
||||
'hostname': 'wjprn05',
|
||||
'ipaddress': '10.0.0.5',
|
||||
}, headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return '10.0.0.5'
|
||||
|
||||
|
||||
def test_alerttier_boundaries():
|
||||
assert alerttier(50) == 'ok'
|
||||
assert alerttier(6) == 'ok'
|
||||
assert alerttier(5) == 'warning'
|
||||
assert alerttier(1) == 'warning'
|
||||
assert alerttier(0) == 'critical'
|
||||
|
||||
|
||||
def _toner(level, color='black'):
|
||||
return {'name': f'{color} toner', 'level': level, 'color': color}
|
||||
|
||||
|
||||
def test_warning_crossing_fires_once_and_rearms(app, db, network_printer):
|
||||
ip = network_printer
|
||||
with app.app_context():
|
||||
# 20% -> ok, no alert
|
||||
with patch('shopdb.api.send_webhook') as hook1, \
|
||||
patch('shopdb.api.send_email'):
|
||||
check_supplies(FakeZabbix({ip: [_toner(20)]}))
|
||||
assert not hook1.called
|
||||
|
||||
# 4% -> warning, alert fires
|
||||
with patch('shopdb.api.send_webhook') as hook2, \
|
||||
patch('shopdb.api.send_email'):
|
||||
check_supplies(FakeZabbix({ip: [_toner(4)]}))
|
||||
assert hook2.called
|
||||
|
||||
# still 3% -> already warning, no repeat
|
||||
with patch('shopdb.api.send_webhook') as hook3, \
|
||||
patch('shopdb.api.send_email'):
|
||||
check_supplies(FakeZabbix({ip: [_toner(3)]}))
|
||||
assert not hook3.called
|
||||
|
||||
# refill to 80% -> re-arm (state back to ok), no alert
|
||||
with patch('shopdb.api.send_webhook') as hook4, \
|
||||
patch('shopdb.api.send_email'):
|
||||
check_supplies(FakeZabbix({ip: [_toner(80)]}))
|
||||
assert not hook4.called
|
||||
row = PrinterSupplyAlert.query.filter_by(supplykey='black').first()
|
||||
assert row.lasttier == 'ok'
|
||||
|
||||
# drop to 2% again -> warning fires again after the re-arm
|
||||
with patch('shopdb.api.send_webhook') as hook5, \
|
||||
patch('shopdb.api.send_email'):
|
||||
check_supplies(FakeZabbix({ip: [_toner(2)]}))
|
||||
assert hook5.called
|
||||
|
||||
|
||||
def test_direct_to_critical_fires_critical(app, db, network_printer):
|
||||
ip = network_printer
|
||||
with app.app_context():
|
||||
with patch('shopdb.api.send_webhook') as hook, \
|
||||
patch('shopdb.api.send_email'):
|
||||
check_supplies(FakeZabbix({ip: [_toner(0)]}))
|
||||
assert hook.called
|
||||
subject = hook.call_args.args[0]
|
||||
assert 'CRITICAL' in subject
|
||||
row = PrinterSupplyAlert.query.filter_by(supplykey='black').first()
|
||||
assert row.lasttier == 'critical'
|
||||
|
||||
|
||||
def test_alerttier_respects_custom_thresholds():
|
||||
# warning at <=20, critical at <=8
|
||||
assert alerttier(25, warning=20, critical=8) == 'ok'
|
||||
assert alerttier(20, warning=20, critical=8) == 'warning'
|
||||
assert alerttier(8, warning=20, critical=8) == 'critical'
|
||||
|
||||
|
||||
def test_custom_warning_threshold_setting_fires(app, db, network_printer):
|
||||
ip = network_printer
|
||||
with app.app_context():
|
||||
Setting.set('printers_alert_warning_threshold', '20')
|
||||
# 15% would be 'ok' under the default 5% warning, but fires at 20%
|
||||
with patch('shopdb.api.send_webhook') as hook, \
|
||||
patch('shopdb.api.send_email'):
|
||||
check_supplies(FakeZabbix({ip: [_toner(15)]}))
|
||||
assert hook.called
|
||||
row = PrinterSupplyAlert.query.filter_by(supplykey='black').first()
|
||||
assert row.lasttier == 'warning'
|
||||
|
||||
|
||||
def test_non_toner_supply_ignored(app, db, network_printer):
|
||||
ip = network_printer
|
||||
with app.app_context():
|
||||
waste = {'name': 'waste toner box', 'level': 1, 'color': 'none'}
|
||||
with patch('shopdb.api.send_email') as email, \
|
||||
patch('shopdb.api.send_webhook'):
|
||||
check_supplies(FakeZabbix({ip: [waste]}))
|
||||
assert not email.called
|
||||
|
||||
|
||||
def test_routes_to_support_team_webhook(app, db, network_printer):
|
||||
ip = network_printer
|
||||
with app.app_context():
|
||||
team = SupportTeam(teamname='Print Team',
|
||||
webhookurl='https://teams.example/print')
|
||||
db.session.add(team)
|
||||
db.session.commit()
|
||||
Setting.set('printers_alert_supportteamid', str(team.supportteamid))
|
||||
|
||||
with patch('shopdb.api.send_webhook') as webhook, \
|
||||
patch('shopdb.api.send_email'):
|
||||
check_supplies(FakeZabbix({ip: [_toner(3)]}))
|
||||
assert webhook.called
|
||||
assert webhook.call_args.kwargs['url'] == 'https://teams.example/print'
|
||||
Reference in New Issue
Block a user