openapi: 3.0.3

info:
  title: tomedo Connect Server API
  version: "1.0.0"
  description: |
    Rendezvous-Server für kontrollierten SSH-Zugang zu Kundeninstallationen ohne feste IP.

    Die vollständige Architektur steht in `KONZEPT.md`. Für das Verständnis dieser API
    genügen vier Punkte:

    1. **Der Kunde ist nicht direkt erreichbar.** Der Agent beim Kunden baut einen
       ausgehenden Reverse-SSH-Tunnel zum Gateway auf. Diese API vermittelt nur.
    2. **Ohne Zustimmung existiert kein Zugang.** Der Tunnel wird erst nach der
       Freigabe durch den Kunden aufgebaut — nicht vorher und nicht dauerhaft.
    3. **Es gibt genau drei Freigabedauern:** `H2` (2 Stunden), `H4` (4 Stunden),
       `HEUTE` (bis 23:59 in der Zeitzone des Kunden). Keine freie Eingabe,
       kein Dauerzugang.
    4. **Der Kunde kann jederzeit abbrechen.** Der Abbruch wirkt lokal auf dem
       Kundensystem; `POST /sessions/{id}/revoke` ist die Meldung an den Server,
       nicht der Auslöser.

    ## Rollen

    Support **und** Kunde melden sich am selben Keycloak an. Wer wer ist, entscheidet
    allein die Rolle im Token.

    | Rolle | Darf |
    |---|---|
    | `connect:customer` | Praxis-Konto: registrieren, Heartbeat, zustimmen, abbrechen |
    | `connect:request` | Freigabe anfragen, Kundenliste und Status sehen |
    | `connect:connect` | Zertifikat abrufen und sich verbinden |
    | `connect:admin` | Kunden und Policy verwalten, Audit lesen, Freigaben beenden |

    `connect:request` ist bewusst breiter vergebbar als `connect:connect` — Anfragen
    stellen darf mehr Personal als tatsächlich auf Kundensysteme zugreifen.
    `connect:customer` gehört ausschließlich Praxis-Konten und darf nie zusammen mit
    einer Support-Rolle vergeben werden.

    ## Transport

    Ausschließlich HTTPS. Der Server sendet HSTS und akzeptiert kein Klartext-HTTP.

  contact:
    name: zollsoft AG

servers:
  - url: https://connect.zollsoft.de:8443
    description: Produktion
  - url: https://localhost:8443
    description: Lokale Entwicklung (selbstsigniertes Zertifikat)

tags:
  - name: Kunde (Agent)
    description: |
      Endpunkte für den tomedo-Server der Praxis.
      Authentifizierung über das Keycloak-Konto der Praxis (Rolle
      `connect:customer`); die Kundennummer wird serverseitig daraus aufgelöst und
      steht nie im Request.
  - name: Support
    description: |
      Endpunkte für Supportmitarbeiter und die CLI `tomedo-connect`.
      Authentifizierung über Keycloak-Zugriffstoken.
  - name: Administration
    description: Kundenverwaltung, Policy und Audit. Erfordert `connect:admin`.
  - name: Intern (Gateway)
    description: |
      Wird ausschließlich vom `ssh-gateway`-Container aufgerufen (sshd
      `AuthorizedKeysCommand` / `AuthorizedPrincipalsCommand`) und ist nicht von außen
      erreichbar. Hier dokumentiert, weil das Verhalten sicherheitsrelevant ist.
  - name: Betrieb
    description: Health-Checks und Spezifikation.

security:
  - keycloak: []

