seperate port and container for external callback
This commit is contained in:
@@ -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,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.
|
||||
|
||||
|
||||
+40
@@ -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
@@ -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"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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 180–300 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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = "0.5.5.63"
|
||||
APP_VERSION = "0.5.5.64"
|
||||
__version__ = APP_VERSION
|
||||
|
||||
+34
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user