The map was one picture of one floor. A second floor was added, the blueprint changed size, and machines moved, so a position now records WHICH DRAWING its coordinates belong to. Buildings and levels (ADR-017). Each level owns its blueprint per theme and its own native pixel size; assets.mapx/mapy are pixels of assets.levelid, not of the site. A position whose level is unknown renders "level unknown" and is never drawn on the default level, because a marker on the wrong floor plan looks entirely correct while pointing at the wrong place. Repositioning in bulk: filter by unplaced, needs-review or level, search, place, confirm. Landmark recalibration solves the transform PER AXIS from landmark pairs and never from image dimensions - the canvas grew taller without rescaling, so a dimension-derived scale would stretch Y by 1.57 and be wrong everywhere. It defaults to a dry run, reports what would land off the drawing, snapshots before applying, and clears mapverifiedat because a transform is a guess awaiting review. Snapshots restore, including the level and the review state, and a restore snapshots first so an undo is undoable. Search: gaugelabreference was matched only for measuring tools and maintenancereference was matched nowhere at all, for any asset type, while Settings happily offers both identifiers on machines and PCs. A tag an operator is told to record has to be findable or it is a write-only field. USB devices and printed items were unreachable from search entirely - neither is an asset, so the generic asset search could not see them and no searcher existed; they now match on serial, asset tag, label, bin code and gage-lab tag, honouring isactive, with Settings toggles and result labels to match. The retired-application rule was half a rule: GET /api/knowledgebase hid articles whose topic application is retired while global search still returned them and printed the retired application as the subject. A filter is only real if every path that reaches the row applies it. Contract to 0.20.0 (additive): Asset gained levelid and mapverifiedat, Location gained levelid, and resolve_asset_position returns the levelid belonging to whichever source supplied the coordinates. The five plugins that write a map position are re-pinned. The install-list text format gained levelid as a NINTH field, appended, because the shipped Pascal installer reads fields 0-7 by index. That installer still compiles in one drawing's dimensions and bundles one blueprint, so its map is accurate for the default level only; /api/maplevels is deliberately unauthenticated so it can read both at runtime once rebuilt. Recorded in PRINTER-INSTALLER.md section 6 along with the other known gaps. Migration 7d33 converts an existing single-map site into one building and one default level carrying the old map_* settings, then assigns every placed asset and location to it. Nothing moves on screen. Old settings rows are kept so a rollback still finds them. Verified end to end on MySQL 5.6 from a production-shaped database.
1355 lines
56 KiB
Python
1355 lines
56 KiB
Python
"""GE-Enforce plugin API.
|
|
|
|
Two audiences:
|
|
- Admin (JWT + geenforce.manage): browse scopes and preview the draft manifest.
|
|
Full CRUD + publish lands in P2; this is the P1/first-slice read surface.
|
|
- Client (service token, geenforce.fetch scope): GET /manifest serves the
|
|
CURRENT PUBLISHED snapshot for a scope, never the live draft. Auth mirrors the
|
|
collector's managed-token pattern (X-API-Key or Bearer PAT).
|
|
"""
|
|
|
|
import ipaddress
|
|
import os
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
from functools import wraps
|
|
|
|
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, cache, success_response, error_response, ErrorCodes, require_permission,
|
|
authorized_service_token, Setting, Application,
|
|
)
|
|
|
|
SHAREROOT_SETTING = 'geenforce_share_root'
|
|
|
|
# Sort floor for revisions with no lastseenat (they sort last regardless).
|
|
_EPOCH = datetime.min
|
|
|
|
from ..models import (
|
|
ManifestScope, ManifestEntry, ManifestPublishedVersion,
|
|
ManifestEnforcementReport, ManifestEnforcementResult, ManifestPayload,
|
|
ManifestBlob, ENTRY_TYPES, PHASES,
|
|
)
|
|
from ..serializer import scope_to_manifest, entry_to_dict
|
|
from ..importer import build_entry, populate_entry
|
|
from ..filters import (
|
|
entry_applies, matches_pctype, matches_hostname, matches_machinenumber,
|
|
matches_cmmversion,
|
|
)
|
|
from .. import service
|
|
|
|
geenforce_bp = Blueprint('geenforce', __name__)
|
|
|
|
FETCH_SCOPE = 'geenforce.fetch'
|
|
REPORT_SCOPE = 'geenforce.report'
|
|
# Comma-separated CIDRs / plain IPs whose callers may reach the client
|
|
# endpoints WITHOUT a per-PC token (network-trust for a vaulted fleet).
|
|
# Empty/unset = disabled (token stays the only path).
|
|
ALLOWED_CIDRS_SETTING = 'geenforce_allowed_cidrs'
|
|
|
|
|
|
def _trusted_client_ip():
|
|
"""The trustworthy caller IP for the AUTH allowlist.
|
|
|
|
Uses request.remote_addr, NOT the raw X-Forwarded-For header. Proxies APPEND
|
|
to X-Forwarded-For, so its first hop is attacker-controlled: parsing it (as
|
|
_client_ip does for rate-limiting) would let any caller send
|
|
'X-Forwarded-For: <allowlisted-ip>' and bypass the token.
|
|
|
|
DEPLOYMENT DEPENDENCY, not a property of this function. remote_addr is only
|
|
trustworthy because of what sits in front:
|
|
|
|
- Behind IIS, the URL-Rewrite rule OVERWRITES X-Forwarded-For with the real
|
|
TCP peer, and waitress (--trusted-proxy=127.0.0.1
|
|
--trusted-proxy-headers=x-forwarded-for) derives remote_addr from it.
|
|
- Remove that rule and IIS still forwards whatever X-Forwarded-For the
|
|
CALLER sent. waitress trusts it because it comes from 127.0.0.1, so
|
|
remote_addr becomes attacker-controlled and this allowlist is bypassable
|
|
from anywhere on the network. It does NOT fail closed.
|
|
- A client reaching waitress directly is not a trusted proxy, so there
|
|
remote_addr is its own peer address.
|
|
|
|
The rule is the control. See docs/geenforce-api-cutover.md; the Windows
|
|
installer enables it and verifies it is live at the end of an install.
|
|
"""
|
|
return request.remote_addr or ''
|
|
|
|
|
|
def _ip_allowlisted():
|
|
"""True when the caller IP falls in the configured geenforce allowlist.
|
|
|
|
Lets vaulted fleet PCs reach the client endpoints without a per-PC token -
|
|
network trust replaces the shared secret. Fails closed: an unparseable
|
|
caller IP or malformed allowlist entry never matches. Empty setting = off.
|
|
Uses the SPOOF-RESISTANT remote_addr (see _trusted_client_ip), never the raw
|
|
X-Forwarded-For header.
|
|
"""
|
|
raw = (Setting.get(ALLOWED_CIDRS_SETTING) or '').strip()
|
|
if not raw:
|
|
return False
|
|
try:
|
|
ip = ipaddress.ip_address(_trusted_client_ip())
|
|
except ValueError:
|
|
return False
|
|
for part in raw.split(','):
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
try:
|
|
if ip in ipaddress.ip_network(part, strict=False):
|
|
return True
|
|
except ValueError:
|
|
continue
|
|
return False
|
|
|
|
|
|
def _require_service_token(scope):
|
|
"""Decorator factory: require a managed service token scoped for `scope`,
|
|
OR a caller from the configured IP allowlist.
|
|
|
|
Two client-auth paths:
|
|
1. a managed geenforce.fetch/report token (X-API-Key or Bearer PAT), or
|
|
2. a source IP in geenforce_allowed_cidrs (vault network trust).
|
|
No env-key fallback. Fail-closed: neither path -> 401.
|
|
"""
|
|
def wrapper(f):
|
|
@wraps(f)
|
|
def decorated(*args, **kwargs):
|
|
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)
|
|
# Network-trust path: an allowlisted vault IP reaches the client
|
|
# endpoints with no token. No token = no resource-scope binding
|
|
# (unrestricted), which the perimeter-trust model accepts.
|
|
if _ip_allowlisted():
|
|
g.geenforce_token = None
|
|
return f(*args, **kwargs)
|
|
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
|
http_code=401)
|
|
return decorated
|
|
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)
|
|
|
|
|
|
# -- client-facing endpoint ---------------------------------------------------
|
|
|
|
@geenforce_bp.route('/manifest', methods=['GET'])
|
|
@require_fetch_token
|
|
def get_manifest():
|
|
"""Serve the current published manifest for a scope (fat-client: full scope).
|
|
|
|
Query: pctype (=scopename, required), phase (default runtime). The engine
|
|
filters client-side, matching today, so subtype/hostname/machinenumber/
|
|
cmmversion are accepted but not applied here.
|
|
"""
|
|
scopename = (request.args.get('pctype') or '').strip()
|
|
phase = (request.args.get('phase') or 'runtime').strip()
|
|
if not scopename:
|
|
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:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'No scope: {scopename}', http_code=404)
|
|
|
|
published = scope.publishedversions.filter_by(iscurrent=True).first()
|
|
if not published:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'{scopename} has no published version',
|
|
http_code=404)
|
|
|
|
etag = f'"{scope.scopeid}-v{published.versionnumber}"'
|
|
if request.headers.get('If-None-Match') == etag:
|
|
return Response(status=304, headers={'ETag': etag})
|
|
return Response(published.manifestjson, mimetype='application/json',
|
|
headers={'ETag': etag,
|
|
'X-Manifest-Version': str(published.versionnumber)})
|
|
|
|
|
|
# -- 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):
|
|
"""Serve a payload blob by content hash over HTTPS.
|
|
|
|
Lets share-less (Intune/local-account) PCs pull installers the manifest
|
|
references without SMB. Sources: the content-addressed blob store (large
|
|
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',
|
|
as_attachment=True, download_name=blob.filename)
|
|
response.headers['ETag'] = etag
|
|
return response
|
|
|
|
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',
|
|
headers={'ETag': etag,
|
|
'Content-Disposition': f'attachment; filename="{inline.filename}"'})
|
|
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No payload for that hash',
|
|
http_code=404)
|
|
|
|
|
|
# -- client reporting (observed state) ----------------------------------------
|
|
|
|
@geenforce_bp.route('/report', methods=['POST'])
|
|
@require_report_token
|
|
def post_report():
|
|
"""Record one PC's enforcement cycle: applied version (did it receive the
|
|
update?) + per-entry self-heal outcomes (installed/skipped/failed)."""
|
|
payload = request.get_json(silent=True) or {}
|
|
if not (payload.get('hostname') or '').strip():
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'hostname is required', http_code=400)
|
|
try:
|
|
report = service.record_enforcement_report(payload)
|
|
db.session.commit()
|
|
except ValueError as exc:
|
|
db.session.rollback()
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc),
|
|
http_code=400)
|
|
return success_response({'reportid': report.reportid,
|
|
'status': report.status}, message='recorded')
|
|
|
|
|
|
# -- admin read surface (P2 adds full CRUD + publish) -------------------------
|
|
|
|
@geenforce_bp.route('/scopes', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def list_scopes():
|
|
"""List imaging PC-type scopes with entry + published-version counts."""
|
|
scopes = ManifestScope.query.order_by(
|
|
ManifestScope.phase, ManifestScope.scopename).all()
|
|
data = []
|
|
for scope in scopes:
|
|
current = scope.publishedversions.filter_by(iscurrent=True).first()
|
|
data.append({
|
|
'scopeid': scope.scopeid,
|
|
'scopename': scope.scopename,
|
|
'phase': scope.phase,
|
|
'manifestversion': scope.manifestversion,
|
|
'minlibversion': scope.minlibversion,
|
|
'minlibversion': scope.minlibversion,
|
|
'computertypeid': scope.computertypeid,
|
|
'measuringtooltypeid': scope.measuringtooltypeid,
|
|
'iscommon': scope.iscommon,
|
|
'entrycount': len(scope.entries),
|
|
'publishedversion': current.versionnumber if current else None,
|
|
})
|
|
return success_response(data)
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/preview', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def preview_scope(scopeid):
|
|
"""Render the DRAFT manifest JSON a publish would freeze (review before ship)."""
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope',
|
|
http_code=404)
|
|
return success_response({'scopename': scope.scopename,
|
|
'manifest': scope_to_manifest(scope)})
|
|
|
|
|
|
@geenforce_bp.route('/applications', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def list_applications():
|
|
"""The core Applications catalog (id + name) for the curated entry->app link
|
|
picker in the entry editor."""
|
|
apps = Application.query.filter_by(isactive=True).order_by(
|
|
Application.appname).all()
|
|
return success_response([{'appid': a.appid, 'appname': a.appname}
|
|
for a in apps])
|
|
|
|
|
|
# -- scope CRUD (geenforce.manage) --------------------------------------------
|
|
|
|
def _scope_summary(scope):
|
|
current = scope.publishedversions.filter_by(iscurrent=True).first()
|
|
return {
|
|
'scopeid': scope.scopeid,
|
|
'scopename': scope.scopename,
|
|
'phase': scope.phase,
|
|
'manifestversion': scope.manifestversion,
|
|
'description': scope.description,
|
|
'computertypeid': scope.computertypeid,
|
|
'measuringtooltypeid': scope.measuringtooltypeid,
|
|
'iscommon': scope.iscommon,
|
|
'entrycount': len(scope.entries),
|
|
'publishedversion': current.versionnumber if current else None,
|
|
}
|
|
|
|
|
|
def _entry_payload(entry):
|
|
"""Manifest-entry dict with entryid + sortorder + the curated app link.
|
|
|
|
appid/appname are shopdb metadata (not manifest fields), added alongside the
|
|
rendered manifest entry for the editor.
|
|
"""
|
|
data = {'entryid': entry.entryid, 'sortorder': entry.sortorder,
|
|
'appid': entry.appid, 'appname': None}
|
|
if entry.appid:
|
|
app = db.session.get(Application, entry.appid)
|
|
data['appname'] = app.appname if app else None
|
|
data.update(entry_to_dict(entry))
|
|
# Inline-payload metadata (shopdb-only, NOT manifest keys) for the editor.
|
|
data['payloadsource'] = entry.payloadsource
|
|
data['payloadref'] = entry.payloadref
|
|
data['payloadsha256'] = entry.payloadsha256
|
|
data['haspayload'] = (entry.payloadsource == 'inline'
|
|
and entry.payloadsha256 is not None)
|
|
return data
|
|
|
|
|
|
def _apply_app_link(entry, payload):
|
|
"""Set the optional curated appid from the payload (shopdb metadata, not a
|
|
manifest field, so handled outside populate_entry). Ignores an unknown id."""
|
|
if 'appid' not in payload:
|
|
return
|
|
appid = payload.get('appid')
|
|
if appid in (None, '', 0):
|
|
entry.appid = None
|
|
return
|
|
# Ignore a non-numeric id rather than 500 (matches the docstring contract).
|
|
try:
|
|
appid = int(appid)
|
|
except (ValueError, TypeError):
|
|
return
|
|
if db.session.get(Application, appid):
|
|
entry.appid = appid
|
|
|
|
|
|
@geenforce_bp.route('/scopes', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def create_scope():
|
|
payload = request.get_json(silent=True) or {}
|
|
scopename = (payload.get('scopename') or '').strip()
|
|
phase = (payload.get('phase') or 'runtime').strip()
|
|
if not scopename:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'scopename is required', http_code=400)
|
|
if phase not in PHASES:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
f'phase must be one of {PHASES}', http_code=400)
|
|
if ManifestScope.query.filter_by(scopename=scopename, phase=phase).first():
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'scope already exists', http_code=400)
|
|
scope = ManifestScope(
|
|
scopename=scopename, phase=phase,
|
|
manifestversion=str(payload.get('manifestversion', '1.0')),
|
|
minlibversion=(payload.get('minlibversion') or None),
|
|
description=payload.get('description'),
|
|
computertypeid=payload.get('computertypeid'),
|
|
measuringtooltypeid=payload.get('measuringtooltypeid'),
|
|
iscommon=bool(payload.get('iscommon', scopename == 'common')))
|
|
db.session.add(scope)
|
|
db.session.commit()
|
|
return success_response(_scope_summary(scope), http_code=201)
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def get_scope(scopeid):
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
data = _scope_summary(scope)
|
|
data['entries'] = [_entry_payload(e) for e in scope.entries]
|
|
return success_response(data)
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['PUT'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def update_scope(scopeid):
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
payload = request.get_json(silent=True) or {}
|
|
for field in ('description', 'computertypeid', 'measuringtooltypeid'):
|
|
if field in payload:
|
|
setattr(scope, field, payload[field])
|
|
if 'manifestversion' in payload:
|
|
scope.manifestversion = str(payload['manifestversion'])
|
|
if 'minlibversion' in payload:
|
|
# Blank clears it, which returns the scope to a derived floor rather
|
|
# than pinning it at the empty string.
|
|
scope.minlibversion = (payload['minlibversion'] or None)
|
|
if 'iscommon' in payload:
|
|
scope.iscommon = bool(payload['iscommon'])
|
|
db.session.commit()
|
|
return success_response(_scope_summary(scope))
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['DELETE'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def delete_scope(scopeid):
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
db.session.delete(scope)
|
|
db.session.commit()
|
|
return success_response({'deleted': scopeid})
|
|
|
|
|
|
# -- entry CRUD (geenforce.manage) --------------------------------------------
|
|
|
|
def _validate_entry(payload):
|
|
"""Return an error string, or None if the entry payload is valid."""
|
|
if not (payload.get('Name') or '').strip():
|
|
return 'entry Name is required'
|
|
if payload.get('Type') not in ENTRY_TYPES:
|
|
return f'entry Type must be one of {ENTRY_TYPES}'
|
|
return None
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/entries', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def create_entry(scopeid):
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
payload = request.get_json(silent=True) or {}
|
|
invalid = _validate_entry(payload)
|
|
if invalid:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
|
|
nextorder = max([e.sortorder for e in scope.entries], default=-1) + 1
|
|
entry = build_entry(payload, nextorder)
|
|
_apply_app_link(entry, payload)
|
|
scope.entries.append(entry)
|
|
try:
|
|
db.session.commit()
|
|
except IntegrityError:
|
|
db.session.rollback()
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'an entry with that Name already exists in this scope',
|
|
http_code=400)
|
|
return success_response(_entry_payload(entry), http_code=201)
|
|
|
|
|
|
@geenforce_bp.route('/entries/<int:entryid>', methods=['PUT'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def update_entry(entryid):
|
|
entry = db.session.get(ManifestEntry, entryid)
|
|
if not entry:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
|
payload = request.get_json(silent=True) or {}
|
|
invalid = _validate_entry(payload)
|
|
if invalid:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
|
|
# Free the one-to-one InUseCheck (unique entryid) before populate re-inserts
|
|
# it, so the replacement does not collide with the old row mid-flush.
|
|
if entry.inusecheck is not None:
|
|
db.session.delete(entry.inusecheck)
|
|
entry.inusecheck = None
|
|
db.session.flush()
|
|
populate_entry(entry, payload)
|
|
_apply_app_link(entry, payload)
|
|
try:
|
|
db.session.commit()
|
|
except IntegrityError:
|
|
db.session.rollback()
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'an entry with that Name already exists in this scope',
|
|
http_code=400)
|
|
return success_response(_entry_payload(entry))
|
|
|
|
|
|
@geenforce_bp.route('/entries/<int:entryid>', methods=['DELETE'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def delete_entry(entryid):
|
|
entry = db.session.get(ManifestEntry, entryid)
|
|
if not entry:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
|
db.session.delete(entry)
|
|
db.session.commit()
|
|
return success_response({'deleted': entryid})
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/entries/reorder', methods=['PUT'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def reorder_entries(scopeid):
|
|
"""Set entry order from a list of entryids (the ordering contract)."""
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
order = (request.get_json(silent=True) or {}).get('order') or []
|
|
owned = {e.entryid: e for e in scope.entries}
|
|
if set(order) != set(owned):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'order must list exactly this scope\'s entry ids',
|
|
http_code=400)
|
|
for position, entryid in enumerate(order):
|
|
owned[entryid].sortorder = position
|
|
db.session.commit()
|
|
return success_response({'order': order})
|
|
|
|
|
|
# -- simulate: "what would this PC get" ---------------------------------------
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/simulate', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def simulate_scope(scopeid):
|
|
"""Which draft entries apply to a given machine profile, and why the rest
|
|
are filtered out (reuses the engine-mirror filters)."""
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
profile = {
|
|
'pctype': request.args.get('pctype') or scope.scopename,
|
|
'subtype': request.args.get('subtype'),
|
|
'hostname': request.args.get('hostname'),
|
|
'machinenumber': request.args.get('machinenumber'),
|
|
'cmmversion': request.args.get('cmmversion'),
|
|
'phase': scope.phase,
|
|
}
|
|
applied, filtered = [], []
|
|
for entry in scope.entries:
|
|
entry_dict = entry_to_dict(entry)
|
|
if entry_applies(entry_dict, profile):
|
|
applied.append(entry.name)
|
|
else:
|
|
reasons = []
|
|
strict_allowed = profile.get('phase') == 'preinstall'
|
|
if not matches_pctype(entry_dict, profile['pctype'],
|
|
profile['subtype'], strict_allowed):
|
|
reasons.append('PCTypes')
|
|
if not matches_hostname(entry_dict, profile['hostname']):
|
|
reasons.append('TargetHostnames')
|
|
if not matches_machinenumber(entry_dict, profile['machinenumber']):
|
|
reasons.append('TargetMachineNumbers')
|
|
if not matches_cmmversion(entry_dict, profile['cmmversion']):
|
|
reasons.append('_CmmVersion')
|
|
filtered.append({'name': entry.name, 'filteredby': reasons})
|
|
return success_response({'profile': profile, 'applied': applied,
|
|
'filtered': filtered})
|
|
|
|
|
|
# -- compliance: "how much of the fleet has this app" -------------------------
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/compliance', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def scope_compliance(scopeid):
|
|
"""Fleet-install coverage per app-linked entry (from collected PC data).
|
|
|
|
One row per entry that carries a curated appid; installed/version-match
|
|
counts come from the computers plugin's ComputerInstalledApp. Degrades to
|
|
null counts (computersplugin: false) when that plugin is absent.
|
|
"""
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope',
|
|
http_code=404)
|
|
return success_response(service.compliance_for_scope(scope))
|
|
|
|
|
|
# -- inline payload upload / fetch (small scripts + configs) ------------------
|
|
|
|
PAYLOAD_MAX_BYTES = 1024 * 1024
|
|
|
|
|
|
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.publish')
|
|
def upload_entry_payload(entryid):
|
|
"""Store an inline payload (<= 1 MB) for an entry and point the entry at it."""
|
|
entry = db.session.get(ManifestEntry, entryid)
|
|
if not entry:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
|
uploaded = request.files.get('file')
|
|
if uploaded is None:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'a file is required', http_code=400)
|
|
rawbytes = uploaded.read()
|
|
if not rawbytes:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'payload is empty', http_code=400)
|
|
if len(rawbytes) > PAYLOAD_MAX_BYTES:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'payload exceeds 1 MB limit', http_code=400)
|
|
service.store_inline_payload(entry, uploaded.filename,
|
|
uploaded.mimetype, rawbytes)
|
|
db.session.commit()
|
|
return success_response(_entry_payload(entry), http_code=201)
|
|
|
|
|
|
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def download_entry_payload(entryid):
|
|
"""Return the stored inline payload bytes for an entry (404 if none)."""
|
|
entry = db.session.get(ManifestEntry, entryid)
|
|
if not entry:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
|
payload = ManifestPayload.query.filter_by(entryid=entryid).first()
|
|
if not payload:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'entry has no payload',
|
|
http_code=404)
|
|
return Response(
|
|
payload.payloadbytes,
|
|
mimetype=payload.contenttype or 'application/octet-stream',
|
|
headers={'Content-Disposition':
|
|
f'attachment; filename="{payload.filename}"'})
|
|
|
|
|
|
# -- publish lifecycle (geenforce.publish) ------------------------------------
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/publish', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.publish')
|
|
def publish_scope_route(scopeid):
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
from flask_jwt_extended import get_jwt_identity
|
|
body = request.get_json(silent=True) or {}
|
|
notes = body.get('notes')
|
|
try:
|
|
publishedby = int(get_jwt_identity())
|
|
except (TypeError, ValueError):
|
|
publishedby = None
|
|
try:
|
|
version = service.publish_scope(
|
|
scope.scopename, scope.phase, notes=notes,
|
|
publishedby=publishedby, force=bool(body.get('force')))
|
|
except service.LibVersionTooOldError as error:
|
|
# 409, not 500: the request is well-formed, the fleet is not ready. The
|
|
# hosts come back so the caller can act without going hunting.
|
|
return error_response(
|
|
ErrorCodes.CONFLICT, str(error), http_code=409,
|
|
details={'required': error.required, 'floor': error.floor,
|
|
'hosts': error.hosts})
|
|
db.session.commit()
|
|
return success_response({'versionnumber': version}, http_code=201)
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/publish-preflight', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def publish_preflight(scopeid):
|
|
"""Would publishing this scope outrun the fleet's enforcer lib?
|
|
|
|
Lets the UI warn BEFORE someone clicks publish, rather than only refusing
|
|
afterwards.
|
|
"""
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
hosts, floor = service.hosts_below_libversion(
|
|
scope.scopename, scope.phase, service.required_libversion(scope))
|
|
return success_response({
|
|
'required': service.required_libversion(scope),
|
|
'floor': floor,
|
|
'hostsbehind': hosts,
|
|
'canpublish': not hosts,
|
|
})
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/versions', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def list_versions(scopeid):
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
versions = ManifestPublishedVersion.query.filter_by(
|
|
scopeid=scopeid).order_by(
|
|
ManifestPublishedVersion.versionnumber.desc()).all()
|
|
return success_response([{
|
|
'versionnumber': v.versionnumber,
|
|
'iscurrent': v.iscurrent,
|
|
'publishedat': v.publishedat.isoformat() + 'Z' if v.publishedat else None,
|
|
'publishedby': v.publishedby,
|
|
'notes': v.notes,
|
|
} for v in versions])
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/versions/<int:versionnumber>',
|
|
methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def get_version(scopeid, versionnumber):
|
|
version = ManifestPublishedVersion.query.filter_by(
|
|
scopeid=scopeid, versionnumber=versionnumber).first()
|
|
if not version:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such version',
|
|
http_code=404)
|
|
import json as _json
|
|
return success_response({'versionnumber': version.versionnumber,
|
|
'manifest': _json.loads(version.manifestjson)})
|
|
|
|
|
|
def _normalize_cidrs(raw):
|
|
"""Validate + normalize a comma/newline-separated CIDR list. Returns
|
|
(normalized_csv, bad_entries). Bare IPs are accepted (host route)."""
|
|
good, bad = [], []
|
|
for part in (raw or '').replace('\n', ',').split(','):
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
try:
|
|
good.append(str(ipaddress.ip_network(part, strict=False)))
|
|
except ValueError:
|
|
bad.append(part)
|
|
return ','.join(good), bad
|
|
|
|
|
|
@geenforce_bp.route('/config', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def get_config():
|
|
"""Plugin config: the on-share export root + the client IP allowlist."""
|
|
shareroot = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
|
cidrs = Setting.query.filter_by(key=ALLOWED_CIDRS_SETTING).first()
|
|
return success_response({
|
|
'shareroot': shareroot.value if shareroot else '',
|
|
'allowedcidrs': cidrs.value if cidrs else '',
|
|
})
|
|
|
|
|
|
@geenforce_bp.route('/config', methods=['PUT'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.publish')
|
|
def put_config():
|
|
payload = request.get_json(silent=True) or {}
|
|
result = {}
|
|
if 'shareroot' in payload:
|
|
shareroot = (payload.get('shareroot') or '').strip()
|
|
Setting.set(SHAREROOT_SETTING, shareroot, valuetype='string',
|
|
category='geenforce',
|
|
description='On-share export root for GE-Enforce manifests')
|
|
result['shareroot'] = shareroot
|
|
if 'allowedcidrs' in payload:
|
|
normalized, bad = _normalize_cidrs(payload.get('allowedcidrs'))
|
|
if bad:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'Invalid CIDR(s): ' + ', '.join(bad), http_code=400)
|
|
Setting.set(ALLOWED_CIDRS_SETTING, normalized, valuetype='string',
|
|
category='geenforce',
|
|
description='Client IP allowlist (CIDRs) that may reach the '
|
|
'GE-Enforce client endpoints without a token')
|
|
result['allowedcidrs'] = normalized
|
|
db.session.commit()
|
|
return success_response(result)
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/export-share', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.publish')
|
|
def export_share_route(scopeid):
|
|
"""Write the scope's current published JSON to the configured share root
|
|
(backing up the old file to _meta/history first)."""
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
|
shareroot = setting.value if setting else ''
|
|
if not shareroot:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'Configure the share root first', http_code=400)
|
|
try:
|
|
path = service.export_scope_to_share(scope.scopename, scope.phase,
|
|
shareroot)
|
|
except (ValueError, OSError) as exc:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
|
|
return success_response({'path': path})
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/rollback', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.publish')
|
|
def rollback_scope_route(scopeid):
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
|
versionnumber = (request.get_json(silent=True) or {}).get('versionnumber')
|
|
if versionnumber is None:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'versionnumber is required', http_code=400)
|
|
try:
|
|
service.rollback_scope(scope.scopename, scope.phase, int(versionnumber))
|
|
db.session.commit()
|
|
except ValueError as exc:
|
|
db.session.rollback()
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
|
|
return success_response({'versionnumber': int(versionnumber)})
|
|
|
|
|
|
def _current_published_version(scopename, phase):
|
|
scope = ManifestScope.query.filter_by(
|
|
scopename=scopename, phase=phase).first()
|
|
if not scope:
|
|
return None
|
|
published = scope.publishedversions.filter_by(iscurrent=True).first()
|
|
return published.versionnumber if published else None
|
|
|
|
|
|
def _asset_facts(hostnames):
|
|
"""What ShopDB knows about each reporting host, resolved in bulk.
|
|
|
|
A report row only carries a hostname; everything an operator wants next -
|
|
which machine that PC drives, which measuring tool it is, where it sits,
|
|
whether its config is still being backed up - hangs off the asset behind it.
|
|
|
|
Bulk queries, never per row: this table shows the whole fleet, so a lookup
|
|
inside the loop would be one query per PC. Every plugin here is optional
|
|
(ADR-013), hence the ImportError guards - a lean build without computers
|
|
still renders the table, just without these columns.
|
|
|
|
Returns {lowercased hostname: {...}}.
|
|
"""
|
|
facts = {}
|
|
if not hostnames:
|
|
return facts
|
|
|
|
try:
|
|
from plugins.computers.models import Computer
|
|
except ImportError:
|
|
return facts
|
|
|
|
from shopdb.api import Asset
|
|
|
|
rows = (db.session.query(Computer, Asset)
|
|
.join(Asset, Asset.assetid == Computer.assetid)
|
|
.filter(Computer.hostname.in_(hostnames))
|
|
.all())
|
|
|
|
assetidtohost = {}
|
|
for computer, asset in rows:
|
|
key = (computer.hostname or '').lower()
|
|
assetidtohost[asset.assetid] = key
|
|
facts[key] = {
|
|
'computerid': computer.computerid,
|
|
'assetid': asset.assetid,
|
|
'assetnumber': asset.assetnumber,
|
|
'location': (asset.location.locationname if asset.location else None),
|
|
'mapx': asset.mapx,
|
|
'levelid': asset.levelid,
|
|
'mapy': asset.mapy,
|
|
'machinenumber': None, 'machineassetid': None,
|
|
'machinepluginid': None,
|
|
'toolassetnumber': None, 'toolassetid': None,
|
|
'toolpluginid': None,
|
|
'displayrole': None,
|
|
'backupkind': None, 'backuplastseen': None,
|
|
'backuplastseenat': None, 'backupcollectedat': None,
|
|
}
|
|
|
|
_attach_controlled_assets(facts, assetidtohost)
|
|
_attach_display_roles(facts, hostnames)
|
|
_attach_backup_state(facts, hostnames)
|
|
return facts
|
|
|
|
|
|
def _attach_controlled_assets(facts, assetidtohost):
|
|
"""Machine number / measuring-tool asset # for the asset each PC controls.
|
|
|
|
The collector records this as a 'controls' relationship, so read that rather
|
|
than re-deriving it - the same resolution living in two places is what put a
|
|
wrong subtype filter on the map.
|
|
"""
|
|
if not assetidtohost:
|
|
return
|
|
try:
|
|
from shopdb.api import Asset, AssetRelationship, RelationshipType
|
|
except ImportError:
|
|
return
|
|
|
|
controls = RelationshipType.query.filter_by(
|
|
relationshiptype='controls').first()
|
|
if not controls:
|
|
return
|
|
|
|
target = db.aliased(Asset)
|
|
rows = (db.session.query(AssetRelationship.sourceassetid, target)
|
|
.join(target, target.assetid == AssetRelationship.targetassetid)
|
|
.filter(AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
|
AssetRelationship.isactive.is_(True),
|
|
AssetRelationship.sourceassetid.in_(assetidtohost))
|
|
.all())
|
|
|
|
# assetid -> the PLUGIN extension id the detail routes key on. /machines/:id
|
|
# and /measuringtools/:id take machineid / measuringtoolid, NOT the core
|
|
# assetid; linking with the assetid lands on whichever record happens to
|
|
# share that number, which looks plausible and is wrong. Frontend
|
|
# getAssetRoute resolves the same way.
|
|
toolids = {}
|
|
try:
|
|
from plugins.measuringtools.models import MeasuringTool
|
|
toolids = {row.assetid: row.measuringtoolid for row in
|
|
MeasuringTool.query.with_entities(
|
|
MeasuringTool.assetid, MeasuringTool.measuringtoolid)}
|
|
except ImportError:
|
|
pass
|
|
machineids = {}
|
|
try:
|
|
from plugins.machines.models import Machine
|
|
machineids = {row.assetid: row.machineid for row in
|
|
Machine.query.with_entities(Machine.assetid, Machine.machineid)}
|
|
except ImportError:
|
|
pass
|
|
|
|
for sourceassetid, asset in rows:
|
|
entry = facts.get(assetidtohost.get(sourceassetid))
|
|
if not entry:
|
|
continue
|
|
if asset.assetid in toolids:
|
|
entry['toolassetnumber'] = asset.assetnumber
|
|
entry['toolassetid'] = asset.assetid
|
|
entry['toolpluginid'] = toolids.get(asset.assetid)
|
|
else:
|
|
entry['machinenumber'] = asset.assetnumber
|
|
entry['machineassetid'] = asset.assetid
|
|
# None when the machines plugin is not installed or the asset has no
|
|
# extension row: the frontend then renders the number without a link
|
|
# rather than a link that goes somewhere wrong.
|
|
entry['machinepluginid'] = machineids.get(asset.assetid)
|
|
|
|
|
|
def _attach_display_roles(facts, hostnames):
|
|
"""Dashboard / Lobby / parts kiosk, from the display-role mapping.
|
|
|
|
ShopDB's mirror of the display-type.txt each kiosk reads at startup.
|
|
"""
|
|
from shopdb.api import DashboardDefault
|
|
lowered = {name.lower() for name in hostnames}
|
|
for default in DashboardDefault.query.filter_by(isactive=True).all():
|
|
host = (default.fqdn or '').split('.')[0].lower()
|
|
if host and host in lowered and facts.get(host):
|
|
facts[host]['displayrole'] = default.displayrole
|
|
|
|
|
|
def _attach_backup_state(facts, hostnames):
|
|
"""Whether this PC's config is still being backed up, and of what kind.
|
|
|
|
Keyed on lastseenat, NOT the newest revision date. Dedup means an unchanged
|
|
config writes no revision, so a machine stable for six months has a
|
|
six-month-old newest revision and is perfectly healthy; the one to worry
|
|
about is the machine whose backup stopped running. sourcehostname is the PC
|
|
the config was read off, which is exactly the host reporting here.
|
|
"""
|
|
try:
|
|
from plugins.backups.models import BackupRevision
|
|
except ImportError:
|
|
return
|
|
rows = (BackupRevision.query
|
|
.filter(BackupRevision.sourcehostname.in_(hostnames))
|
|
.all())
|
|
# Newest confirmed first, ordered HERE rather than in SQL: putting nulls
|
|
# last needs ORDER BY ... NULLS LAST, which SQLite accepts and MySQL
|
|
# rejects outright - so the query passed every test and 500'd on the real
|
|
# database. Row counts here are one per host per kind, so sorting in Python
|
|
# costs nothing.
|
|
rows.sort(key=lambda revision: (revision.lastseenat is not None,
|
|
revision.lastseenat or _EPOCH),
|
|
reverse=True)
|
|
for revision in rows:
|
|
key = (revision.sourcehostname or '').lower()
|
|
entry = facts.get(key)
|
|
# First row per host wins.
|
|
if entry and entry['backuplastseen'] is None:
|
|
entry['backupkind'] = revision.backupkind
|
|
entry['backuplastseen'] = (
|
|
revision.lastseenat.isoformat() + 'Z'
|
|
if revision.lastseenat else None)
|
|
# Raw value kept beside the wire string so the verdict compares
|
|
# datetimes rather than re-parsing its own output.
|
|
entry['backuplastseenat'] = revision.lastseenat
|
|
# collectedat is when this configuration was CAPTURED, and it only
|
|
# moves when the config actually changed. Pairing it with lastseenat
|
|
# is what lets the hover say "checked X, and what it verified is
|
|
# still the backup taken at Y" instead of one date that has to mean
|
|
# both and ends up meaning neither.
|
|
entry['backupcollectedat'] = (
|
|
revision.collectedat.isoformat() + 'Z'
|
|
if revision.collectedat else None)
|
|
|
|
|
|
def _backup_ok(lastseenat, cutoff):
|
|
"""Tri-state verdict for one PC's backup: True good, False stale, None unknown.
|
|
|
|
A BOOLEAN rather than a date, because the question is "is this backed up?"
|
|
and every backup kind answers it the same way. Adding a new kind needs no
|
|
change here - it reports a revision like the others and inherits the badge.
|
|
|
|
None means there is nothing to judge: no backup revision for this host at
|
|
all, or the staleness check is disabled. The caller shows no badge rather
|
|
than a green one, since "never seen" must not read as healthy.
|
|
"""
|
|
if cutoff is None or lastseenat is None:
|
|
return None
|
|
return lastseenat >= cutoff
|
|
|
|
|
|
def _backup_stale_cutoff():
|
|
"""(cutoff datetime, threshold days) for 'this PC's backup has gone quiet'.
|
|
|
|
A raw date makes the reader do the arithmetic, and the arithmetic is not
|
|
obvious: the collector only ATTEMPTS every backups_intervalhours (24 by
|
|
default), so a confirmation from yesterday is the healthy steady state, not
|
|
a warning. What answers the question is the verdict.
|
|
|
|
The threshold is the backups plugin's own backups_staledays, read through
|
|
its service so there is ONE definition of stale rather than a second one
|
|
drifting here. Returns (None, 0) when the plugin is absent or the check is
|
|
disabled, and callers then show the date alone.
|
|
"""
|
|
try:
|
|
from plugins.backups.services import staleness
|
|
except ImportError:
|
|
return None, 0
|
|
try:
|
|
days = int(staleness.staledays())
|
|
except (TypeError, ValueError):
|
|
return None, 0
|
|
if days <= 0:
|
|
return None, 0
|
|
return service._utcnow() - timedelta(days=days), days
|
|
|
|
|
|
def _report_stale_cutoff():
|
|
"""(cutoff datetime, threshold minutes) for 'this PC has gone quiet'.
|
|
|
|
A report is a record of one cycle, not a heartbeat with an expiry: nothing
|
|
ages it. So a PC that stops reporting keeps the status of its last good
|
|
cycle and reads as healthy while it is unplugged. Comparing receivedat - the
|
|
SERVER's clock, not anything the client asserts - against a threshold is
|
|
what turns silence into a visible state.
|
|
|
|
Returns (None, 0) when the check is disabled, so callers skip it entirely.
|
|
"""
|
|
from shopdb.api import Setting
|
|
try:
|
|
minutes = int(Setting.get('geenforce_reportstaleminutes', 30) or 0)
|
|
except (TypeError, ValueError):
|
|
minutes = 30
|
|
if minutes <= 0:
|
|
return None, 0
|
|
# Same clock helper that WROTE receivedat (naive UTC), so the two cannot
|
|
# drift into comparing an aware datetime against a naive one.
|
|
return service._utcnow() - timedelta(minutes=minutes), minutes
|
|
|
|
|
|
@geenforce_bp.route('/reports', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def list_reports():
|
|
"""Latest enforcement report per PC (fleet compliance view).
|
|
|
|
Each row shows the applied vs latest published version (receivedlatest = the
|
|
PC picked up the update) and the install/skip/fail counts + status.
|
|
Optional filters: hostname, scopename.
|
|
"""
|
|
query = ManifestEnforcementReport.query.filter_by(iscurrent=True)
|
|
hostname = request.args.get('hostname')
|
|
scopename = request.args.get('scopename')
|
|
if hostname:
|
|
query = query.filter(ManifestEnforcementReport.hostname.ilike(hostname))
|
|
if scopename:
|
|
query = query.filter_by(scopename=scopename)
|
|
reports = query.order_by(
|
|
ManifestEnforcementReport.receivedat.desc()).all()
|
|
|
|
facts = _asset_facts({report.hostname for report in reports
|
|
if report.hostname})
|
|
|
|
stalecutoff, stalafter = _report_stale_cutoff()
|
|
backupcutoff, backupafterdays = _backup_stale_cutoff()
|
|
latest_cache = {}
|
|
data = []
|
|
for report in reports:
|
|
key = (report.scopename, report.phase)
|
|
if key not in latest_cache:
|
|
latest_cache[key] = _current_published_version(*key)
|
|
latest = latest_cache[key]
|
|
known = facts.get((report.hostname or '').lower(), {})
|
|
# Two separate ways to have gone quiet, and the table has a column for
|
|
# each. receivedat is the SERVER's record of the last report it got;
|
|
# lastcheckin is the client's own claim about when it last ran. A PC can
|
|
# fail either way round - silent to the server, or still posting while
|
|
# its own cycle has stopped advancing - so both count.
|
|
#
|
|
# They are checked independently rather than by taking the newer of the
|
|
# two: lastcheckin is client-asserted, so a wrong or future value there
|
|
# must not be able to mask real silence on receivedat.
|
|
isstale = stalecutoff is not None and (
|
|
report.receivedat is None
|
|
or report.receivedat < stalecutoff
|
|
or (report.lastcheckin is not None
|
|
and report.lastcheckin < stalecutoff))
|
|
data.append({
|
|
'reportid': report.reportid,
|
|
'hostname': report.hostname,
|
|
# A host ShopDB has no asset for still shows: the enforcement result
|
|
# is real even when the inventory is behind.
|
|
'computerid': known.get('computerid'),
|
|
'assetnumber': known.get('assetnumber'),
|
|
'location': known.get('location'),
|
|
'mapx': known.get('mapx'),
|
|
'mapy': known.get('mapy'),
|
|
'machinenumber': known.get('machinenumber'),
|
|
'machineassetid': known.get('machineassetid'),
|
|
'machinepluginid': known.get('machinepluginid'),
|
|
'toolassetnumber': known.get('toolassetnumber'),
|
|
'toolassetid': known.get('toolassetid'),
|
|
'toolpluginid': known.get('toolpluginid'),
|
|
# Reported by the device wins; the DashboardDefault mapping is a
|
|
# fallback for hosts on an older client that does not send it.
|
|
'displayrole': report.subtype or known.get('displayrole'),
|
|
'backupkind': known.get('backupkind'),
|
|
'backuplastseen': known.get('backuplastseen'),
|
|
# VERIFIED, not "last backup taken". Dedup means an unchanged config
|
|
# writes no new revision, so lastseenat is the last time the
|
|
# collector CHECKED and found the backup intact - which is the
|
|
# question. backupok says whether that check is recent enough to
|
|
# trust; the date alone reads as "nothing has happened since", which
|
|
# is the healthy steady state and looks like neglect.
|
|
'backupok': _backup_ok(known.get('backuplastseenat'), backupcutoff),
|
|
'backupcollectedat': known.get('backupcollectedat'),
|
|
'backupstaleafterdays': backupafterdays,
|
|
'scopename': report.scopename,
|
|
'phase': report.phase,
|
|
'appliedversion': report.appliedversion,
|
|
'latestversion': latest,
|
|
'receivedlatest': (latest is not None
|
|
and report.appliedversion == latest),
|
|
'enforcerversion': report.enforcerversion,
|
|
# 'status' is what the PC said about its LAST cycle and is left
|
|
# exactly as reported. 'isstale' is the server's own judgement that
|
|
# no cycle has been heard from in too long - a PC switched off after
|
|
# a clean run reports 'ok' forever, so the two are different facts
|
|
# and the table shows both.
|
|
'status': report.status,
|
|
'isstale': isstale,
|
|
'staleafterminutes': stalafter,
|
|
'installed': report.installedcount,
|
|
'skipped': report.skippedcount,
|
|
'failed': report.failedcount,
|
|
'filtered': report.filteredcount,
|
|
'lastcheckin': (report.lastcheckin.isoformat() + 'Z'
|
|
if report.lastcheckin else None),
|
|
'receivedat': (report.receivedat.isoformat() + 'Z'
|
|
if report.receivedat else None),
|
|
})
|
|
return success_response(data)
|
|
|
|
|
|
@geenforce_bp.route('/reports/<int:reportid>', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def get_report(reportid):
|
|
"""One report with per-entry outcomes (self-heal / failure detail)."""
|
|
report = db.session.get(ManifestEnforcementReport, reportid)
|
|
if not report:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such report',
|
|
http_code=404)
|
|
latest = _current_published_version(report.scopename, report.phase)
|
|
return success_response({
|
|
'reportid': report.reportid,
|
|
'hostname': report.hostname,
|
|
'scopename': report.scopename,
|
|
'phase': report.phase,
|
|
'appliedversion': report.appliedversion,
|
|
'latestversion': latest,
|
|
'receivedlatest': (latest is not None
|
|
and report.appliedversion == latest),
|
|
'status': report.status,
|
|
'results': [{
|
|
'entryname': r.entryname,
|
|
'action': r.action,
|
|
'selfhealed': r.selfhealed,
|
|
'exitcode': r.exitcode,
|
|
'message': r.message,
|
|
} for r in report.results],
|
|
})
|
|
|
|
|
|
@geenforce_bp.route('/dashboard/failures', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def dashboard_failures():
|
|
"""Entries that FAILED on their PC's most recent enforcement cycle.
|
|
|
|
The dashboard card behind this answers a question nothing else does: what is
|
|
broken on the floor right now. The reports table has held it all along -
|
|
status, the failing entry, its exit code and the engine's message - and the
|
|
only way to see any of it was to open one PC's report modal, one PC at a
|
|
time. A bay returned 500 to every collector report for a day and a half
|
|
before anyone looked.
|
|
|
|
Per ENTRY, not per report: "three PCs failed" is a number, while "Install
|
|
OpenText failed with exit 1603 on WJSF1234" is something a person can act
|
|
on. Only current reports (iscurrent) are considered, so a failure that has
|
|
since been fixed disappears on its own rather than needing dismissing.
|
|
"""
|
|
rows = (db.session.query(ManifestEnforcementResult, ManifestEnforcementReport)
|
|
.join(ManifestEnforcementReport,
|
|
ManifestEnforcementResult.reportid ==
|
|
ManifestEnforcementReport.reportid)
|
|
.filter(ManifestEnforcementReport.iscurrent.is_(True),
|
|
ManifestEnforcementResult.action == 'failed')
|
|
.order_by(ManifestEnforcementReport.receivedat.desc())
|
|
.limit(50)
|
|
.all())
|
|
|
|
# Resolve hostnames to computerids in ONE query so the card can link to the
|
|
# PC rather than just naming it. A row whose PC is unknown to shopdb still
|
|
# shows - the failure is real even when the inventory is behind.
|
|
hostnames = {report.hostname for _result, report in rows}
|
|
idbyhost = {}
|
|
if hostnames:
|
|
try:
|
|
from plugins.computers.models import Computer
|
|
for computer in Computer.query.filter(
|
|
Computer.hostname.in_(hostnames)).all():
|
|
idbyhost[(computer.hostname or '').lower()] = computer.computerid
|
|
except ImportError:
|
|
pass
|
|
|
|
return success_response([{
|
|
'hostname': report.hostname,
|
|
'computerid': idbyhost.get((report.hostname or '').lower()),
|
|
'scopename': report.scopename,
|
|
'entryname': result.entryname,
|
|
'exitcode': result.exitcode,
|
|
'message': (result.message or '').strip()[:200],
|
|
'receivedat': (report.receivedat.isoformat() + 'Z'
|
|
if report.receivedat else None),
|
|
} for result, report in rows])
|