paths:

  # ----------------------------------------------------------------- Kunde

  /api/v1/agents/register:
    post:
      tags: [Kunde (Agent)]
      summary: SSH-Schlüssel des Agents hinterlegen
      description: |
        Registriert den Agent für die Praxis, die zum angemeldeten Keycloak-Konto
        gehört. Der übergebene Public Key wird später vom Gateway benutzt, um den
        Reverse-Tunnel dieses Agents zu autorisieren.

        **Die Kundennummer steht bewusst nicht im Request** — sie wird aus dem
        Keycloak-Konto aufgelöst. Ein Praxis-Konto kann damit gar nicht erst für eine
        fremde Kundennummer sprechen.

        Es gibt kein Enrollment-Geheimnis: Ein zweites Token, das verteilt, rotiert
        und widerrufen werden müsste, entfällt. Wird das Konto in Keycloak
        deaktiviert, ist der Zugang sofort weg.

        Der Aufruf ist **idempotent**: Nach einer Neuinstallation darf derselbe
        Schlüssel erneut kommen.
      security:
        - keycloak: [connect:customer]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ssh_public_key]
              properties:
                ssh_public_key:
                  type: string
                  description: Inhalt von `agent_ed25519.pub`
                  example: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI… agent@praxis
                version: { type: string, example: "1.2.0" }
                os: { type: string, example: "macOS 15.3" }
      responses:
        "200":
          description: Agent registriert
          content:
            application/json:
              schema:
                type: object
                properties:
                  agent_id: { type: string, format: uuid }
                  kundennummer: { type: string }
                  ssh_key_fpr: { type: string }
                  gateway_host: { type: string }
                  gateway_port: { type: integer }
                  gateway_user: { type: string, example: agent }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403":
          description: Das Keycloak-Konto ist keiner Kundennummer zugeordnet
        "409":
          description: Der Schlüssel gehört bereits einem anderen Kunden

  /api/v1/agents/heartbeat:
    post:
      tags: [Kunde (Agent)]
      summary: Lebenszeichen senden und Zustand abholen
      description: |
        Der zentrale Endpunkt des Agents, üblicherweise im 60-Sekunden-Takt.

        Die Antwort enthält alles, was der Zustimmungsdialog beim Kunden braucht —
        insbesondere die **fertig berechneten Endzeitpunkte** je Option. Damit muss
        die Zeitzonen- und `HEUTE`-Logik nicht in jeder Client-Implementierung neu
        entstehen.

        Die öffentliche IP wird serverseitig aus der TCP-Verbindung übernommen und
        **nicht** aus dem Body gelesen — sonst wäre sie fälschbar. Sie dient nur der
        Diagnose, nie dem Verbindungsaufbau.
      security:
        - keycloak: [connect:customer]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                agent_id:
                  type: string
                  format: uuid
                  description: Aus der Registrierung. Ohne Angabe wird kein Lebenszeichen vermerkt.
                version: { type: string }
                os: { type: string }
      responses:
        "200":
          description: Aktueller Zustand
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HeartbeatResponse' }
              examples:
                offeneAnfrage:
                  summary: Support hat 4 Stunden angefragt
                  value:
                    serverzeit: "2026-08-07T13:20:11Z"
                    kundennummer: "12345"
                    zeitzone: "Europe/Berlin"
                    zugang_gesperrt: false
                    offene_anfrage:
                      id: "6f1c…"
                      angefragt_von: m.schmidt
                      grund: "Ticket #4711, Druckproblem"
                      vorschlag: H4
                      optionen:
                        - { option: H2, label: "2 Stunden", endet_um: "2026-08-07T15:20:11Z", endet_um_lokal: "17:20" }
                        - { option: H4, label: "4 Stunden", endet_um: "2026-08-07T17:20:11Z", endet_um_lokal: "19:20" }
                        - { option: HEUTE, label: "für heute", endet_um: "2026-08-07T21:59:59Z", endet_um_lokal: "23:59" }
                      laeuft_ab_am: "2026-08-07T13:50:11Z"
                    session: null
                ruhe:
                  summary: Nichts zu tun
                  value:
                    serverzeit: "2026-08-07T13:20:11Z"
                    kundennummer: "12345"
                    zeitzone: "Europe/Berlin"
                    zugang_gesperrt: false
                    offene_anfrage: null
                    session: null
        "401": { $ref: '#/components/responses/Unauthorized' }

  /api/v1/access-requests/{id}/approve:
    post:
      tags: [Kunde (Agent)]
      summary: Freigabe erteilen
      description: |
        Die Zustimmung des Kunden. **Erst hier entsteht überhaupt ein Netzwerkpfad zum
        Kundensystem** — vorher ist der Zugang nicht nur verboten, sondern technisch
        nicht vorhanden.

        Der Kunde darf eine **andere** Option wählen als die angefragte; das ist der
        Sinn des Dialogs. Zulässig ist die Schnittmenge aus globaler Policy und
        Kundeneinstellung.

        Die Antwort enthält den zugeteilten Tunnel-Port und den fertigen
        `autossh`-Befehl.
      security:
        - keycloak: [connect:customer]
      parameters:
        - { $ref: '#/components/parameters/RequestId' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [option]
              properties:
                option: { $ref: '#/components/schemas/Option' }
                entschieden_von:
                  type: string
                  description: Anzeigename am Kundenarbeitsplatz, landet im Audit-Log.
                  example: "Frau Meier, Empfang"
      responses:
        "201":
          description: Freigabe erteilt, Session angelegt
          content:
            application/json:
              schema:
                type: object
                properties:
                  session: { $ref: '#/components/schemas/AktiveSession' }
                  option: { $ref: '#/components/schemas/Option' }
                  tunnel_befehl:
                    type: string
                    example: autossh -M 0 -N -i /etc/tomedo-connect/agent_ed25519 -p 443 -R 20134:127.0.0.1:22 …
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403":
          description: Zugang durch den Kunden gesperrt (Kill-Switch aktiv)
        "409":
          description: Anfrage ist nicht mehr offen oder abgelaufen
        "422":
          description: Option nicht erlaubt oder zu kurz (`HEUTE` kurz vor Mitternacht)

  /api/v1/access-requests/{id}/deny:
    post:
      tags: [Kunde (Agent)]
      summary: Freigabe ablehnen
      security:
        - keycloak: [connect:customer]
      parameters:
        - { $ref: '#/components/parameters/RequestId' }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                entschieden_von: { type: string }
      responses:
        "204": { description: Abgelehnt }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "409": { description: Anfrage ist nicht mehr offen }

  /api/v1/sessions/{id}/tunnel-up:
    post:
      tags: [Kunde (Agent)]
      summary: Tunnel steht
      description: |
        Meldet, dass der Reverse-Tunnel aufgebaut ist (Status `PENDING` → `ACTIVE`).
        Rein informativ für die Anzeige — die Zugangskontrolle hängt nicht daran.
      security:
        - keycloak: [connect:customer]
      parameters:
        - { $ref: '#/components/parameters/SessionId' }
      responses:
        "204": { description: Vermerkt }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "404": { $ref: '#/components/responses/NotFound' }

  # ----------------------------------------------------------------- Support

  /api/v1/access-requests:
    post:
      tags: [Support]
      summary: Freigabe beim Kunden anfragen
      description: |
        Legt eine Anfrage an, die der Agent beim nächsten Heartbeat abholt.

        Die Anfrage **verfällt** nach `ANFRAGE_TTL` (Standard 30 Minuten) — eine
        unbeantwortete Anfrage von gestern darf nicht heute versehentlich bestätigt
        werden.

        Pro Kunde kann immer nur **eine** Anfrage offen sein. Ein zweiter Versuch
        liefert `409`; das verhindert, dass Kunden mit Anfragen bedrängt werden.
      security:
        - keycloak: [connect:request]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kundennummer, grund]
              properties:
                kundennummer: { type: string, example: "12345" }
                grund:
                  type: string
                  description: Erscheint dem Kunden im Dialog. Pflichtfeld.
                  example: "Ticket #4711, Druckproblem"
                ticket_ref: { type: string, example: "4711" }
                option:
                  allOf: [{ $ref: '#/components/schemas/Option' }]
                  description: Gewünschte Dauer. Ohne Angabe die Serververvorgabe.
      responses:
        "201":
          description: Anfrage angelegt
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccessRequest' }
        "403":
          description: Kunde hat den Zugang gesperrt
        "404": { $ref: '#/components/responses/NotFound' }
        "409":
          description: Für diesen Kunden ist bereits eine Anfrage offen
        "422":
          description: Option für diesen Kunden nicht zulässig
    get:
      tags: [Support]
      summary: Anfragen eines Kunden auflisten
      security:
        - keycloak: [connect:request]
      parameters:
        - { name: kundennummer, in: query, required: true, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 500 } }
      responses:
        "200":
          description: Liste
          content:
            application/json:
              schema:
                type: object
                properties:
                  anfragen:
                    type: array
                    items: { $ref: '#/components/schemas/AccessRequest' }

  /api/v1/customers:
    get:
      tags: [Support]
      summary: Kundenliste mit Status
      description: |
        Die Liste, die die Weboberfläche unter /kunden anzeigt.

        Der Status wird **serverseitig** zusammengefasst, damit alle Clients dieselbe
        Einstufung anzeigen und die Logik nicht in jeder Oberfläche neu entsteht.
        Alles kommt aus einer einzigen Datenbankabfrage — bei periodischem Abruf wären
        N+1-Abfragen teuer.
      security:
        - keycloak: [connect:request]
      parameters:
        - name: suche
          in: query
          schema: { type: string }
          description: Filtert über Kundennummer und Name.
        - name: limit
          in: query
          schema: { type: integer, default: 200, maximum: 1000 }
      responses:
        "200":
          description: Liste
          content:
            application/json:
              schema:
                type: object
                properties:
                  serverzeit: { type: string, format: date-time }
                  kunden:
                    type: array
                    items: { $ref: '#/components/schemas/CustomerListEntry' }
    put:
      tags: [Administration]
      summary: Kunde anlegen oder aktualisieren
      security:
        - keycloak: [connect:admin]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kundennummer, keycloak_user]
              properties:
                kundennummer: { type: string }
                keycloak_user:
                  type: string
                  description: Benutzername des Praxis-Kontos in Keycloak.
                keycloak_sub:
                  type: string
                  description: |
                    Optional. Wird beim ersten Login des Agents automatisch
                    nachgetragen. Ab dann gilt der Subject statt des Benutzernamens —
                    eine Umbenennung in Keycloak bricht die Zuordnung also nicht.
                name: { type: string }
                zeitzone: { type: string, example: Europe/Berlin }
                erlaubte_optionen:
                  type: array
                  items: { $ref: '#/components/schemas/Option' }
      responses:
        "200":
          description: Gespeichert
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "409":
          description: Das Keycloak-Konto ist bereits einem anderen Kunden zugeordnet

  /api/v1/customers/{nr}/status:
    get:
      tags: [Support]
      summary: Kundenstatus
      description: |
        Ist der Kunde online, welche Freigabeoptionen stehen zur Verfügung, läuft
        gerade eine Anfrage oder Session? Erster Aufruf vor jeder Anfrage.
      security:
        - keycloak: [connect:request]
      parameters:
        - { $ref: '#/components/parameters/Kundennummer' }
      responses:
        "200":
          description: Status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CustomerStatus' }
        "404": { $ref: '#/components/responses/NotFound' }

  /api/v1/sessions/{id}/connect:
    post:
      tags: [Support]
      summary: SSH-Zertifikat abrufen und verbinden
      description: |
        Stellt ein kurzlebiges, CA-signiertes SSH-Zertifikat aus.

        Zwei Eigenschaften sind sicherheitsrelevant:

        * **Gültigkeit endet exakt mit der Freigabe.** Ein Zertifikat kann den Ablauf
          nicht überleben.
        * **Der Principal ist die Session-ID**, nicht der Benutzername. Das Gateway
          leitet daraus den einzig erlaubten Tunnel-Port ab. Wird die Session
          abgebrochen, weist das Gateway das Zertifikat ab — auch wenn es zeitlich
          noch gültig wäre. Deshalb braucht es keine Revocation-Liste.

        Die Antwort enthält fertige `ssh`- und `scp`-Befehle.
      security:
        - keycloak: [connect:connect]
      parameters:
        - { $ref: '#/components/parameters/SessionId' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ssh_public_key]
              properties:
                ssh_public_key:
                  type: string
                  description: Eigener öffentlicher Schlüssel, z.B. `~/.ssh/tomedo_connect.pub`
                source_address:
                  type: string
                  description: |
                    Optionales CIDR, auf das das Zertifikat eingeschränkt wird
                    (z.B. das Büronetz). Empfohlen.
                  example: "203.0.113.0/24"
      responses:
        "200":
          description: Zertifikat ausgestellt
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConnectResponse' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403":
          description: Kunde hat zwischenzeitlich gesperrt
        "409":
          description: Freigabe ist beendet (abgelaufen oder abgebrochen)

  /api/v1/sessions/{id}:
    get:
      tags: [Support, Kunde (Agent)]
      summary: Session abfragen
      description: Restlaufzeit, Status und verbundener Mitarbeiter.
      security:
        - keycloak: [connect:request]
        - keycloak: [connect:customer]
      parameters:
        - { $ref: '#/components/parameters/SessionId' }
      responses:
        "200":
          description: Session
          content:
            application/json:
              schema:
                type: object
                properties:
                  session: { $ref: '#/components/schemas/Session' }
                  gateway_host: { type: string }
                  gateway_port: { type: integer }
                  restsekunden: { type: integer }
        "404": { $ref: '#/components/responses/NotFound' }

  /api/v1/sessions/{id}/revoke:
    post:
      tags: [Kunde (Agent), Administration]
      summary: Abbruch melden
      description: |
        **Wichtig: Dieser Endpunkt ist die Buchführung, nicht der Auslöser.**

        Wenn der Kunde „sofort beenden" wählt, kappt der Agent den Tunnel lokal —
        erst lokale Sperre setzen, dann `autossh` killen, dann die zugehörigen
        `sshd`-Kindprozesse aufräumen. Zu dem Zeitpunkt, an dem dieser Aufruf
        eintrifft, ist die Verbindung längst weg.

        Der Grund für diese Aufteilung: Wäre der Abbruch ein Server-Roundtrip,
        könnte ein Netzproblem — oder ein kompromittierter Server — den Kunden daran
        hindern, die Verbindung zu kappen.

        Der Aufruf ist deshalb **idempotent**: Eine bereits geschlossene Session
        liefert `200` mit `bereits_geschlossen: true`, damit die Retry-Queue des
        Agents nicht hängen bleibt.

        Support-Mitarbeiter benötigen `connect:admin`; der Agent darf nur seine
        eigene Session beenden.
      security:
        - keycloak: [connect:customer]
        - keycloak: [connect:admin]
      parameters:
        - { $ref: '#/components/parameters/SessionId' }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                grund: { type: string, description: Freitext fürs Audit-Log }
      responses:
        "200":
          description: Session geschlossen (oder war es bereits)
          content:
            application/json:
              schema:
                type: object
                properties:
                  session: { $ref: '#/components/schemas/Session' }
                  bereits_geschlossen: { type: boolean }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  /api/v1/customers/{nr}/policy:
    get:
      tags: [Support, Kunde (Agent)]
      summary: Freigabe-Policy abfragen
      description: |
        Welche der drei Optionen dieser Kunde anbieten darf, in welcher Zeitzone
        gerechnet wird und wann die Optionen jeweils enden würden.
      security:
        - keycloak: [connect:request]
        - keycloak: [connect:customer]
      parameters:
        - { $ref: '#/components/parameters/Kundennummer' }
      responses:
        "200":
          description: Policy
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Policy' }
        "404": { $ref: '#/components/responses/NotFound' }
    put:
      tags: [Administration]
      summary: Freigabe-Policy setzen
      description: |
        Schränkt die zulässigen Optionen für einen Kunden ein — etwa ein Klinikkunde
        vertraglich auf `["H2"]`. Mehr als global erlaubt ist nie möglich.

        Eine leere Liste wird abgewiesen: Der Kunde könnte dann nie zustimmen.
      security:
        - keycloak: [connect:admin]
      parameters:
        - { $ref: '#/components/parameters/Kundennummer' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [erlaubte_optionen]
              properties:
                erlaubte_optionen:
                  type: array
                  items: { $ref: '#/components/schemas/Option' }
                  example: ["H2", "H4"]
                zeitzone:
                  type: string
                  description: IANA-Name, kein Offset (wegen Sommerzeit).
                  example: Europe/Berlin
      responses:
        "200":
          description: Aktualisiert
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Policy' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "404": { $ref: '#/components/responses/NotFound' }

  /api/v1/customers/{nr}/sperre:
    put:
      tags: [Kunde (Agent), Administration]
      summary: Kill-Switch setzen oder lösen
      description: |
        Sperrt den Zugang komplett: Es werden keine neuen Anfragen mehr angenommen,
        **und eine laufende Freigabe wird sofort mit beendet**. Eine Sperre, die eine
        bestehende Verbindung weiterlaufen ließe, wäre keine.

        Mit `bis` befristbar; die Sperre läuft dann von selbst aus.
      security:
        - keycloak: [connect:customer]
        - keycloak: [connect:admin]
      parameters:
        - { $ref: '#/components/parameters/Kundennummer' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [gesperrt]
              properties:
                gesperrt: { type: boolean }
                bis:
                  type: string
                  format: date-time
                  description: Optionales Ende der Sperre. Ohne Angabe unbefristet.
                grund: { type: string }
      responses:
        "200":
          description: Gesetzt
          content:
            application/json:
              schema:
                type: object
                properties:
                  policy: { $ref: '#/components/schemas/Policy' }
                  session_beendet:
                    type: boolean
                    description: Ob durch die Sperre eine laufende Freigabe beendet wurde.
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  # ----------------------------------------------------------------- Admin

  /api/v1/whoami:
    get:
      tags: [Support, Kunde (Agent)]
      summary: Eigene Kennung und Rollen
      description: |
        Zeigt, als wer der Server den Aufrufer sieht. Erspart Rätselraten, wenn die
        Keycloak-Zuordnung klemmt: Fehlen hier die `connect:*`-Rollen, greift der
        Rollen-Mapper im Realm nicht, und jeder weitere Aufruf endet mit `403`.
      security:
        - keycloak: []
      responses:
        "200":
          description: Kennung
          content:
            application/json:
              schema:
                type: object
                properties:
                  art:
                    type: string
                    enum: [support, customer, gateway]
                  subject: { type: string }
                  benutzername: { type: string }
                  name: { type: string }
                  email: { type: string }
                  rollen:
                    type: array
                    items: { type: string }
                  kundennummer:
                    type: string
                    description: Nur bei Praxis-Konten gesetzt.

  /api/v1/audit:
    get:
      tags: [Administration]
      summary: Audit-Log lesen
      description: |
        Append-only Nachweisführung: Wer hat wann was angefragt, wer hat zugestimmt,
        wer war wie lange verbunden, wann wurde abgebrochen.
      security:
        - keycloak: [connect:admin]
      parameters:
        - { name: kundennummer, in: query, schema: { type: string } }
        - { name: von, in: query, schema: { type: string, format: date-time } }
        - { name: bis, in: query, schema: { type: string, format: date-time } }
        - { name: limit, in: query, schema: { type: integer, default: 200, maximum: 2000 } }
      responses:
        "200":
          description: Einträge, neueste zuerst
          content:
            application/json:
              schema:
                type: object
                properties:
                  eintraege:
                    type: array
                    items: { $ref: '#/components/schemas/AuditEntry' }

  # ----------------------------------------------------------------- Intern

  /internal/authkeys:
    get:
      tags: [Intern (Gateway)]
      summary: authorized_keys-Zeile für einen Agent
      description: |
        Wird von sshd über `AuthorizedKeysCommand` aufgerufen. Antwortet mit
        `text/plain`.

        Die Zeile erlaubt genau eines: das Öffnen des Tunnel-Ports dieser Session
        (`restrict,port-forwarding,permitopen="",permitlisten="127.0.0.1:<port>"`).
        Kein Shell-Zugang, kein PTY, keine fremden Ports.

        **Ohne aktive Freigabe ist die Antwort leer** — die Anmeldung scheitert dann.
        Dadurch fällt die Entscheidung bei jedem Verbindungsaufbau neu gegen den
        aktuellen Datenbankstand; es gibt keine Schlüsseldatei, die nach einem
        Widerruf veraltete Einträge enthalten könnte.
      security:
        - gatewaySecret: []
      parameters:
        - name: fingerprint
          in: query
          required: true
          schema: { type: string }
          example: SHA256:abc…
      responses:
        "200":
          description: Zeile oder leer
          content:
            text/plain:
              schema: { type: string }

  /internal/principals:
    get:
      tags: [Intern (Gateway)]
      summary: Principals-Zeile für ein Support-Zertifikat
      description: |
        Wird von sshd über `AuthorizedPrincipalsCommand` aufgerufen. Die Key-ID des
        Zertifikats enthält die Session-ID; die Antwort bindet den Zugang an den
        Tunnel-Port genau dieser Session.

        Damit wirkt ein Abbruch sofort auch auf bereits ausgestellte Zertifikate,
        ohne dass eine KRL gepflegt werden muss.
      security:
        - gatewaySecret: []
      parameters:
        - name: key_id
          in: query
          required: true
          schema: { type: string }
          description: sshd-Token `%i`, Format `<session-uuid>/<benutzer>`
      responses:
        "200":
          description: Zeile oder leer
          content:
            text/plain:
              schema: { type: string }

  /internal/active-ports:
    get:
      tags: [Intern (Gateway)]
      summary: Ports mit gültiger Freigabe
      description: |
        Der Reaper im Gateway kappt jeden lauschenden Tunnel-Port, der hier nicht
        auftaucht. Damit verschwinden auch Tunnel, deren Agent den Abbruch nicht
        umsetzen konnte.
      security:
        - gatewaySecret: []
      responses:
        "200":
          description: Ein Port je Zeile
          content:
            text/plain:
              schema: { type: string, example: "20134\n20287\n" }

  # ----------------------------------------------------------------- Betrieb

  /healthz:
    get:
      tags: [Betrieb]
      summary: Liveness
      security: []
      responses:
        "200": { description: Prozess läuft }

  /readyz:
    get:
      tags: [Betrieb]
      summary: Readiness (inkl. Datenbank)
      security: []
      responses:
        "200": { description: Bereit }
        "503": { description: Datenbank nicht erreichbar }

  /openapi.yaml:
    get:
      tags: [Betrieb]
      summary: Diese Spezifikation
      security: []
      responses:
        "200":
          description: OpenAPI-Dokument
          content:
            application/yaml:
              schema: { type: string }

components:

  securitySchemes:
    keycloak:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Keycloak-Zugriffstoken. **Beide Seiten** — Support und Kunde — nutzen dasselbe
        Keycloak; unterschieden werden sie allein über die Rollen. Diese werden aus
        `realm_access.roles` und `resource_access[<client>].roles` gelesen.

        Die Anmeldung der Kommandozeilen-Werkzeuge läuft über den Device
        Authorization Grant (RFC 8628) — auf einem Praxis-Server gibt es oft keinen
        Browser, und ein Passwort bekommt das Werkzeug so nie zu sehen.
    gatewaySecret:
      type: apiKey
      in: header
      name: X-Gateway-Secret
      description: Gemeinsames Geheimnis des `ssh-gateway`-Containers.

  parameters:
    Kundennummer:
      name: nr
      in: path
      required: true
      schema: { type: string }
      example: "12345"
    SessionId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    RequestId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }

  responses:
    BadRequest:
      description: Fehlerhafte Anfrage
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Nicht authentifiziert
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: Rolle fehlt oder Zugriff auf fremde Kundennummer
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Nicht gefunden
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: Zustandskonflikt
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:

    Option:
      type: string
      enum: [H2, H4, HEUTE]
      description: |
        Freigabedauer. Es gibt bewusst genau diese drei Werte:

        * `H2` — 2 Stunden ab Zustimmung
        * `H4` — 4 Stunden ab Zustimmung
        * `HEUTE` — bis 23:59:59 in der Zeitzone des Kunden

        `HEUTE` wird über die **Kundenzeitzone** gerechnet, nicht über die Serverzeit
        (der Container läuft auf UTC). Am späten Abend ist `HEUTE` daher **kürzer** als
        `H2` — deshalb liefert die API zu jeder Option den konkreten Endzeitpunkt mit,
        damit der Dialog „bis 23:59" anzeigen kann. Ist eine Option kürzer als
        5 Minuten, entfällt sie.

    OptionAngebot:
      type: object
      properties:
        option: { $ref: '#/components/schemas/Option' }
        label: { type: string, example: "2 Stunden" }
        endet_um: { type: string, format: date-time }
        endet_um_lokal:
          type: string
          example: "17:20"
          description: Endzeit in der Zeitzone des Kunden, fertig für die Anzeige.

    CustomerListEntry:
      type: object
      description: |
        Eine Zeile der Kundenliste. `status` ist die serverseitig zusammengefasste
        Einstufung — Clients sollen darauf einfärben und nicht `status_text` parsen.
      properties:
        kundennummer: { type: string }
        name: { type: string }
        keycloak_user: { type: string }
        zeitzone: { type: string }
        online:
          type: boolean
          description: Agent hat sich kürzlich gemeldet.
        letzter_kontakt: { type: string, format: date-time, nullable: true }
        agent_anzahl: { type: integer }
        zugang_gesperrt: { type: boolean }
        gesperrt_bis: { type: string, format: date-time, nullable: true }
        status:
          type: string
          enum: [GESPERRT, OFFLINE, BEREIT, ANGEFRAGT, FREIGABE, VERBUNDEN]
          description: |
            `GESPERRT` Kunde hat den Zugang gesperrt ·
            `OFFLINE` kein Lebenszeichen ·
            `BEREIT` online, nichts offen ·
            `ANGEFRAGT` Anfrage läuft, Kunde hat noch nicht entschieden ·
            `FREIGABE` freigegeben, Tunnel noch nicht aufgebaut ·
            `VERBUNDEN` Tunnel steht
        status_text:
          type: string
          description: Erläuterung für die Anzeige, z.B. "wartet auf Entscheidung, verfällt in 12 min".
        anfrage_id: { type: string, format: uuid, nullable: true }
        anfrage_option: { type: string }
        anfrage_laeuft_ab_am: { type: string, format: date-time, nullable: true }
        session_id: { type: string, format: uuid, nullable: true }
        tunnel_port: { type: integer, nullable: true }
        gueltig_bis: { type: string, format: date-time, nullable: true }
        restsekunden: { type: integer }
        verbunden_von: { type: string }

    Customer:
      type: object
      properties:
        kundennummer: { type: string }
        keycloak_user: { type: string }
        keycloak_sub: { type: string }
        name: { type: string }
        aktiv: { type: boolean }
        zeitzone: { type: string }
        erlaubte_optionen:
          type: array
          items: { $ref: '#/components/schemas/Option' }
        zugang_gesperrt: { type: boolean }
        gesperrt_bis: { type: string, format: date-time, nullable: true }

    Policy:
      type: object
      properties:
        kundennummer: { type: string }
        zeitzone: { type: string }
        erlaubte_optionen:
          type: array
          items: { $ref: '#/components/schemas/Option' }
          description: Schnittmenge aus globaler Policy und Kundeneinstellung.
        globale_optionen:
          type: array
          items: { $ref: '#/components/schemas/Option' }
        standard: { $ref: '#/components/schemas/Option' }
        angebote:
          type: array
          items: { $ref: '#/components/schemas/OptionAngebot' }
        zugang_gesperrt: { type: boolean }
        gesperrt_bis: { type: string, format: date-time, nullable: true }

    AccessRequest:
      type: object
      properties:
        id: { type: string, format: uuid }
        kundennummer: { type: string }
        angefragt_von: { type: string }
        grund: { type: string }
        ticket_ref: { type: string }
        option_angefragt: { $ref: '#/components/schemas/Option' }
        option_gewaehrt:
          allOf: [{ $ref: '#/components/schemas/Option' }]
          nullable: true
          description: Was der Kunde tatsächlich freigegeben hat — kann abweichen.
        status:
          type: string
          enum: [PENDING, APPROVED, DENIED, EXPIRED, REVOKED]
        erstellt_am: { type: string, format: date-time }
        laeuft_ab_am:
          type: string
          format: date-time
          description: Danach ist die Anfrage nicht mehr bestätigbar.
        entschieden_am: { type: string, format: date-time, nullable: true }
        entschieden_von: { type: string }
        session_id: { type: string, format: uuid, nullable: true }

    Session:
      type: object
      properties:
        id: { type: string, format: uuid }
        access_request_id: { type: string, format: uuid }
        kundennummer: { type: string }
        tunnel_port:
          type: integer
          description: |
            Port am Gateway, auf `127.0.0.1` gebunden. Von außen **nicht** erreichbar —
            nur über einen authentifizierten ProxyJump.
          example: 20134
        status:
          type: string
          enum: [PENDING, ACTIVE, CLOSED]
          description: |
            `PENDING` = freigegeben, Tunnel noch nicht aufgebaut.
            `ACTIVE` = Tunnel steht. `CLOSED` = beendet.
        gueltig_ab: { type: string, format: date-time }
        gueltig_bis: { type: string, format: date-time }
        geschlossen_am: { type: string, format: date-time, nullable: true }
        geschlossen_grund:
          type: string
          enum: [EXPIRED, REVOKED_CUSTOMER, REVOKED_ADMIN, AGENT_GONE, MANUAL]
        geschlossen_von: { type: string }
        verbunden_von:
          type: string
          description: Support-Mitarbeiter, der zuletzt ein Zertifikat abgerufen hat.

    AktiveSession:
      type: object
      properties:
        id: { type: string, format: uuid }
        tunnel_port: { type: integer }
        gateway_host: { type: string }
        gateway_port: { type: integer }
        status: { type: string }
        gueltig_bis: { type: string, format: date-time }
        restsekunden: { type: integer }
        verbunden_von: { type: string }

    HeartbeatResponse:
      type: object
      properties:
        serverzeit: { type: string, format: date-time }
        kundennummer: { type: string }
        zeitzone: { type: string }
        zugang_gesperrt: { type: boolean }
        gesperrt_bis: { type: string, format: date-time, nullable: true }
        offene_anfrage:
          nullable: true
          type: object
          properties:
            id: { type: string, format: uuid }
            angefragt_von: { type: string }
            grund: { type: string }
            ticket_ref: { type: string }
            vorschlag:
              allOf: [{ $ref: '#/components/schemas/Option' }]
              description: Vom Support gewünscht. Vorauswahl, keine Vorgabe.
            optionen:
              type: array
              items: { $ref: '#/components/schemas/OptionAngebot' }
              description: Was der Kunde wählen darf, mit fertigen Endzeiten.
            erstellt_am: { type: string, format: date-time }
            laeuft_ab_am: { type: string, format: date-time }
        session:
          nullable: true
          allOf: [{ $ref: '#/components/schemas/AktiveSession' }]

    CustomerStatus:
      type: object
      properties:
        kundennummer: { type: string }
        name: { type: string }
        zeitzone: { type: string }
        zugang_gesperrt: { type: boolean }
        gesperrt_bis: { type: string, format: date-time, nullable: true }
        verfuegbare_optionen:
          type: array
          items: { $ref: '#/components/schemas/OptionAngebot' }
        agents:
          type: array
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              online: { type: boolean }
              letzter_kontakt: { type: string, format: date-time }
              letzte_ip:
                type: string
                description: |
                  Nur Diagnose und Inventar — **nicht** der Verbindungsweg. Der Zugang
                  läuft immer über den vom Kunden aufgebauten Reverse-Tunnel.
              version: { type: string }
              os: { type: string }
              ssh_key_fpr: { type: string }
        offene_anfrage:
          nullable: true
          allOf: [{ $ref: '#/components/schemas/AccessRequest' }]
        session:
          nullable: true
          allOf: [{ $ref: '#/components/schemas/AktiveSession' }]

    ConnectResponse:
      type: object
      properties:
        session_id: { type: string, format: uuid }
        kundennummer: { type: string }
        gateway_host: { type: string }
        gateway_port: { type: integer }
        tunnel_port: { type: integer }
        gateway_user: { type: string, example: support }
        zertifikat:
          type: string
          description: |
            Signiertes SSH-Zertifikat. Als `<key>-cert.pub` neben dem privaten
            Schlüssel ablegen, dann nimmt `ssh` es automatisch.
        gueltig_bis: { type: string, format: date-time }
        restsekunden: { type: integer }
        ssh_befehl:
          type: string
          example: ssh -J support@connect.zollsoft.de:443 -p 20134 tomedo@127.0.0.1
        scp_befehl:
          type: string
          example: scp -J support@connect.zollsoft.de:443 -P 20134 tomedo@127.0.0.1:/var/log/tomedo/'*.log' ./logs/

    AuditEntry:
      type: object
      properties:
        ts: { type: string, format: date-time }
        akteur:
          type: string
          description: Support-Benutzername, `agent:<uuid>`, `gateway` oder `system`.
        aktion:
          type: string
          example: session.opened
        kundennummer: { type: string }
        session_id: { type: string, format: uuid, nullable: true }
        quell_ip: { type: string }
        details: { type: object, additionalProperties: true }

    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
                - bad_request
                - unauthorized
                - forbidden
                - not_found
                - conflict
                - zugang_gesperrt
                - option_nicht_erlaubt
                - internal_error
              description: |
                Maschinenlesbar — Clients sollen hierauf reagieren, nicht auf
                `message` (Text kann sich ändern).
            message: { type: string }
