seperate port and container for external callback

This commit is contained in:
root
2026-08-09 10:24:53 +02:00
parent 3ff0794cfc
commit 639c2b68c8
13 changed files with 155 additions and 16 deletions
+3
View File
@@ -9,3 +9,6 @@ LOCAL_ADMIN_USERNAME=emergency-admin
LOCAL_ADMIN_PASSWORD=CHANGE_ME_MIN_12_CHARS
BACKUP_INTERVAL_HOURS=8
BACKUP_RETENTION_DAYS=3
# Optional host port for the callback-only Docker Compose profile.
CALLBACK_PORT=8090
+1 -1
View File
@@ -1,4 +1,4 @@
# AssetManager 0.5.5.59
# AssetManager 0.5.5.64
AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. It provides asset inventory, software inventory, remote job execution, reporting, privacy/retention documentation, and optional integration with MeshCentral.
+1 -1
View File
@@ -1 +1 @@
0.5.5.63
0.5.5.64
+40
View File
@@ -14,6 +14,46 @@ BASE_TRANSLATIONS = {
"common.import": ("Import", "Importieren"), "common.back": ("Back", "Zurück"), "common.yes": ("Yes", "Ja"), "common.no": ("No", "Nein"),
"common.active": ("Active", "Aktiv"), "common.actions": ("Actions", "Aktionen"), "common.name": ("Name", "Name"),
"common.description": ("Description", "Beschreibung"), "common.language": ("Language", "Sprache"), "common.all": ("All", "Alle"),
"fields.title": ("Fields", "Felder"),
"fields.add": ("Add field", "Feld hinzufügen"),
"fields.edit": ("Edit field", "Feld bearbeiten"),
"fields.catalog_help": ("Central labels and data types apply immediately to all categories, forms, tables and exports.", "Zentrale Bezeichnungen und Datentypen gelten sofort für alle Kategorien, Formulare, Tabellen und Exporte."),
"fields.field": ("Field", "Feld"),
"fields.label": ("Label", "Bezeichnung"),
"fields.data_type": ("Data type", "Datentyp"),
"fields.read_only": ("Read only", "Nur lesen"),
"fields.system": ("System", "System"),
"fields.stored_values": ("Stored values", "Gespeicherte Werte"),
"fields.delete_confirm": ("Delete this field and all values?", "Dieses Feld und alle gespeicherten Werte löschen?"),
"fields.confirm_values": ("Confirm values", "Werte bestätigen"),
"fields.technical_name": ("Technical field name", "Technischer Feldname"),
"fields.technical_name_help": ("Letters, numbers and underscores only.", "Nur Buchstaben, Zahlen und Unterstriche."),
"fields.automatic": ("automatic", "automatisch"),
"fields.default_field": ("Default field", "Standardfeld"),
"fields.searchable": ("Searchable", "Durchsuchbar"),
"fields.filterable": ("Filterable", "Filterbar"),
"fields.chart_enabled": ("Available for charts", "Für Diagramme verfügbar"),
"fields.excel_import": ("Excel import", "Excel-Import"),
"fields.excel_export": ("Excel export", "Excel-Export"),
"fields.bulk_edit": ("Bulk edit", "Sammelbearbeitung"),
"fields.data_type_short_text": ("Short text", "Kurztext"),
"fields.data_type_long_text": ("Long text", "Langtext"),
"fields.data_type_integer": ("Integer", "Ganzzahl"),
"fields.data_type_decimal": ("Decimal number", "Dezimalzahl"),
"fields.data_type_boolean": ("Boolean", "Ja/Nein"),
"fields.data_type_date": ("Date", "Datum"),
"fields.data_type_datetime": ("Date and time", "Datum und Uhrzeit"),
"fields.data_type_url": ("URL", "URL"),
"fields.data_type_email": ("E-mail address", "E-Mail-Adresse"),
"fields.data_type_ip": ("IP address", "IP-Adresse"),
"fields.data_type_json": ("JSON", "JSON"),
"fields.data_type_asset_reference": ("Asset reference", "Asset-Verweis"),
"fields.data_type_user_reference": ("User reference", "Benutzer-Verweis"),
"fields.data_type_status": ("Status", "Status"),
"software.settings.dedicated_listener": ("Dedicated callback listener", "Separater Callback-Zugang"),
"software.settings.dedicated_listener_optional": ("Optional callback API only", "Optional nur Callback-API"),
"software.settings.dedicated_listener_help": ("For Internet/DMZ scenarios, the callback API can run as a separate Docker Compose service on host port {port}. This listener exposes only the callback endpoint and health check, never the AssetManager web interface. Configure the public FQDN above as the callback base URL.", "Für Internet-/DMZ-Szenarien kann die Callback-API als eigener Docker-Compose-Dienst auf Host-Port {port} laufen. Dieser Zugang stellt ausschließlich den Callback-Endpunkt und den Healthcheck bereit, niemals die AssetManager-Weboberfläche. Trage oben den öffentlichen FQDN als Callback-Basisadresse ein."),
"profile.title": ("User profile", "Benutzerprofil"), "profile.username": ("Username", "Benutzername"),
"profile.display_name": ("Display name", "Anzeigename"), "profile.email": ("Email", "E-Mail"), "profile.source": ("Source", "Quelle"),
"profile.last_login": ("Last sign-in", "Letzte Anmeldung"), "profile.change_password": ("Change password", "Passwort ändern"),
+12
View File
@@ -212,6 +212,15 @@ if not ldap_logger.handlers:
ldap_logger.addHandler(ldap_console_handler)
app = FastAPI(title=os.getenv("APP_TITLE", "AssetManager"))
# Dedicated callback-only ASGI application. It deliberately exposes only the
# software callback endpoint and its health endpoint so deployments may publish
# callbacks through a separate port/FQDN without exposing the AssetManager UI.
callback_app = FastAPI(
title=f"{os.getenv('APP_TITLE', 'AssetManager')} Callback",
docs_url=None,
redoc_url=None,
openapi_url=None,
)
_session_cfg = load_config().get("authentication", {})
_session_env = _session_cfg.get("session_secret_env", "SESSION_SECRET")
_session_secret = os.getenv(_session_env) or os.getenv("SESSION_SECRET") or "assetmanager-change-this-session-secret"
@@ -4870,6 +4879,7 @@ def category_delete(category_id: int, request: Request, db: Session = Depends(ge
@app.get("/api/software-callback/health")
@callback_app.get("/api/software-callback/health")
def software_callback_health():
return {
"ok": True,
@@ -6397,6 +6407,7 @@ def _process_software_job_callback(
@app.post("/api/software-jobs/{job_id}/callback")
@callback_app.post("/api/software-jobs/{job_id}/callback")
async def software_job_callback(job_id: int, request: Request, token: str = ""):
client_host = request.client.host if request.client else "-"
content_type = request.headers.get("content-type", "-")
@@ -7775,6 +7786,7 @@ def settings_software_page(request: Request):
"callback_health_url": f"{callback_base}/api/software-callback/health" if callback_base else "",
"debug_log": _software_debug_tail(),
"debug_log_path": str(SOFTWARE_CALLBACK_DEBUG_LOG),
"callback_dedicated_port": str(os.getenv("CALLBACK_PORT", "8090") or "8090"),
},
)
+8 -8
View File
@@ -1,11 +1,11 @@
{% extends 'base.html' %}{% block content %}<h1>{% if definition %}Edit field{% else %}Add field{% endif %}</h1>
{% extends 'base.html' %}{% block content %}<h1>{% if definition %}{{ t('fields.edit') }}{% else %}{{ t('fields.add') }}{% endif %}</h1>
<form method="post" class="settings-form">
{% if not definition %}<label>Technical field name<input name="field_name" pattern="[a-zA-Z0-9_]+" required><small>Letters, numbers and underscores only.</small></label>{% else %}<label>Technical field name<input value="{{definition.field_name}}" readonly></label>{% endif %}
<label>Label<input name="label" value="{{definition.label if definition else ''}}" required></label><label>Description<textarea name="description">{{definition.description or '' if definition else ''}}</textarea></label>
<label>Data type<select name="data_type">{% for key,label in data_types.items() %}<option value="{{key}}" {% if definition and definition.data_type==key %}selected{% endif %}>{{label}}</option>{% endfor %}</select></label><label>{{ t('fields.list_width', 'Breite in Listen (Pixel)') }}
<input type="number" name="list_width" min="40" max="800" step="10" value="{{ definition.list_width if definition and definition.list_width else '' }}" placeholder="automatisch">
<small>{{ t('fields.list_width_hint', 'Leer = automatische Breite. Sinnvoll sind etwa 60 für kurze Werte und 180300 für längere Texte.') }}</small>
{% if not definition %}<label>{{ t('fields.technical_name') }}<input name="field_name" pattern="[a-zA-Z0-9_]+" required><small>{{ t('fields.technical_name_help') }}</small></label>{% else %}<label>{{ t('fields.technical_name') }}<input value="{{definition.field_name}}" readonly></label>{% endif %}
<label>{{ t('fields.label') }}<input name="label" value="{{definition.label if definition else ''}}" required></label><label>{{ t('common.description') }}<textarea name="description">{{definition.description or '' if definition else ''}}</textarea></label>
<label>{{ t('fields.data_type') }}<select name="data_type">{% for key,label in data_types.items() %}<option value="{{key}}" {% if definition and definition.data_type==key %}selected{% endif %}>{{ t('fields.data_type_' ~ key, label) }}</option>{% endfor %}</select></label><label>{{ t('fields.list_width') }}
<input type="number" name="list_width" min="40" max="800" step="10" value="{{ definition.list_width if definition and definition.list_width else '' }}" placeholder="{{ t('fields.automatic') }}">
<small>{{ t('fields.list_width_hint') }}</small>
</label>
<div class="checkbox-row"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="readonly" {% if definition and definition.readonly %}checked{% endif %}{% if definition and definition.field_name in ['mesh_node_id','mesh_mtype'] %} disabled{% endif %}> Read only</label>{% if definition and not definition.is_system %}<label class="checkbox-label"><input class="checkbox" type="checkbox" name="is_active" {% if definition.is_active %}checked{% endif %}> Active</label>{% endif %}<label class="checkbox-label"><input class="checkbox" type="checkbox" name="is_default" {% if definition and definition.is_default %}checked{% endif %}> Standardfeld</label></div>
<div class="checkbox-row">{% for key,label in [('searchable','Searchable'),('filterable','Filterable'),('chart_enabled','Available for charts'),('excel_import','Excel import'),('excel_export','Excel export'),('bulk_edit','Bulk edit')] %}<label class="checkbox-label"><input class="checkbox" type="checkbox" name="{{key}}" {% if not definition or definition|attr(key) %}checked{% endif %}> {{label}}</label>{% endfor %}</div>
<div class="checkbox-row"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="readonly" {% if definition and definition.readonly %}checked{% endif %}{% if definition and definition.field_name in ['mesh_node_id','mesh_mtype','mesh_icon'] %} disabled{% endif %}> {{ t('fields.read_only') }}</label>{% if definition and not definition.is_system %}<label class="checkbox-label"><input class="checkbox" type="checkbox" name="is_active" {% if definition.is_active %}checked{% endif %}> {{ t('common.active') }}</label>{% endif %}<label class="checkbox-label"><input class="checkbox" type="checkbox" name="is_default" {% if definition and definition.is_default %}checked{% endif %}> {{ t('fields.default_field') }}</label></div>
<div class="checkbox-row">{% for key,label_key in [('searchable','fields.searchable'),('filterable','fields.filterable'),('chart_enabled','fields.chart_enabled'),('excel_import','fields.excel_import'),('excel_export','fields.excel_export'),('bulk_edit','fields.bulk_edit')] %}<label class="checkbox-label"><input class="checkbox" type="checkbox" name="{{key}}" {% if not definition or definition|attr(key) %}checked{% endif %}> {{ t(label_key) }}</label>{% endfor %}</div>
<button class="button button-save">💾 {{t('common.save')}}</button></form>{% endblock %}
+4 -4
View File
@@ -1,6 +1,6 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><h1>Fields</h1><div class="toolbar-actions"><a class="button" href="/settings/fields/new"> Add field</a><a class="button button-secondary" href="/settings">{{ t('common.back') }}</a></div></div>
<p class="muted">Central labels and data types apply immediately to all categories, forms, tables and exports.</p>
<div class="sticky-table management-table-scroll"><table class="data-table"><thead><tr><th>Field</th><th>Label</th><th>Description</th><th>Data type</th><th>Read only</th><th>System</th><th>Stored values</th><th>{{ t('common.actions') }}</th></tr></thead><tbody>
{% for d in definitions %}<tr><td><code>{{d.field_name}}</code></td><td>{{d.label}}</td><td>{{d.description or ''}}</td><td>{{data_types.get(d.data_type,d.data_type)}}</td><td>{{t('common.yes') if d.readonly else t('common.no')}}</td><td>{{t('common.yes') if d.is_system else t('common.no')}}</td><td>{{value_counts.get(d.id,0)}}</td><td><a class="button button-secondary" href="/settings/fields/{{d.id}}/edit">{{t('common.edit')}}</a>{% if not d.is_system %}<form class="inline-form" method="post" action="/settings/fields/{{d.id}}/delete" onsubmit="return confirm('Delete this field and all values?');"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="confirm_values">Confirm values</label><button class="button button-danger">{{t('common.delete')}}</button></form>{% endif %}</td></tr>{% endfor %}
<div class="toolbar"><h1>{{ t('fields.title') }}</h1><div class="toolbar-actions"><a class="button" href="/settings/fields/new"> {{ t('fields.add') }}</a><a class="button button-secondary" href="/settings">{{ t('common.back') }}</a></div></div>
<p class="muted">{{ t('fields.catalog_help') }}</p>
<div class="sticky-table management-table-scroll"><table class="data-table"><thead><tr><th>{{ t('fields.field') }}</th><th>{{ t('fields.label') }}</th><th>{{ t('common.description') }}</th><th>{{ t('fields.data_type') }}</th><th>{{ t('fields.read_only') }}</th><th>{{ t('fields.system') }}</th><th>{{ t('fields.stored_values') }}</th><th>{{ t('common.actions') }}</th></tr></thead><tbody>
{% for d in definitions %}<tr><td><code>{{d.field_name}}</code></td><td>{{d.label}}</td><td>{{d.description or ''}}</td><td>{{ t('fields.data_type_' ~ d.data_type, data_types.get(d.data_type,d.data_type)) }}</td><td>{{t('common.yes') if d.readonly else t('common.no')}}</td><td>{{t('common.yes') if d.is_system else t('common.no')}}</td><td>{{value_counts.get(d.id,0)}}</td><td><a class="button button-secondary" href="/settings/fields/{{d.id}}/edit">{{t('common.edit')}}</a>{% if not d.is_system %}<form class="inline-form" method="post" action="/settings/fields/{{d.id}}/delete" onsubmit="return confirm({{ t('fields.delete_confirm')|tojson }});"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="confirm_values">{{ t('fields.confirm_values') }}</label><button class="button button-danger">{{t('common.delete')}}</button></form>{% endif %}</td></tr>{% endfor %}
</tbody></table></div>{% endblock %}
+10
View File
@@ -129,6 +129,16 @@
<span>{{ t('software.settings.health_url') }}</span>
<code id="callback-health-preview">{{ callback_health_url or t('software.settings.automatic_url') }}</code>
</div>
<div class="software-callback-preview software-settings-wide">
<span>{{ t('software.settings.dedicated_listener') }}</span>
<div>
<strong>{{ t('software.settings.dedicated_listener_optional') }}</strong>
<p class="muted">{{ t('software.settings.dedicated_listener_help', port=callback_dedicated_port) }}</p>
<code>CALLBACK_PORT={{ callback_dedicated_port }}</code>
<code>docker compose --profile callback up -d callback</code>
</div>
</div>
</div>
</section>
+1 -1
View File
@@ -1,2 +1,2 @@
APP_VERSION = "0.5.5.63"
APP_VERSION = "0.5.5.64"
__version__ = APP_VERSION
+34 -1
View File
@@ -34,11 +34,12 @@ services:
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-3}
MESHCENTRAL_PASSWORD: ${MESHCENTRAL_PASSWORD:-}
SYNC_LOG_DIR: /app/data/logs/sync
DIAGNOSTIC_DIR: /app/data/logs/diagnostics
DIAGNOSTIC_DIR: /app/data/logs/diagnostics
LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD:-}
SESSION_SECRET: ${SESSION_SECRET:-}
LOCAL_ADMIN_USERNAME: ${LOCAL_ADMIN_USERNAME:-}
LOCAL_ADMIN_PASSWORD: ${LOCAL_ADMIN_PASSWORD:-}
CALLBACK_PORT: ${CALLBACK_PORT:-8090}
ports:
- "${APP_PORT:-8088}:8000"
@@ -49,3 +50,35 @@ services:
- ./data/backups:/data/backups
- ./data/scripts:/scripts
# Optional callback-only listener for Internet/DMZ scenarios.
# Start with: docker compose --profile callback up -d
# Only the callback POST endpoint and callback health endpoint are exposed.
callback:
build: .
profiles: ["callback"]
container_name: assetmanager-callback
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-assetmanager}:${POSTGRES_PASSWORD:-change-me}@db:5432/${POSTGRES_DB:-assetmanager}
APP_TITLE: AssetManager
APP_CONFIG: /app/config/config.json
APPINFO_PATH: /app/config/APPINFO.json
MESHCENTRAL_PASSWORD: ${MESHCENTRAL_PASSWORD:-}
LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD:-}
SESSION_SECRET: ${SESSION_SECRET:-}
command: ["uvicorn", "app.main:callback_app", "--host", "0.0.0.0", "--port", "8001"]
ports:
- "${CALLBACK_PORT:-8090}:8001"
volumes:
- ./data/config:/app/config
- ./data/logs:/app/data/logs
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8001/api/software-callback/health', timeout=3).read()"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
+30
View File
@@ -286,3 +286,33 @@ You can verify the active mount with:
```
Images copied directly into `./data/uploads/library` are available in the AssetManager image library after reloading the page.
## Optional dedicated callback port / DMZ listener
AssetManager can expose software-job callbacks through a separate, callback-only Docker service. This is useful when remote devices must send callbacks through an Internet-facing FQDN while the normal AssetManager web interface remains internal.
The optional service exposes only:
- `GET /api/software-callback/health`
- `POST /api/software-jobs/{job_id}/callback`
It does not expose the AssetManager UI, login, assets, settings, static files, or administration routes.
Set the desired host port in `.env`:
```env
CALLBACK_PORT=8090
```
Start the additional service:
```bash
docker compose --profile callback up -d callback
```
The internal callback container listens on port `8001`; Docker maps the configured host port to it. A reverse proxy or firewall can therefore publish only this port/service. For example, a public `https://callback.example.org` can proxy to the Docker host on port `8090`. Configure that public URL under **Software and Jobs → Settings → Callback base URL**.
The standard AssetManager application continues to run on `APP_PORT` (default `8088`). The dedicated callback service is optional; installations that do not enable the `callback` profile behave as before.
For image-based installations, use the same AssetManager image for both the `app` and `callback` services instead of `build: .`. The callback service must always run the same version as the main application.
+1
View File
@@ -1,3 +1,4 @@
- [0.5.5.64](UPDATE-0.5.5.64.md)
- [0.5.5.35](UPDATE-0.5.5.35.md)
- [0.5.5.34](UPDATE-0.5.5.34.md)
# Version history
+10
View File
@@ -0,0 +1,10 @@
# Update 0.5.5.64
## Dedicated callback listener and field-page translations
- Added an optional callback-only Docker Compose service for Internet/DMZ deployments.
- The callback service exposes only the software callback POST route and callback health route; the AssetManager UI is not available on that service.
- Added `CALLBACK_PORT` to `.env.example` with default host port `8090`.
- The optional listener is started with `docker compose --profile callback up -d`.
- The existing callback base URL continues to define the public HTTP/HTTPS FQDN used by job scripts.
- Translated the central field-management page and field editor, including the previously hard-coded **Add field** button and data-type labels.