fix(backend): Neustart gegen die eigene Datenbank, /info als Lebendprobe
Der Dienst lief einmal und stuerzte danach in einer Schleife: MODE=PostgreSQL laesst H2 unquotierte Bezeichner klein anlegen, Liquibase sucht seine Verwaltungstabellen gross, findet nichts, legt sie an — "Table databasechangelog already exists". Der erste Start ging, jeder weitere nicht. Gemessen mit dem echten Jar: ohne den Modus laufen beide. Liquibase auf Kleinschreibung zu konfigurieren half nicht, es korrigiert den Namen selbst zurueck — deshalb weicht der Modus ganz. Die Testsuite konnte das nicht finden (jeder Test bekommt eine frische In-Memory-DB), und der Regressionstest dafuer hat zweimal gelogen: erst reichte er die URL als Default-Property herein, die die application.yaml ueberstimmt; dann als Argument, aber damit pruefte er eine URL, die er sich selbst ausgedacht hatte. Jetzt hat die URL einen Regler (werkbaum.data-dir), der Test ueberschreibt nur den, und die Gegenprobe faellt. Dieselbe Sorte Fehler eine Ebene hoeher: Die Testkonfiguration hiess application.yaml und verdeckte damit die Hauptkonfiguration vollstaendig. Sie ist jetzt eine Profil-Ueberlagerung. Dazu GET /api/v1/info mit Name, Version und Bauzeitpunkt. Die Lebendprobe erwartete bisher eine 404 von einem Dokument, das es nicht gibt — ein erwarteter Fehler ist eine schlechte Zusicherung, dieselbe 404 liefert auch ein falsch konfigurierter Proxy. 138 Backend-Tests. Gegenprobe: MODE=PostgreSQL zurueck -> genau der Neustart-Test faellt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
78901793f0
commit
dea904814b
@@ -270,9 +270,15 @@ The backend is a separate deploy, because it is a service rather than files:
|
|||||||
```bash
|
```bash
|
||||||
scripts/install-jdk.sh # once: a JDK 21 into the server's home
|
scripts/install-jdk.sh # once: a JDK 21 into the server's home
|
||||||
scripts/deploy-backend.sh # build, upload, systemd user unit, restart
|
scripts/deploy-backend.sh # build, upload, systemd user unit, restart
|
||||||
|
scripts/reset-password.sh # asks for the master password, hashes it there
|
||||||
scripts/deploy-prod.sh # the editor — also writes the /api/ proxy rule
|
scripts/deploy-prod.sh # the editor — also writes the /api/ proxy rule
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`GET /api/v1/info` answers with name, version and build time — that is the
|
||||||
|
liveness check, for the deploy and for monitoring. Expecting a **404** from a
|
||||||
|
document that does not exist would be a poor assurance: a misconfigured proxy
|
||||||
|
returns one too.
|
||||||
|
|
||||||
Configuration lives in the git-ignored `.env` (template `.env.example`):
|
Configuration lives in the git-ignored `.env` (template `.env.example`):
|
||||||
`BACKEND_SSH`, and optionally `BACKEND_DIR`, `BACKEND_JDK_DIR`, `BACKEND_PORT`,
|
`BACKEND_SSH`, and optionally `BACKEND_DIR`, `BACKEND_JDK_DIR`, `BACKEND_PORT`,
|
||||||
`BACKEND_XMX`.
|
`BACKEND_XMX`.
|
||||||
@@ -292,8 +298,10 @@ Configuration lives in the git-ignored `.env` (template `.env.example`):
|
|||||||
- **The master password never leaves the server.** It goes into
|
- **The master password never leaves the server.** It goes into
|
||||||
`<BACKEND_DIR>/env` (mode 600), which the deploy creates empty on the first
|
`<BACKEND_DIR>/env` (mode 600), which the deploy creates empty on the first
|
||||||
run. Until a hash is in it, the document list stays locked — deliberately.
|
run. Until a hash is in it, the document list stays locked — deliberately.
|
||||||
Generate it **interactively** (`htpasswd -nBC 12 ''`, no `-b`): a password on
|
`scripts/reset-password.sh` asks for it, sends it to the server over
|
||||||
a command line lands in the shell history, and the shell mangles it on the
|
**stdin** (never as an argument — those show up in the process list) and
|
||||||
|
verifies afterwards that hash and password match. Never put a password on a
|
||||||
|
command line: it lands in the shell history, and the shell mangles it on the
|
||||||
way — `ge$heim` becomes `ge`. What gets hashed is then not what you type
|
way — `ge$heim` becomes `ge`. What gets hashed is then not what you type
|
||||||
later, and the server answers 401 while everything looks right.
|
later, and the server answers 401 while everything looks right.
|
||||||
- **Memory is the scarce resource on that host**, not CPU. The JVM flags are
|
- **Memory is the scarce resource on that host**, not CPU. The JVM flags are
|
||||||
|
|||||||
+36
-8
@@ -218,21 +218,47 @@ alles richtig aussieht.
|
|||||||
|
|
||||||
## Persistenz
|
## Persistenz
|
||||||
|
|
||||||
- **H2 im File-Modus** (`./data/editor.mv.db`) mit `MODE=PostgreSQL` –
|
- **H2 im File-Modus** (`${werkbaum.data-dir:./data}/editor.mv.db`) – läuft im
|
||||||
läuft im Server-Prozess, keine Datenbank-Installation nötig. Dokumente und
|
Server-Prozess, keine Datenbank-Installation nötig. Dokumente und Historie
|
||||||
Historie überleben einen Neustart.
|
überleben einen Neustart.
|
||||||
|
- **Bewusst ohne `MODE=PostgreSQL`**, obwohl es naheliegt: In dem Modus legt H2
|
||||||
|
unquotierte Bezeichner klein an, Liquibase sucht seine Verwaltungstabellen
|
||||||
|
aber groß — findet nichts, legt sie an, und H2 antwortet „Table
|
||||||
|
databasechangelog already exists". Der erste Start ging, **jeder weitere
|
||||||
|
stürzte ab**. Gemessen und in D77 begründet.
|
||||||
|
- Die JDBC-URL hat **einen** Regler: `werkbaum.data-dir`. Der Regressionstest
|
||||||
|
überschreibt nur den, damit alles Übrige an der ausgelieferten URL unter
|
||||||
|
Test steht.
|
||||||
- **Schema per Liquibase im formatierten SQL-Format**
|
- **Schema per Liquibase im formatierten SQL-Format**
|
||||||
(`src/main/resources/db/changelog/db.changelog-master.sql`, kein XML).
|
(`src/main/resources/db/changelog/db.changelog-master.sql`, kein XML).
|
||||||
Neue Änderungen werden als weitere `--changeset`-Blöcke angehängt;
|
Neue Änderungen werden als weitere `--changeset`-Blöcke angehängt;
|
||||||
Hibernate validiert nur (`ddl-auto: validate`).
|
Hibernate validiert nur (`ddl-auto: validate`).
|
||||||
- **Umstieg auf echtes PostgreSQL:** im Wesentlichen JDBC-URL/Credentials in
|
- **Umstieg auf echtes PostgreSQL:** im Wesentlichen JDBC-URL/Credentials in
|
||||||
der `application.yaml` tauschen und den Postgres-Treiber als Dependency
|
der `application.yaml` tauschen und den Postgres-Treiber als Dependency
|
||||||
ergänzen – Schema-Migrationen und Code bleiben unverändert.
|
ergänzen – Schema-Migrationen und Code bleiben unverändert. (Der
|
||||||
- Tests laufen gegen H2 in-memory (`src/test/resources/application.yaml`),
|
H2-PostgreSQL-Modus war ein Nachbau davon und ist es nicht wert, siehe oben.)
|
||||||
mit demselben Liquibase-Schema.
|
- Tests laufen gegen H2 in-memory. Die Testkonfiguration heißt
|
||||||
|
`application-test.yaml` und ist eine **Profil-Überlagerung**: Gleichnamig
|
||||||
|
(`application.yaml`) verdeckte sie die Hauptkonfiguration vollständig, und
|
||||||
|
die Tests prüften eine Konfiguration, die in Produktion nie läuft.
|
||||||
- Service-Methoden sind `@Transactional`: Dokument-Änderung und
|
- Service-Methoden sind `@Transactional`: Dokument-Änderung und
|
||||||
Historieneintrag werden atomar geschrieben.
|
Historieneintrag werden atomar geschrieben.
|
||||||
|
|
||||||
|
## Läuft er? `GET /api/v1/info`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name":"editor-backend","version":"0.1.0-SNAPSHOT","builtAt":"2026-08-26T16:59:40.341Z"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Offen, ohne Nebenwirkung, und sagt zugleich, **welcher Stand** läuft. Vorher
|
||||||
|
war die Lebendprobe eine Anfrage nach einem nicht existierenden Dokument mit
|
||||||
|
der Erwartung **404** — ein erwarteter *Fehler* ist eine schlechte
|
||||||
|
Zusicherung, weil ihn auch ein falsch konfigurierter Proxy liefert.
|
||||||
|
|
||||||
|
Die Angaben kommen aus `META-INF/build-info.properties`
|
||||||
|
(`springBoot { buildInfo() }`, Teil des Boot-Plugins). Fehlt die Datei — etwa
|
||||||
|
beim Start aus der IDE —, steht dort `unbekannt` statt eines Fehlers.
|
||||||
|
|
||||||
## Betrieb auf der stabilen Instanz
|
## Betrieb auf der stabilen Instanz
|
||||||
|
|
||||||
`scripts/deploy-backend.sh` (im Repo-Wurzelordner) baut das Fat-Jar, legt es
|
`scripts/deploy-backend.sh` (im Repo-Wurzelordner) baut das Fat-Jar, legt es
|
||||||
@@ -244,8 +270,10 @@ nur auf `127.0.0.1` — von außen kommt man über die Proxy-Regel in
|
|||||||
- **Speicher:** `-Xmx192m -Xms48m` plus Freiraum-Verhältnisse; gemessen rund
|
- **Speicher:** `-Xmx192m -Xms48m` plus Freiraum-Verhältnisse; gemessen rund
|
||||||
174 MB RSS gegen 291 MB ohne Angaben. Nach einem GC leben ~45 MB. Zu wenig
|
174 MB RSS gegen 291 MB ohne Angaben. Nach einem GC leben ~45 MB. Zu wenig
|
||||||
Luft? `BACKEND_XMX` in `.env`.
|
Luft? `BACKEND_XMX` in `.env`.
|
||||||
- **Master-Passwort:** `<BACKEND_DIR>/env` auf dem Server, Modus 600. Ohne
|
- **Master-Passwort:** `scripts/reset-password.sh` fragt es verdeckt ab, hasht
|
||||||
Hash bleibt `GET /documents` gesperrt.
|
es auf dem Server und prüft selbst nach, ob Hash und Passwort zueinander
|
||||||
|
passen. Gespeichert wird nur der Hash (`<BACKEND_DIR>/env`, Modus 600); ohne
|
||||||
|
ihn bleibt `GET /documents` gesperrt.
|
||||||
- **Datenbank:** H2 im Dateimodus unter `<BACKEND_DIR>/data/`. Ein Deploy
|
- **Datenbank:** H2 im Dateimodus unter `<BACKEND_DIR>/data/`. Ein Deploy
|
||||||
fasst das Verzeichnis nicht an (kein `--delete`). Umstieg auf das dort
|
fasst das Verzeichnis nicht an (kein `--delete`). Umstieg auf das dort
|
||||||
laufende PostgreSQL: JDBC-URL in der `application.yaml` tauschen und den
|
laufende PostgreSQL: JDBC-URL in der `application.yaml` tauschen und den
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ plugins {
|
|||||||
group = "de.werkbaum"
|
group = "de.werkbaum"
|
||||||
version = "0.1.0-SNAPSHOT"
|
version = "0.1.0-SNAPSHOT"
|
||||||
|
|
||||||
|
// Erzeugt META-INF/build-info.properties (Teil des Boot-Plugins, keine neue
|
||||||
|
// Abhaengigkeit) - daraus speist sich GET /api/v1/info.
|
||||||
|
springBoot {
|
||||||
|
buildInfo()
|
||||||
|
}
|
||||||
|
|
||||||
java {
|
java {
|
||||||
toolchain {
|
toolchain {
|
||||||
languageVersion = JavaLanguageVersion.of(21)
|
languageVersion = JavaLanguageVersion.of(21)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import de.werkbaum.generated.model.DocumentCreateRequest
|
|||||||
import de.werkbaum.generated.model.DocumentHistoryEntry as ApiHistoryEntry
|
import de.werkbaum.generated.model.DocumentHistoryEntry as ApiHistoryEntry
|
||||||
import de.werkbaum.generated.model.DocumentUpdateRequest
|
import de.werkbaum.generated.model.DocumentUpdateRequest
|
||||||
import de.werkbaum.generated.model.RestoreRequest
|
import de.werkbaum.generated.model.RestoreRequest
|
||||||
|
import de.werkbaum.generated.model.ServiceInfo
|
||||||
import de.werkbaum.domain.ChangeAuthor
|
import de.werkbaum.domain.ChangeAuthor
|
||||||
import de.werkbaum.domain.ChangeEvent
|
import de.werkbaum.domain.ChangeEvent
|
||||||
import de.werkbaum.domain.ChangeFeed
|
import de.werkbaum.domain.ChangeFeed
|
||||||
@@ -18,6 +19,7 @@ import de.werkbaum.domain.Document
|
|||||||
import de.werkbaum.domain.DocumentHistoryEntry
|
import de.werkbaum.domain.DocumentHistoryEntry
|
||||||
import de.werkbaum.service.DocumentService
|
import de.werkbaum.service.DocumentService
|
||||||
import de.werkbaum.service.LiveEditingService
|
import de.werkbaum.service.LiveEditingService
|
||||||
|
import org.springframework.boot.info.BuildProperties
|
||||||
import org.springframework.http.CacheControl
|
import org.springframework.http.CacheControl
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
@@ -36,8 +38,30 @@ import java.util.UUID
|
|||||||
class DocumentsController(
|
class DocumentsController(
|
||||||
private val service: DocumentService,
|
private val service: DocumentService,
|
||||||
private val liveEditing: LiveEditingService,
|
private val liveEditing: LiveEditingService,
|
||||||
|
/**
|
||||||
|
* Aus `META-INF/build-info.properties` (Gradle: `springBoot { buildInfo() }`).
|
||||||
|
* Bewusst optional: Wer die Anwendung aus der IDE startet, hat die Datei
|
||||||
|
* nicht — dann fehlt die Zusatzangabe, statt dass der Start scheitert.
|
||||||
|
*/
|
||||||
|
private val buildProperties: BuildProperties? = null,
|
||||||
) : DocumentsApi {
|
) : DocumentsApi {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lebendprobe: läuft der Dienst, und welcher Stand ist es?
|
||||||
|
*
|
||||||
|
* Offen und ohne Nebenwirkung. Vorher musste die Prüfung eine Anfrage nach
|
||||||
|
* einem nicht existierenden Dokument stellen und auf **404** hoffen — ein
|
||||||
|
* erwarteter Fehler ist eine schlechte Zusicherung, weil ihn auch ein
|
||||||
|
* falsch konfigurierter Proxy liefert.
|
||||||
|
*/
|
||||||
|
override fun getInfo(): ResponseEntity<ServiceInfo> = ResponseEntity.ok(
|
||||||
|
ServiceInfo(
|
||||||
|
name = buildProperties?.name ?: "werkbaum-backend",
|
||||||
|
version = buildProperties?.version ?: "unbekannt",
|
||||||
|
builtAt = buildProperties?.time?.atOffset(java.time.ZoneOffset.UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
override fun listDocuments(): ResponseEntity<List<ApiDocument>> =
|
override fun listDocuments(): ResponseEntity<List<ApiDocument>> =
|
||||||
ResponseEntity.ok(service.findAll().map { it.toApi() })
|
ResponseEntity.ok(service.findAll().map { it.toApi() })
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ spring:
|
|||||||
name: editor-backend
|
name: editor-backend
|
||||||
|
|
||||||
datasource:
|
datasource:
|
||||||
# H2 im File-Modus mit PostgreSQL-Kompatibilitaet.
|
# H2 im File-Modus. Bewusst OHNE MODE=PostgreSQL, obwohl das naheliegt:
|
||||||
# Spaeterer Umstieg auf echtes PostgreSQL = im Wesentlichen nur diese URL aendern.
|
# In dem Modus legt H2 unquotierte Bezeichner klein an, Liquibase sucht
|
||||||
url: jdbc:h2:file:./data/editor;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
|
# seine Verwaltungstabellen aber gross - findet nichts, legt sie an, und H2
|
||||||
|
# antwortet "Table databasechangelog already exists". Folge: Der erste
|
||||||
|
# Start geht, JEDER WEITERE stuerzt ab. Gemessen und in D77 begruendet;
|
||||||
|
# der Umstieg auf echtes PostgreSQL bleibt eine Frage von URL und Treiber.
|
||||||
|
url: jdbc:h2:file:${werkbaum.data-dir:./data}/editor;DEFAULT_NULL_ORDERING=HIGH
|
||||||
username: sa
|
username: sa
|
||||||
password: ""
|
password: ""
|
||||||
driver-class-name: org.h2.Driver
|
driver-class-name: org.h2.Driver
|
||||||
|
|||||||
@@ -313,6 +313,25 @@ paths:
|
|||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound"
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
/info:
|
||||||
|
get:
|
||||||
|
tags: [Documents]
|
||||||
|
operationId: getInfo
|
||||||
|
summary: Name und Version des Dienstes
|
||||||
|
description: >
|
||||||
|
Offen und ohne Nebenwirkung - gedacht als Lebendprobe fuer Deploy und
|
||||||
|
Ueberwachung. Ohne diesen Endpunkt bliebe dafuer nur eine Anfrage nach
|
||||||
|
einem Dokument, das es nicht gibt, und man muesste auf eine 404 hoffen:
|
||||||
|
Ein erwarteter Fehler ist eine schlechte Zusicherung, weil ihn auch ein
|
||||||
|
falsch konfigurierter Proxy liefert.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Der Dienst laeuft
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ServiceInfo"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
responses:
|
responses:
|
||||||
NotFound:
|
NotFound:
|
||||||
@@ -565,6 +584,19 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/ChangeEvent"
|
$ref: "#/components/schemas/ChangeEvent"
|
||||||
|
|
||||||
|
ServiceInfo:
|
||||||
|
type: object
|
||||||
|
required: [name, version]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
builtAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Fehlt, wenn ohne Build-Informationen gestartet (z. B. aus der IDE).
|
||||||
|
|
||||||
ProblemDetail:
|
ProblemDetail:
|
||||||
type: object
|
type: object
|
||||||
description: Fehlerformat nach RFC 9457 (Problem Details)
|
description: Fehlerformat nach RFC 9457 (Problem Details)
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.werkbaum
|
||||||
|
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import org.springframework.boot.builder.SpringApplicationBuilder
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Anwendung muss **gegen ihre eigene Datenbank neu starten** können.
|
||||||
|
*
|
||||||
|
* Klingt selbstverständlich und war es nicht: `DATABASE_TO_LOWER=TRUE` lässt
|
||||||
|
* H2 unquotierte Bezeichner klein anlegen (wie PostgreSQL, dafür steht es in
|
||||||
|
* der URL), Liquibase sucht seine Verwaltungstabellen aber unter
|
||||||
|
* `DATABASECHANGELOG`, findet nichts und legt sie an — woraufhin H2
|
||||||
|
* „Table databasechangelog already exists" sagt. Der **erste** Start ging,
|
||||||
|
* jeder weitere stürzte ab.
|
||||||
|
*
|
||||||
|
* Gefunden hat das erst der Server, nicht die Testsuite: Jeder Test bekommt
|
||||||
|
* eine frische In-Memory-Datenbank, und auch von Hand gestartet wurde immer
|
||||||
|
* gegen ein leeres Verzeichnis. Der Fall „starte noch einmal" kam schlicht nie
|
||||||
|
* vor — bis der erste Deploy ihn zum Regelfall machte (D77-Nachtrag).
|
||||||
|
*
|
||||||
|
* Deshalb hier eine **Datei**-Datenbank und zwei Starts nacheinander. Der Test
|
||||||
|
* kostet zwei Kontext-Starts; das ist er wert.
|
||||||
|
*/
|
||||||
|
class RestartTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `startet auch gegen eine bestehende Datenbank`(@TempDir dir: Path) {
|
||||||
|
repeat(2) { lauf ->
|
||||||
|
val context = SpringApplicationBuilder(EditorBackendApplication::class.java)
|
||||||
|
.run(
|
||||||
|
// Überschrieben wird **nur das Verzeichnis**, nicht die
|
||||||
|
// ganze JDBC-URL: Sonst prüfte der Test eine URL, die er
|
||||||
|
// sich selbst ausgedacht hat, und die ausgelieferte bliebe
|
||||||
|
// ungeprüft — genau daran ist der erste Anlauf gescheitert.
|
||||||
|
//
|
||||||
|
// Und als **Argument**, nicht als `properties(...)`:
|
||||||
|
// Letztere sind Default-Properties mit der NIEDRIGSTEN
|
||||||
|
// Priorität, die `application.yaml` überstimmt sie.
|
||||||
|
"--werkbaum.data-dir=$dir",
|
||||||
|
"--server.port=0",
|
||||||
|
"--werkbaum.master-password.hash={noop}egal",
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
context.isRunning shouldBe true
|
||||||
|
} finally {
|
||||||
|
context.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test
|
|||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient
|
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient
|
||||||
import org.springframework.boot.test.context.SpringBootTest
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.test.context.ActiveProfiles
|
||||||
import org.springframework.test.context.TestPropertySource
|
import org.springframework.test.context.TestPropertySource
|
||||||
import org.springframework.test.web.servlet.client.RestTestClient
|
import org.springframework.test.web.servlet.client.RestTestClient
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
@@ -18,6 +19,7 @@ import java.util.Base64
|
|||||||
* UUID offen da, und das Zugriffsmodell (D76) wäre hinfällig — ohne dass es
|
* UUID offen da, und das Zugriffsmodell (D76) wäre hinfällig — ohne dass es
|
||||||
* jemandem auffiele.
|
* jemandem auffiele.
|
||||||
*/
|
*/
|
||||||
|
@ActiveProfiles("test")
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
@AutoConfigureRestTestClient
|
@AutoConfigureRestTestClient
|
||||||
@TestPropertySource(
|
@TestPropertySource(
|
||||||
@@ -26,8 +28,8 @@ import java.util.Base64
|
|||||||
// Eigene Datenbank: Dieser Test braucht einen zweiten Spring-Kontext,
|
// Eigene Datenbank: Dieser Test braucht einen zweiten Spring-Kontext,
|
||||||
// und zwei Kontexte auf derselben In-Memory-H2 stolpern uebereinander
|
// und zwei Kontexte auf derselben In-Memory-H2 stolpern uebereinander
|
||||||
// (Liquibase legt DATABASECHANGELOG ein zweites Mal an).
|
// (Liquibase legt DATABASECHANGELOG ein zweites Mal an).
|
||||||
"spring.datasource.url=jdbc:h2:mem:editor-master-pw;MODE=PostgreSQL;" +
|
"spring.datasource.url=jdbc:h2:mem:editor-master-pw;" +
|
||||||
"DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1",
|
"DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
class MasterPasswordDefaultTest {
|
class MasterPasswordDefaultTest {
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import io.cucumber.spring.CucumberContextConfiguration
|
|||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient
|
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient
|
||||||
import org.springframework.boot.test.context.SpringBootTest
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.test.context.ActiveProfiles
|
||||||
|
|
||||||
|
@ActiveProfiles("test")
|
||||||
@CucumberContextConfiguration
|
@CucumberContextConfiguration
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
// Seit Boot 4 stellt @SpringBootTest die Test-Client-Bean nicht mehr von selbst bereit
|
// Seit Boot 4 stellt @SpringBootTest die Test-Client-Bean nicht mehr von selbst bereit
|
||||||
|
|||||||
@@ -71,6 +71,22 @@ class DocumentStepDefinitions {
|
|||||||
lastResponse = listDocuments("test-geheim")
|
lastResponse = listDocuments("test-geheim")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Wenn("ich die Info des Dienstes abrufe")
|
||||||
|
fun `ich rufe die Info ab`() {
|
||||||
|
lastResponse = client.get()
|
||||||
|
.uri("/api/v1/info")
|
||||||
|
.exchange()
|
||||||
|
.returnResult(String::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Und("die Antwort nennt einen Namen und eine Version")
|
||||||
|
fun `die Antwort nennt Name und Version`() {
|
||||||
|
withClue("Antwort: ${body()}") {
|
||||||
|
body() shouldContain "\"name\""
|
||||||
|
body() shouldContain "\"version\""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Wenn("ich alle Dokumente ohne Master-Passwort abrufe")
|
@Wenn("ich alle Dokumente ohne Master-Passwort abrufe")
|
||||||
fun `ich rufe alle Dokumente ohne Passwort ab`() {
|
fun `ich rufe alle Dokumente ohne Passwort ab`() {
|
||||||
lastResponse = client.get()
|
lastResponse = client.get()
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Nur die Abweichungen fuer Tests. Bewusst `application-test.yaml` und nicht
|
||||||
|
# `application.yaml`: Gleichnamig VERDECKT die Testdatei die Hauptkonfiguration
|
||||||
|
# vollstaendig, und dann pruefen die Tests eine Konfiguration, die in
|
||||||
|
# Produktion nie laeuft. Als Profil-Ueberlagerung laedt Spring erst die
|
||||||
|
# Hauptdatei und legt diese darueber - Fehler wie die Liquibase-Schreibweise
|
||||||
|
# (D77-Nachtrag) fallen so im Test auf statt beim zweiten Start am Server.
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:h2:mem:editor-test;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1
|
||||||
|
|
||||||
|
werkbaum:
|
||||||
|
live-editing:
|
||||||
|
# Kurz, damit die Behavior-Tests nicht auf die Produktionswerte warten.
|
||||||
|
max-wait: 5s
|
||||||
|
|
||||||
|
master-password:
|
||||||
|
# {noop} = Klartext. Nur im Test; in Produktion steht hier {bcrypt}$2a$...
|
||||||
|
hash: "{noop}test-geheim"
|
||||||
|
max-attempts: 3
|
||||||
|
lockout: 2s
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
spring:
|
|
||||||
datasource:
|
|
||||||
url: jdbc:h2:mem:editor-test;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1
|
|
||||||
username: sa
|
|
||||||
password: ""
|
|
||||||
driver-class-name: org.h2.Driver
|
|
||||||
jpa:
|
|
||||||
hibernate:
|
|
||||||
ddl-auto: validate
|
|
||||||
open-in-view: false
|
|
||||||
liquibase:
|
|
||||||
change-log: classpath:db/changelog/db.changelog-master.sql
|
|
||||||
|
|
||||||
threads:
|
|
||||||
virtual:
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
werkbaum:
|
|
||||||
live-editing:
|
|
||||||
# Kurz, damit die Behavior-Tests nicht auf die Produktionswerte warten.
|
|
||||||
max-wait: 5s
|
|
||||||
|
|
||||||
master-password:
|
|
||||||
# {noop} = Klartext. Nur im Test; in Produktion steht hier {bcrypt}$2a$...
|
|
||||||
hash: "{noop}test-geheim"
|
|
||||||
max-attempts: 3
|
|
||||||
lockout: 2s
|
|
||||||
@@ -4,6 +4,15 @@ Funktionalität: Dokumente verwalten
|
|||||||
möchte ich Dokumente anlegen, abrufen, ändern und löschen können,
|
möchte ich Dokumente anlegen, abrufen, ändern und löschen können,
|
||||||
damit das Backend die Grundlage für den Editor bildet.
|
damit das Backend die Grundlage für den Editor bildet.
|
||||||
|
|
||||||
|
Szenario: Der Dienst sagt, wer er ist
|
||||||
|
Wenn ich die Info des Dienstes abrufe
|
||||||
|
Dann erhalte ich den Status 200
|
||||||
|
Und die Antwort nennt einen Namen und eine Version
|
||||||
|
|
||||||
|
Szenario: Die Info ist ohne Master-Passwort zu haben
|
||||||
|
Wenn ich die Info des Dienstes abrufe
|
||||||
|
Dann erhalte ich den Status 200
|
||||||
|
|
||||||
Szenario: Ein neues Dokument anlegen
|
Szenario: Ein neues Dokument anlegen
|
||||||
Wenn ich ein Dokument mit dem Titel "Notizen" und dem Inhalt "Hallo Welt" anlege
|
Wenn ich ein Dokument mit dem Titel "Notizen" und dem Inhalt "Hallo Welt" anlege
|
||||||
Dann erhalte ich den Status 201
|
Dann erhalte ich den Status 201
|
||||||
|
|||||||
@@ -6386,6 +6386,68 @@ nichts. Der Test hat bewiesen, dass die richtigen Pfade *übergeben* werden,
|
|||||||
nicht dass die Gegenseite sie versteht. Dieselbe Grenze wie in D25 und D72,
|
nicht dass die Gegenseite sie versteht. Dieselbe Grenze wie in D25 und D72,
|
||||||
nur eine Ebene tiefer: Ein Stub prüft die eigene Seite der Naht.
|
nur eine Ebene tiefer: Ein Stub prüft die eigene Seite der Naht.
|
||||||
|
|
||||||
|
**Nachtrag beim ersten Betrieb: `MODE=PostgreSQL` verhindert den Neustart.**
|
||||||
|
Der Dienst lief einmal und stürzte danach in einer Schleife: Liquibase legt
|
||||||
|
seine Verwaltungstabelle an, H2 antwortet „Table databasechangelog already
|
||||||
|
exists". In dem Modus schreibt H2 unquotierte Bezeichner **klein** (wie
|
||||||
|
PostgreSQL, dafür stand er in der URL); Liquibase sucht sie **groß**, findet
|
||||||
|
nichts und legt sie neu an. Der erste Start ging, jeder weitere nicht.
|
||||||
|
|
||||||
|
Gemessen, mit dem echten Jar und je frischem Verzeichnis:
|
||||||
|
|
||||||
|
| URL | zweiter Start |
|
||||||
|
|---|---|
|
||||||
|
| `MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE` (bisher) | stürzt ab |
|
||||||
|
| dito, Liquibase-Tabellen kleingeschrieben konfiguriert | stürzt ab |
|
||||||
|
| **ohne `MODE=PostgreSQL`** | **läuft** |
|
||||||
|
|
||||||
|
Die zweite Zeile ist der Grund, warum der Modus ganz weicht statt Liquibase
|
||||||
|
umkonfiguriert zu werden: Liquibase korrigiert den Namen selbst wieder auf
|
||||||
|
Großschreibung. Verloren geht wenig — der Modus ließ H2 wie PostgreSQL
|
||||||
|
*aussehen*, das Schema kommt aber ohnehin aus einem Liquibase-Changelog, und
|
||||||
|
der Umstieg auf echtes PostgreSQL bleibt eine Frage von URL und Treiber.
|
||||||
|
|
||||||
|
**Warum die Testsuite das nicht gefunden hat, und was daraus folgt.** Jeder
|
||||||
|
Test bekam eine frische In-Memory-Datenbank; „starte noch einmal" kam nie vor.
|
||||||
|
Der Regressionstest dafür hat mich dann **zweimal hintereinander belogen**, und
|
||||||
|
beide Male auf dieselbe Art — er prüfte etwas anderes, als er behauptete:
|
||||||
|
|
||||||
|
1. Er reichte die JDBC-URL über `SpringApplicationBuilder.properties(…)`
|
||||||
|
herein. Das sind **Default**-Properties mit der *niedrigsten* Priorität; die
|
||||||
|
`application.yaml` überstimmt sie. Der Test lief die ganze Zeit gegen eine
|
||||||
|
andere Datenbank und meldete für jede Variante dasselbe Ergebnis.
|
||||||
|
2. Als Kommandozeilen-Argument gereicht wirkte die URL — aber jetzt prüfte der
|
||||||
|
Test eine URL, die er sich **selbst ausgedacht** hatte, während die
|
||||||
|
ausgelieferte ungeprüft blieb. Die Gegenprobe (Modus zurückbauen, muss
|
||||||
|
fallen) blieb prompt stumm.
|
||||||
|
|
||||||
|
Beides fiel nur auf, weil die Gegenprobe zum Verfahren gehört. Jetzt hat die
|
||||||
|
URL **einen** Regler — `werkbaum.data-dir` —, der Test überschreibt nur den,
|
||||||
|
und alles Übrige an der ausgelieferten URL steht unter Test. Mit
|
||||||
|
`MODE=PostgreSQL` zurück fällt genau die eine danach benannte Zusicherung.
|
||||||
|
|
||||||
|
Dieselbe Lehre eine Ebene höher: Die Testkonfiguration hieß
|
||||||
|
`application.yaml` und **verdeckte** damit die Hauptkonfiguration vollständig —
|
||||||
|
die Tests prüften eine Konfiguration, die in Produktion nie läuft. Sie heißt
|
||||||
|
jetzt `application-test.yaml` und ist eine Profil-Überlagerung.
|
||||||
|
|
||||||
|
**Eine Lebendprobe braucht einen eigenen Endpunkt.** Bis hierher fragte das
|
||||||
|
Deploy nach einem Dokument, das es nicht gibt, und hoffte auf **404**. Ein
|
||||||
|
erwarteter *Fehler* ist eine schlechte Zusicherung: Dieselbe 404 liefert auch
|
||||||
|
ein falsch konfigurierter Proxy. `GET /api/v1/info` antwortet stattdessen mit
|
||||||
|
Name, Version und Bauzeitpunkt — offen, ohne Nebenwirkung, und es sagt
|
||||||
|
zugleich, **welcher Stand** läuft. Die Daten kommen aus
|
||||||
|
`META-INF/build-info.properties` (Gradle: `springBoot { buildInfo() }`, Teil
|
||||||
|
des Boot-Plugins — keine neue Abhängigkeit) und sind optional: Wer aus der IDE
|
||||||
|
startet, hat die Datei nicht und bekommt „unbekannt" statt eines Fehlers.
|
||||||
|
|
||||||
|
**Das Passwort setzt jetzt ein eigenes Skript** (`scripts/reset-password.sh`).
|
||||||
|
Es fragt verdeckt nach, schickt das Passwort über **stdin** zum Server (nicht
|
||||||
|
als Argument — Argumente stehen in der Prozessliste, die auf einem geteilten
|
||||||
|
Host jeder lesen kann), hasht dort mit `htpasswd -i` und prüft anschließend
|
||||||
|
selbst mit `htpasswd -vi`, ob Hash und Passwort zueinander passen. Genau diese
|
||||||
|
Gegenprobe fehlte, als das erste 401 wie ein Konfigurationsfehler aussah.
|
||||||
|
|
||||||
**Nicht getestet, weil es nicht zu testen war:** Alles bis zur SSH-Grenze ist
|
**Nicht getestet, weil es nicht zu testen war:** Alles bis zur SSH-Grenze ist
|
||||||
gemessen — die erzeugte Unit ist mit `systemd-analyze verify` gültig, das Jar
|
gemessen — die erzeugte Unit ist mit `systemd-analyze verify` gültig, das Jar
|
||||||
startet mit **genau** den Flags der Unit in einer Sekunde, antwortet auf die
|
startet mit **genau** den Flags der Unit in einer Sekunde, antwortet auf die
|
||||||
|
|||||||
@@ -116,7 +116,8 @@
|
|||||||
- [ ] #be: Backend (XL)
|
- [ ] #be: Backend (XL)
|
||||||
- [/] #be.scaffold: Kotlin and Spring Boot scaffold (M)
|
- [/] #be.scaffold: Kotlin and Spring Boot scaffold (M)
|
||||||
- [^] #be.scaffold.gradle: Gradle Kotlin DSL, JDK 21 (S)
|
- [^] #be.scaffold.gradle: Gradle Kotlin DSL, JDK 21 (S)
|
||||||
- [ ] #be.scaffold.ci: Health endpoint and CI (S)
|
- [x] #be.scaffold.info: An endpoint that says name and version (XS)
|
||||||
|
- [ ] #be.scaffold.ci: Continuous integration for the backend (S)
|
||||||
- [^] #be.docs: Documents on the server (L) :#be.scaffold
|
- [^] #be.docs: Documents on the server (L) :#be.scaffold
|
||||||
- [^] #be.docs.model: Text plus metadata, no structure format (M)
|
- [^] #be.docs.model: Text plus metadata, no structure format (M)
|
||||||
- [^] #be.docs.rest: REST endpoints (S)
|
- [^] #be.docs.rest: REST endpoints (S)
|
||||||
@@ -768,9 +769,14 @@
|
|||||||
The build itself: Kotlin DSL, one module, the current long-term-support
|
The build itself: Kotlin DSL, one module, the current long-term-support
|
||||||
Java.
|
Java.
|
||||||
|
|
||||||
|
#be.scaffold.info
|
||||||
|
Something to ask "are you there, and which build is it?" — open and without
|
||||||
|
side effects. Expecting a 404 from a document that does not exist would be a
|
||||||
|
poor assurance: a misconfigured proxy returns one too.
|
||||||
|
|
||||||
#be.scaffold.ci
|
#be.scaffold.ci
|
||||||
Enough to see that it lives: a health endpoint, and a pipeline that builds
|
A pipeline that builds and tests the backend on every push, like the editor
|
||||||
and tests it on every push.
|
has. The "is it alive" half moved out into its own node and is done.
|
||||||
|
|
||||||
#be.docs
|
#be.docs
|
||||||
Documents on a server instead of in one browser. Text plus metadata —
|
Documents on a server instead of in one browser. Text plus metadata —
|
||||||
|
|||||||
@@ -190,14 +190,16 @@ REMOTE
|
|||||||
# ---- 4) Nachsehen, ob er wirklich antwortet ----
|
# ---- 4) Nachsehen, ob er wirklich antwortet ----
|
||||||
if [ "$RESTART" -eq 1 ]; then
|
if [ "$RESTART" -eq 1 ]; then
|
||||||
echo "==> Warten, bis der Dienst antwortet"
|
echo "==> Warten, bis der Dienst antwortet"
|
||||||
# Es gibt (noch) keinen Health-Endpunkt — der steht als eigener Knoten im
|
# `GET /api/v1/info` — offen, ohne Nebenwirkung, und sagt zugleich, welcher
|
||||||
# Plan (`#be.scaffold.ci`). Bis dahin ist die ehrlichste Lebendprobe eine
|
# Stand läuft. Vorher stand hier eine Anfrage nach einem nicht existierenden
|
||||||
# Anfrage nach einem Dokument, das es nicht gibt: 404 heißt, die Anwendung
|
# Dokument mit der Erwartung 404; ein erwarteter **Fehler** ist eine schlechte
|
||||||
# ist oben und beantwortet Anfragen.
|
# Zusicherung, weil ihn auch ein falsch konfigurierter Proxy liefert.
|
||||||
PROBE="http://127.0.0.1:${PORT}/api/v1/documents/00000000-0000-0000-0000-000000000000"
|
PROBE="http://127.0.0.1:${PORT}/api/v1/info"
|
||||||
if ssh "$SSH_TARGET" "for i in \$(seq 1 45); do
|
if ssh "$SSH_TARGET" "for i in \$(seq 1 45); do
|
||||||
code=\$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 '$PROBE' || true)
|
body=\$(curl -s --max-time 3 '$PROBE' || true)
|
||||||
if [ \"\$code\" = '404' ]; then echo \" oben nach \${i}s (HTTP 404 wie erwartet)\"; exit 0; fi
|
case \"\$body\" in
|
||||||
|
*'\"version\"'*) echo \" oben nach \${i}s: \$body\"; exit 0 ;;
|
||||||
|
esac
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
echo ' ! antwortet nicht.'; exit 1"; then
|
echo ' ! antwortet nicht.'; exit 1"; then
|
||||||
|
|||||||
Executable
+137
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Werkbaum — Master-Passwort der Dokumentenliste setzen (D77).
|
||||||
|
#
|
||||||
|
# Fragt das Passwort **verdeckt ab** und hasht es auf dem Server. Bewusst so
|
||||||
|
# und nicht `htpasswd -b … PASSWORT`: Ein Passwort auf der Kommandozeile landet
|
||||||
|
# in der Shell-History, und schlimmer — die Shell fasst es vorher an. `ge$heim`
|
||||||
|
# wird zu `ge`, `ge heim` zu `geheim`. Gehasht wird dann etwas anderes als das,
|
||||||
|
# was man später eintippt, und der Server antwortet mit 401, obwohl alles
|
||||||
|
# richtig aussieht (genau so passiert, D76-Nachtrag 6).
|
||||||
|
#
|
||||||
|
# Verwendung:
|
||||||
|
# scripts/reset-password.sh [ssh-ziel] [--no-restart]
|
||||||
|
#
|
||||||
|
# ssh-ziel ohne Angabe BACKEND_SSH aus .env (Vorlage: .env.example)
|
||||||
|
# --no-restart Hash schreiben, Dienst nicht neu starten
|
||||||
|
#
|
||||||
|
# Das Passwort verlässt diesen Rechner nur über die SSH-Verbindung und wird
|
||||||
|
# **nur als Hash** gespeichert (`<BACKEND_DIR>/env`, Modus 600). Danach prüft
|
||||||
|
# das Skript selbst, ob Hash und Passwort zueinander passen — der Fehler oben
|
||||||
|
# sieht sonst aus wie ein Konfigurationsfehler.
|
||||||
|
#
|
||||||
|
# Siehe docs/DECISIONS.md D77 und backend/README.md.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SSH_TARGET=""
|
||||||
|
RESTART=1
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--no-restart) RESTART=0 ;;
|
||||||
|
-h|--help)
|
||||||
|
awk 'NR>2 { if ($0 ~ /^#/) { sub(/^# ?/, ""); print } else exit }' "$0"
|
||||||
|
exit 0 ;;
|
||||||
|
-*) echo "Unbekannte Option: $arg" >&2; exit 2 ;;
|
||||||
|
*)
|
||||||
|
if [ -n "$SSH_TARGET" ]; then echo "Zu viele Argumente." >&2; exit 2; fi
|
||||||
|
SSH_TARGET="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "$ROOT/scripts/lib-env.sh"
|
||||||
|
|
||||||
|
[ -n "$SSH_TARGET" ] || SSH_TARGET="$(env_value BACKEND_SSH)"
|
||||||
|
if [ -z "$SSH_TARGET" ]; then
|
||||||
|
echo "Usage: $0 <ssh-ziel> (oder BACKEND_SSH in .env)" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
DIR="$(env_value BACKEND_DIR)"; DIR="${DIR:-opt/werkbaum}"
|
||||||
|
case "$DIR" in /*) DIR_SH="$DIR" ;; *) DIR_SH="\$HOME/$DIR" ;; esac
|
||||||
|
|
||||||
|
echo "==> Master-Passwort für ${SSH_TARGET}"
|
||||||
|
echo " (die Dokumentenliste; alles andere ist über die Dokument-UUID erreichbar)"
|
||||||
|
|
||||||
|
# Verdeckt einlesen, zweimal. `read -s` gibt es in bash überall; auf ein
|
||||||
|
# Terminal angewiesen ist es nicht, deshalb der Hinweis bei Rohr-Eingabe.
|
||||||
|
if [ ! -t 0 ]; then
|
||||||
|
echo "Kein Terminal — dieses Skript fragt nach und braucht eines." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
printf ' Neues Passwort: '
|
||||||
|
read -rs PASSWORT
|
||||||
|
printf '\n Wiederholen: '
|
||||||
|
read -rs PASSWORT2
|
||||||
|
printf '\n'
|
||||||
|
|
||||||
|
if [ "$PASSWORT" != "$PASSWORT2" ]; then
|
||||||
|
echo "Die beiden Eingaben sind verschieden — nichts geändert." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$PASSWORT" ]; then
|
||||||
|
echo "Leeres Passwort — nichts geändert." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "${#PASSWORT}" -lt 8 ]; then
|
||||||
|
# Keine Regeln über Sonderzeichen; die Länge ist das, was zählt, und dieses
|
||||||
|
# eine Passwort schützt eine Liste, die alle Dokument-Adressen preisgäbe.
|
||||||
|
echo "Kürzer als 8 Zeichen — bitte länger wählen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Das Passwort geht über **stdin** an den Server, nie als Argument: Argumente
|
||||||
|
# stehen in der Prozessliste, die auf einem geteilten Host jeder lesen kann.
|
||||||
|
echo "==> hashen und speichern"
|
||||||
|
printf '%s' "$PASSWORT" | ssh "$SSH_TARGET" DIR="$DIR_SH" RESTART="$RESTART" 'bash -s' <<'REMOTE'
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="$(eval echo "$DIR")"
|
||||||
|
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||||
|
|
||||||
|
PASSWORT="$(cat)"
|
||||||
|
[ -n "$PASSWORT" ] || { echo "Kein Passwort angekommen." >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v htpasswd >/dev/null || {
|
||||||
|
echo "htpasswd fehlt auf dem Server (Paket apache2-utils)." >&2; exit 1; }
|
||||||
|
|
||||||
|
# htpasswd -i liest das Passwort von stdin - es steht also weder in der
|
||||||
|
# Prozessliste noch in einer History.
|
||||||
|
HASH="$(printf '%s' "$PASSWORT" | htpasswd -niBC 12 '' | tr -d ':\n')"
|
||||||
|
case "$HASH" in
|
||||||
|
\$2*) : ;;
|
||||||
|
*) echo "htpasswd hat keinen bcrypt-Hash geliefert." >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
mkdir -p "$DIR"
|
||||||
|
umask 077
|
||||||
|
printf 'WERKBAUM_MASTER_PASSWORD_HASH={bcrypt}%s\n' "$HASH" > "$DIR/env.neu"
|
||||||
|
mv "$DIR/env.neu" "$DIR/env"
|
||||||
|
chmod 600 "$DIR/env"
|
||||||
|
echo " gespeichert in $DIR/env (Modus 600)"
|
||||||
|
|
||||||
|
# Gegenprobe, bevor irgendjemand sich wundert: Passen Hash und Passwort?
|
||||||
|
CHK="$(mktemp)"
|
||||||
|
trap 'rm -f "$CHK"' EXIT
|
||||||
|
printf 'werkbaum:%s\n' "$HASH" > "$CHK"
|
||||||
|
if printf '%s' "$PASSWORT" | htpasswd -vi "$CHK" werkbaum >/dev/null 2>&1; then
|
||||||
|
echo " Gegenprobe: Hash und Passwort passen zueinander."
|
||||||
|
else
|
||||||
|
echo " ! Gegenprobe FEHLGESCHLAGEN - der Hash passt nicht zum Passwort." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$RESTART" = "1" ]; then
|
||||||
|
if systemctl --user is-enabled werkbaum-backend.service >/dev/null 2>&1; then
|
||||||
|
systemctl --user restart werkbaum-backend.service
|
||||||
|
echo " Dienst neu gestartet."
|
||||||
|
else
|
||||||
|
echo " ! Dienst noch nicht eingerichtet - scripts/deploy-backend.sh ausführen."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
unset PASSWORT PASSWORT2
|
||||||
|
|
||||||
|
echo "==> Fertig. Probe:"
|
||||||
|
echo " curl -su werkbaum:<passwort> https://werkbaum.javagil.de/api/v1/documents"
|
||||||
Reference in New Issue
Block a user