Eerste commit

This commit is contained in:
jf
2026-09-27 14:21:31 +00:00
commit de4ff128cb
295 changed files with 47374 additions and 0 deletions
+256
View File
@@ -0,0 +1,256 @@
# Adverteren op MijnBesluit
Een balk op het overzichtsscherm met een korte tekst en een klein logo. Adverteerders boeken
zelf een periode en een aantal vertoningen per uur, betalen via bunq, en na goedkeuring door de
beheerder gaat de campagne draaien.
Dit stuk beschrijft hoe dat werkt en waarom. Lees het voordat je eraan begint te bouwen.
## Waar de advertentie wél en niet komt
**Wel:** op het overzichtsscherm, op de website en in de app. Een brede tegel met een lichte
achtergrond, bóven de vier tegels maar **onder "wat er speelt"** — die regel waarschuwt je dat er
een overlijden op jouw naam is gemeld of dat er iets voor je klaarstaat, en die gaat altijd voor.
**Niet:** in de kluis, bij momenten, bij de nalatenschap, en nergens in de schermen rond een
overlijdensmelding. Iemand die een bericht aan zijn kinderen klaarzet of net heeft gehoord dat
er een overlijden op zijn naam is gemeld, hoort daar geen reclame naast te zien. Dat is niet
preutsheid maar productbehoud: dat is precies het moment waarop mensen weglopen.
De tegel is laag — een afbeelding, een regel of twee tekst — en draagt zichtbaar het woord
**Advertentie**. Een advertentie die zich voordoet als onderdeel van de applicatie kost je meer
vertrouwen dan hij oplevert.
*(Eerder stond hier dat de balk ónder de tegels hoort. Dat is op 16 september 2026 gewijzigd: hij
staat nu bovenaan, maar wel achter "wat er speelt". De reden om hem laag en herkenbaar te houden
blijft staan.)*
## Hoe wordt gekozen wie er getoond wordt
Per keer dat iemand het overzicht opent is er ruimte voor één advertentie. Er kunnen meerdere
campagnes tegelijk lopen, elk met hun eigen aantal per uur. De keuze gaat zo:
1. Zoek alle campagnes die nu mogen draaien: goedgekeurd, betaald, en binnen hun periode.
2. Kijk per campagne hoe vaak hij dit klokuur al is getoond.
3. Laat de campagne zien die het verst achterloopt op zijn eigen aantal - dus de laagste
verhouding *getoond gedeeld door geboekt*.
4. Zijn ze allemaal op hun aantal, dan verschijnt er niets. Geen opvulling, geen herhaling.
5. Tel de vertoning op.
Zo krijgt wie meer boekt ook meer, zonder dat de een de ander verdringt, en zonder dat iemand
over zijn ingekochte aantal heen gaat.
**Let op wat dit betekent voor je voorwaarden.** Het geboekte aantal is een *maximum*, geen
belofte. Zijn er in een uur minder bezoekers dan er vertoningen zijn ingekocht, dan haalt
niemand zijn aantal. De adverteerder moet daarom in zijn eigen scherm kunnen zien wat er
werkelijk is getoond.
## Wat er wordt opgeslagen
### Adverteerder
Bedrijfsnaam, contactpersoon, e-mailadres, telefoonnummer, adres, postcode, plaats, land,
KvK-nummer en btw-nummer. Dit zijn bedrijfsgegevens, geen persoonsgegevens van bezoekers, maar
ze vallen wel onder je administratie: de belastingdienst wil ze zeven jaar kunnen zien.
### Campagne
- Wie hem heeft geboekt
- De tekst, het logo en de link waar hij heen gaat
- Begindatum en einddatum
- Aantal vertoningen per uur
- Het bedrag, het betaalkenmerk, en wanneer de betaling is gezien
- De stand: `concept`, `wacht-op-betaling`, `wacht-op-goedkeuring`, `loopt`, `afgewezen`,
`gestopt` - met bij een afwijzing de reden erbij, zodat de adverteerder weet waarom
### Vertoningen
Eén regel per campagne per klokuur met een teller erin, niet één regel per vertoning. Bij een
paar honderd bezoekers per uur scheelt dat een tabel die anders eindeloos volloopt, en voor het
kiezen én voor de verantwoording is het genoeg.
Kliks worden apart geteld, ook per uur.
## Het betaalpad
Bunq.me is een betaalverzoek, geen kassa. Er komt **geen bericht terug** naar MijnBesluit als er
betaald is. De applicatie kan dus niet zelf vaststellen dat een campagne betaald is.
Daarom loopt het zo:
1. De adverteerder stelt zijn campagne samen en ziet wat het kost.
2. Hij krijgt de bunq.me-link, met het campagnenummer als omschrijving.
3. De campagne komt op `wacht-op-betaling`.
4. De beheerder ziet in zijn bunq dat er betaald is, met dat nummer erbij, en vinkt de campagne
af als betaald.
5. In hetzelfde scherm keurt hij de inhoud goed of af.
6. Pas dan gaat hij draaien, vanaf de begindatum.
### De betaallink
`AdvertentieService.Betaallink` zet hem in elkaar:
```
https://bunq.me/KwikBit?amount=30.00&description=MB-00012
```
Het adres staat in de instellingen onder `Adverteren:BunqMe`; een rekening wisselt eerder dan de
code. Laat je die instelling leeg, dan komt er geen knop en staan het bedrag en het kenmerk er
gewoon om met de hand over te maken - een knop naar de verkeerde rekening is erger dan geen knop.
Twee dingen die makkelijk misgaan en daarom in een zelfcontrole vastliggen:
* **Het bedrag krijgt een punt**, wat de taalinstelling van de server ook is. Zonder dat maakt
een Nederlandse Windows er `30,00` van, en dan leest bunq een ander bedrag of helemaal geen.
* **Het kenmerk gaat gecodeerd mee.** Het is `MB-00012`: kort genoeg om over te typen als iemand
de link niet gebruikt.
Op de knop staat wat je doet en voor hoeveel ("Betaal € 30,00 via bunq"), niet waar de link heen
gaat. Dezelfde link staat in de bevestigingsmail, want niet iedereen komt terug in het scherm.
Stap 4 en 5 zijn twee vinkjes in één scherm. Wil je dit later automatisch, dan kan dat met de
bunq-API in plaats van een bunq.me-link; dat is een aparte verbouwing en vraagt sleutels en een
terugmeldadres.
## Waarom goedkeuring vooraf
Zonder goedkeuring kan iedereen die betaalt zijn advertentie naast jouw tekst over uitvaarten
krijgen. Je zou het achteraf kunnen weghalen, maar dan is het al gezien. De vertraging voor de
adverteerder is klein - je kijkt er dezelfde dag naar - en het verschil in wat er mis kan gaan
is groot.
## De prijs
bedrag = dagen x vertoningen per uur x tarief
met een bodem eronder. Begin- en einddag tellen allebei mee. Het tarief en de bodem staan in de
instellingen (`Adverteren:TariefCenten`, `Adverteren:MinimumCenten`) en niet in de code: het is
het soort getal dat je na drie maanden wilt bijstellen. Standaard 20 cent en een bodem van
10 euro.
Een voorbeeld: 30 dagen, 5 keer per uur = 150 x 20 cent = **30 euro**.
Hoe kom je aan zo'n getal? Reken terug naar wat de adverteerder hoogstens kan krijgen. Vijf keer
per uur, 24 uur, 30 dagen is 3600 vertoningen; 30 euro is dan ruim 8 euro per duizend. Aan de
lage kant voor een klein, goed omschreven publiek — en dat hoort ook, want dat maximum haalt hij
nooit: 's nachts opent niemand het overzicht.
Bij een campagne wordt het gebruikte tarief mee opgeslagen (`rate_cents`). Verandert het tarief
later, dan verandert een al geboekte campagne niet mee — anders valt een oude factuur niet meer
na te rekenen.
## Wat er nog beslist moet worden
- **Facturen.** Moet MijnBesluit ze maken, of doe je dat zelf? Met btw erop hoort er een factuur
met een doorlopend nummer bij.
- **Terugbetalen.** Wat als een campagne door een storing niet heeft gedraaid?
- **Algemene voorwaarden.** Daarin hoort in elk geval te staan dat het aantal een maximum is en
dat je een advertentie mag weigeren.
## Hoe het in de code zit
| Wat | Waar |
|---|---|
| Tabellen | `db/13_adverteren.sql`: `adverteerders`, `campagnes`, `campagne_uren` |
| Gegevens | `MijnBesluit.Domain/Entities/Adverteren.cs` |
| Regels | `MijnBesluit.Application/Services/AdvertentieService.cs` |
| Opslag | `AdvertentieRepository` in Infrastructure |
| Controles | `tests/MijnBesluit.SelfCheck/AdvertentieChecks.cs` |
Een adverteerder is een **eigen tabel** en geen regel in `users`. Hij is een bedrijf, geen persoon
met een kluis, familie of nalatenschap. Hem bij de gebruikers zetten zou betekenen dat elke vraag
over gebruikers hem moet uitzonderen, en één vergeten uitzondering is er één te veel in een
applicatie die over nalatenschappen gaat. Inloggen gaat wel op dezelfde manier: een code van zes
cijfers per e-mail, met een eigen doel-nummer in `login_codes`.
De afbeelding staat als blob bij de campagne en niet bij `vault_files`. Die tabel is voor spullen
van gebruikers en is versleuteld met sleutels per gebruiker; een advertentie is juist openbaar.
Hij wordt bij het opslaan verkleind.
Wat er bewust **niet** in zit: iets wat een advertentie koppelt aan wie hem ziet. Er wordt per
campagne per uur geteld, meer niet. Geen profielen, geen kenmerken, geen bezoeker die door de
applicatie heen te volgen is. Dit is een applicatie waarin mensen hun wachtwoorden en hun laatste
woorden bewaren; daar hoort geen advertentienetwerk in te zitten dat meekijkt.
De beheerder krijgt een e-mail zodra er een campagne is ingediend. Vanaf dat moment ligt het bij
hem: betaling afvinken en de inhoud beoordelen. Goedkeuren kán niet voordat de betaling is
afgevinkt — anders draait er iets waarvoor niet betaald is, en dat merk je pas als het voorbij is.
## In welke volgorde te bouwen
1. ~~Adverteerder en campagne vastleggen, met een beheerscherm om te betalen-af-te-vinken en goed
te keuren.~~ **Klaar.** `/beheer/adverteren`, bereikbaar vanaf `/beheer`.
2. ~~De balk op het overzicht, met de keuzeregels en de tellers.~~ **Klaar.** Op de website
rechtstreeks vanuit `AdvertentieService`; in de app via de socketactie `home.advertentie`.
3. ~~Het scherm waarin een adverteerder zichzelf aanmeldt en een campagne samenstelt, met de
bunq.me-link erin.~~ **Klaar.** `/adverteren` en `/adverteren/nieuw`.
4. ~~Het scherm waarin hij ziet wat er werkelijk is getoond.~~ **Klaar.** `/adverteren/campagnes`.
Zo staat het werkende deel er eerst, en kun je met één handmatig ingevoerde campagne al zien of
die balk doet wat je wilt voordat er een adverteerder bij komt.
## Het adverteerdersgedeelte
Drie pagina's, allemaal **statisch weergegeven** en niet interactief:
* `/adverteren` - wat het is, wat het kost, en inloggen met een code per e-mail. De ingang staat
op het inlogscherm, onder "Ik ben nieuw en wil een account aanmaken".
* `/adverteren/nieuw` - de campagne samenstellen.
* `/adverteren/campagnes` - zijn campagnes, de stand, de betaalregel, en wat er is getoond.
**Een adverteerder logt in met een eigen cookie en een eigen schema** (`Adverteerderslogin`),
los van de gebruikers van MijnBesluit, en dat cookie gaat alleen mee naar `/adverteren`. Dat is
geen netheid. Met één cookie en een claim erin hangt het verschil tussen een adverteerder en
iemand met een kluis af van de vraag of elk scherm aan die ene controle denkt; met twee schema's
kan dat niet misgaan. Om dezelfde reden zijn die schermen niet interactief: het nummer van de
adverteerder komt bij elk verzoek vers uit dat cookie en hoeft dus nergens te blijven staan.
De code die hij per e-mail krijgt heeft een **eigen doel** (`LoginCodePurpose.Adverteerder`).
Een adverteerderscode geeft dus nooit toegang tot een kluis, en een gewone inlogcode werkt niet
in het adverteerdersgedeelte. Dat ligt vast in de zelfcontrole "een code voor adverteerders is
geen inlogcode".
Wie nieuw is vult zijn bedrijfsnaam in vóórdat hij de code aanvraagt. Die naam wordt bij de code
bewaard en pas gebruikt zodra de code klopt - net als de voornaam bij het registreren van een
gebruiker. Zo kan niemand zich een bedrijfsnaam aanmeten zonder eerst bij dat e-mailadres te
kunnen.
De prijs loopt mee terwijl je invult, met een klein stukje javascript in de pagina zelf. Dat is
een gemak en geen bedrag: wat het kost rekent de server bij het indienen zelf uit.
### Een campagne met de hand invoeren om het te proberen
Er is nog geen scherm waarin een adverteerder zichzelf aanmeldt, dus voor een proef zet je er één
met de hand in. Daarna zie je hem op `/beheer/adverteren` staan, vink je de betaling af, keur je
hem goed, en staat hij op het overzicht.
```sql
INSERT INTO adverteerders (company_name, contact_name, email, created_at)
VALUES ('Uitvaartzorg Noord', 'Piet', 'piet@voorbeeld.nl', UTC_TIMESTAMP(6));
INSERT INTO campagnes (advertiser_id, text, link, starts_on, ends_on, per_hour,
amount_cents, rate_cents, payment_reference, state, created_at)
VALUES (LAST_INSERT_ID(), 'Persoonlijk afscheid, dichtbij', 'https://voorbeeld.nl',
CURDATE(), CURDATE() + INTERVAL 30 DAY, 5, 3000, 20, 'MB-HAND', 1, UTC_TIMESTAMP(6));
```
`state` 1 is *wacht op betaling*; de rest gaat via het beheerscherm.
## Het klikpad
| Adres | Wat het doet |
|---|---|
| `/advertentie/{id}/afbeelding` | De afbeelding. Openbaar, en een jaar in de browsercache |
| `/advertentie/{id}/uit` | Telt de klik en stuurt door naar de adverteerder |
Allebei zonder inlog: een advertentie is openbaar, en een plaatje dat alleen met een sessie te
laden is werkt niet in de app.
De tegel linkt naar `/uit` en niet rechtstreeks naar de adverteerder. Met javascript tellen zou
ook kunnen, maar dan telt hij niet mee als er iets misgaat onderweg. Dit is één plek en één
telling. De doorverwijzing is bewust *niet* permanent: die blijft anders in de browser hangen en
dan telt een tweede klik niet meer mee.
In de app opent de tegel in de browser van de telefoon (`IBuitenlink`), niet in de app zelf. Zou
hij binnen de webview openen, dan wordt MijnBesluit een browser en staat iemand op de site van een
adverteerder zonder weg terug naar zijn eigen kluis.
+326
View File
@@ -0,0 +1,326 @@
# De Android-app
De app is dezelfde applicatie als de website, met dezelfde schermen. Wat verschilt, is de weg
naar de gegevens: de website praat rechtstreeks met MariaDB, de app praat via de SocketServer.
## Hoe het in elkaar zit
| Project | Wat het is | Te testen? |
|---|---|---|
| `MijnBesluit.Client` | De aanroepen over de socket, met modellen voor de app. Geen regels, alleen vertalen. | Ja — 10 controles in de zelftest, tegen een echte socketserver |
| `MijnBesluit.AppUi` | De schermen (Blazor). Inloggen, overzicht, kluis, familie, momenten, nalatenschap. | Compileert; het gedrag zit grotendeels in de laag eronder |
| `MijnBesluit.App` | De Android-schil: één webview met de schermen erin. | Nee — dit moet in Visual Studio op een toestel |
De schermen staan bewust in een eigen project en niet in de app zelf. Zo kan de website ze
later overnemen, en hoeft een wijziging maar op één plek.
## Wat je nodig hebt in Visual Studio
De MAUI-werkbelasting (".NET Multi-platform App UI development"). Heb je die al voor
RenHDashboardMaui, dan ben je er al. Het project staat op `net10.0-android`, net als dat project.
Klopt je versie niet, pas dan `TargetFrameworks` in `src\MijnBesluit.App\MijnBesluit.App.csproj`
aan naar wat je wél hebt staan; de andere projecten hoeven niet mee te veranderen.
## Eerste keer draaien
1. Rechtermuis op `MijnBesluit.App` → **Set as Startup Project**.
2. Kies bovenin een Android-emulator of je eigen toestel.
3. F5.
Verschijnt bij het bouwen een melding dat `Microsoft.AspNetCore.Components.WebView.Maui`
ontbreekt, dan levert jouw werkbelasting die niet vanzelf. Voeg hem dan toe via
**Manage NuGet Packages** op `MijnBesluit.App`.
## Waar de app verbinding mee zoekt
In `MauiProgram.cs` staat bovenaan:
```csharp
public const string Server = "mijnbesluit.nl";
public const int Poort = 5200;
```
- **Op een emulator, met de server op je eigen pc:** zet `Server` op `10.0.2.2` — dat is vanuit
de emulator jouw pc — en zet `tls: true` om naar `tls: false` zolang er geen certificaat is.
- **Op een echt toestel:** laat `mijnbesluit.nl` staan.
## Een APK maken
```powershell
D:\MijnBesluit\tools\Apk-maken.ps1 -Snel # snel, om zelf te proberen
D:\MijnBesluit\tools\Apk-maken.ps1 # ondertekend, om weg te geven
D:\MijnBesluit\tools\Apk-maken.ps1 -Aab # bundel voor de Play Store
```
De debug-versie is meteen te installeren op je eigen toestel (wel eerst "installeren uit
onbekende bron" toestaan). Die wordt gebouwd met `-p:EmbedAssembliesIntoApk=true`: bij Debug
laat MAUI de .NET-assemblies anders buiten het pakket, omdat Visual Studio ze bij het debuggen
los naar het toestel duwt. Zonder die instelling installeert de APK wel, maar klapt de app bij
het starten meteen dicht. De release-versie vraagt de eerste keer om een sleutelbestand aan te
maken; dat komt in `D:\MijnBesluitSleutels`.
**Bewaar dat sleutelbestand apart, net als de mastersleutel van de kluis.** Android herkent een
app aan de sleutel waarmee hij is ondertekend. Raak je hem kwijt, dan is er geen nieuwe versie
meer uit te brengen die op de toestellen van je gebruikers over de oude heen gaat — ook niet met
hulp van Google. Dan moet iedereen de app verwijderen en opnieuw installeren, en is alles wat
lokaal op het toestel stond weg.
Voor de Play Store lever je geen APK aan maar een AAB (Android App Bundle): `-Aab`. Dat is
dezelfde opdracht met een ander pakketformaat, hetzelfde sleutelbestand en hetzelfde wachtwoord.
Google maakt uit die bundel per toestel een APK, zodat niemand de onderdelen voor andere
toestellen meedownloadt.
Een AAB kun je niet zelf op een telefoon zetten. Voor het uitproberen en voor het rondsturen aan
je eigen testers blijf je dus de APK gebruiken; de AAB is alleen voor de Play Console.
## De serverkant
Staat klaar sinds 14 september 2026:
- De SocketServer draait als Windows-service op mijnbesluit.nl, poort 5200.
- Poort 5200 is open; `Test-NetConnection mijnbesluit.nl -Port 5200` geeft True.
- TLS 1.3, met het certificaat van mijnbesluit.nl uit de opslag WebHosting
(`B1BAFBB8A37A2899A2706FE037DDAB0F1694EFD1`, geldig tot 1 april 2027).
- Database en e-mail werken via de socket: `SocketServer-proef.ps1 -Email <adres>` levert een
inlogcode in het postvak.
Controleer het met:
```powershell
D:\MijnBesluit\tools\SocketServer-proef.ps1
D:\MijnBesluit\tools\SocketServer-proef.ps1 -Email jouw@adres.nl
```
Let op de vervaldatum van het certificaat: bij vernieuwing verandert de vingerafdruk en moet
`Socket:CertificateThumbprint` mee, gevolgd door `Restart-Service MijnBesluitSocket`. De service
zet bij het opstarten in het logboek in welke opslag hij het certificaat vond en tot wanneer het
geldig is.
## Wat de app op het toestel bewaart
Alleen het sessietoken, in de Keystore van Android (`ToestelTokenopslag`). Geen kluisinhoud,
geen wachtwoorden: die worden opgehaald op het moment dat je ze bekijkt, en zijn weg zodra je
het scherm sluit. Uitloggen wist het token van het toestel.
## De terugknop van het toestel
Android heeft een eigen terugknop, en die sloot standaard de hele app — ook midden in een
formulier. Dat loopt nu via `Terugknop` in `MijnBesluit.AppUi`.
Hoe het werkt: wie iets open heeft staan meldt zich daar aan en weer af zodra het dicht is. De
laatste aanmelding is als eerste aan de beurt, dus in de volgorde waarin de dingen over elkaar
heen liggen. In de praktijk:
1. Staat er een foto open in een bijlagenvenster, dan gaat die dicht.
2. Staat er een wijzigscherm open, dan gaat dat dicht — zonder op te slaan, net als Annuleren.
3. Zit je in een scherm, dan ga je naar het overzicht.
4. Ben je op het overzicht, dan verschijnt "Nog een keer terug om te sluiten". Pas een tweede
druk binnen twee seconden sluit de app.
De schermen hoeven hier niets voor te doen: `AppSchil` regelt stap 3 en 4, en `Popup` regelt
stap 1 en 2. Een scherm dat er later bij komt doet automatisch mee.
`HoofdPagina.OnBackButtonPressed` is het enige stukje MAUI erin: dat geeft de druk door en laat
Android zijn gang gaan zodra niemand hem afhandelt.
## Vanzelf op slot
Achter het tandwiel in de balk staat één instelling: na hoeveel minuten stilte de app dichtgaat.
`Uitlogklok` houdt bij wanneer er voor het laatst iets gebeurde; de schil vangt elke aanraking en
elke toets op — die bubbelen naar de buitenste laag, dus dat is één plek — en kijkt elke halve
minuut of de termijn voorbij is.
**Wat er dan gebeurt, hangt af van het toestel.**
| Toestel | Wat er gebeurt | Standaard |
|---|---|---|
| Heeft een schermslot, Android 10 of nieuwer | De app gaat op slot. De sessie blijft staan; openen gaat met vingerafdruk, gezicht of de code van de telefoon. | Aan, na 15 minuten — en "uit" bestaat daar niet |
| Geen schermslot, of ouder | De app logt écht uit: het sessietoken gaat van het toestel af en er moet een nieuwe code per e-mail komen. | Uit |
**Op een toestel met een schermslot kan de klok niet uit.** Die keuze staat niet in de lijst, en
een nul die er van vroeger nog in staat wordt opgetild naar het kwartier - in de klok zelf, niet
in het scherm, zodat ook de schil ervan uitgaat. Het slot kost daar één aanraking; uit zetten
levert dus bijna niets op en betekent wel dat een gevonden telefoon een open kluis is. Zonder
schermslot ligt dat andersom en mag "uit" er wél staan.
**Op slot gaat de lijn dicht.** Zodra de app vergrendelt, verbreekt hij de verbinding met de
SocketServer; na het ontgrendelen zet hij hem weer op. Het token blijft staan en de sessie aan de
serverkant ook - die hangt aan het token en niet aan de verbinding - dus je bent gewoon weer waar
je was.
Waarom dat de moeite is: de server verbreekt zelf een verbinding waarover tien minuten niets
gebeurt, maar dat is een terugval, geen zekerheid. Een telefoon die in een tas verdwijnt of zijn
netwerk kwijtraakt, laat een half-open socket achter tot die tien minuten om zijn. Zelf dichtdoen
maakt er een zekerheid van, scheelt een plek van de vijfhonderd, en zorgt dat je na het
ontgrendelen een verse verbinding hebt in plaats van een die misschien nog leeft.
**Bij het openen vraagt hij altijd, en hij vraagt het aan de telefoon.** Ligt er een sessie op
dit toestel en kan het om een vingerafdruk vragen, dan komt dat venster - ongeacht de klok. De
klok gaat over wat er gebeurt terwijl de app openstaat; dit gaat over het openen zelf. Zo is de
app afsluiten en opnieuw starten geen manier om langs het slot te komen.
Let op *waarop* dat gebeurt: op wat er in de beveiligde opslag van de telefoon ligt, niet op een
goedkeuring van de server (`HeeftBewaardeSessieAsync`, en `BijHetOpenenAsync` in de schil). Dat
was eerst andersom, en dat was fout. Het slot ging pas op de app nadat de server het token had
goedgekeurd, dus een telefoon zonder netwerk - of een server die net opnieuw was gestart - stuurde
je naar het inlogscherm met een vraag om je e-mailadres, terwijl je telefoon prima wist wie je
was. Nu komt eerst je vingerafdruk en pas daarna de vraag of de sessie nog loopt. Zolang de app
dat nog niet weet, toont de schil alleen het logo: geen scherm dat een tel later achter een slot
verdwijnt, en geen scherm dat alvast gegevens gaat ophalen.
Dat de sessie ook echt nog loopt, is de andere helft van hetzelfde verhaal: de SocketServer
bewaart zijn sessies sinds `db/16_sessies.sql` in de database en niet meer alleen in het
geheugen. Zonder dat logde elke uitrol of reboot van de server iedereen uit, en was de
vingerafdruk niets waard. Zie `docs/Socketprotocol.md`, hoofdstuk Sessies.
Dat verschil zit in de prijs van vergeten. Met een toestelslot kost het je één aanraking, dus mag
het standaard aan. Zonder kost het een e-mail afwachten, en dat leg je niemand ongevraagd op. Wie
zélf "uit" kiest houdt uit: die nul staat dan in de opslag en is iets anders dan niets gekozen
hebben. Er is een zelftest die dat verschil bewaakt.
`IToestelslot` vraagt het toestel om iemand te controleren; `Toestelslot` in MijnBesluit.App doet
dat met `BiometricPrompt` en `SetDeviceCredentialAllowed(true)`, zodat de code van de telefoon
altijd als terugval werkt. **De app krijgt alleen ja of nee terug.** Er is geen eigen pincode, er
wordt niets bewaard en er valt hier dus ook niets te lekken; de controle gebeurt in een apart
stuk hardware waar geen enkele app bij kan.
`SetDeviceCredentialAllowed` staat vanaf Android 11 als verouderd gemarkeerd. De opvolger
(`SetAllowedAuthenticators`) bestaat pas vanaf 11, en de app werkt vanaf 10. Zolang dat zo is
hoort deze hier te staan; gaat de ondergrens ooit naar 11, dan mag het om.
Op slot laat de schil **niets** van de app meer zien — geen balk, geen navigatie, geen inhoud —
en slikt de terugknop van het toestel. Een grendel die je nog door het raam laat kijken is geen
grendel. Op het slotscherm staat ook "toch helemaal uitloggen", voor wie de telefoon uit handen
geeft.
Twee dingen die verder met opzet zo zijn:
- **Er wordt op de wandklok gerekend, niet op een teller.** Een telefoon die een uur in een tas
zit telt dus net zo goed mee, ook al heeft de lus in de schil dan niet gedraaid. Bij het
terugkomen uit de achtergrond seint `App.xaml.cs` de klok meteen in, vóór er gegevens worden
opgehaald — dat is juist het geval waar dit voor bedoeld is. Stond de app al op slot, dan komt
het venster van het toestel meteen weer in beeld.
- De termijn staat in de gewone voorkeursopslag van Android (`Toestelinstellingen`), niet in de
Keystore: daar hoort alleen in wat geheim is, en hoe lang de app wacht is dat niet. Het
sessietoken staat er wél in (`ToestelTokenopslag`), en dat is ook precies wat achter dit slot
blijft liggen terwijl de app dicht is.
### Een sessie gaat er alleen af als de server hem weigert
Een app die op slot staat, staat op de achtergrond, en Android sluit zo'n app vaak gewoon af.
Terugkomen is dan geen ontwaken maar een koude start: de app pakt het bewaarde token op en vraagt
de server wie hij is. Op dat moment is het netwerk van de telefoon soms nog niet wakker.
Dat mislukken is **geen** reden om het token weg te gooien. Deed de app dat wel, dan moest je een
code per e-mail aanvragen terwijl er niets mis was met je sessie — precies op het moment dat je
dacht dat je alleen even je vinger hoefde te leggen. Alleen de server mag een sessie ongeldig
verklaren (foutcode `sessie-verlopen`); dan ruimt `SessieKwijtAsync` het token op en gaat de app
naar het inlogscherm. Blijft het token staan, dan blijft de app op zijn scherm met "geen
verbinding" en probeert hij het bij de volgende handeling opnieuw.
De zelfcontrole "App: geen verbinding gooit het bewaarde token niet weg" houdt beide kanten vast.
## De tegels in de lijsten
Momenten, Mijn mensen, Mijn kluis en Nalatenschap tonen allemaal dezelfde tegel:
* Als er een profielfoto is, staat die vooraan. Anders de beginletters, even groot, zodat een
lijst niet verspringt zodra er ergens een foto bij komt.
* Drie regels ernaast, altijd in dezelfde opbouw:
1. waar je op zoekt - het onderwerp, de naam, de titel. Loopt door tot de rand.
2. de toelichting, met rechts eventueel een labeltje (de status bij Mijn mensen).
3. de rest, met rechts de knoppen. Die regel mag leeg zijn; bij Mijn mensen is dat zo.
* **De tegel zelf aantikken opent hem.** Bij Momenten en Nalatenschap klapt er iets uit, bij
Mijn mensen en Mijn kluis opent het scherm met de gegevens. Daar staat dus geen knop voor.
* Rechtsonder staan alleen de andere acties, als tekentje: de prullenbak, de envelop om iemand
uit te nodigen. Een wijzertje (het driehoekje) laat zien dat er iets uitklapt; dat is geen
knop. Elke knop heeft een `title` en een `aria-label`, dus een schermlezer noemt hem gewoon.
Het labeltje op regel 2 en de knoppen op regel 3 staan daardoor op elke tegel op dezelfde plek,
pal boven elkaar. Dat is de reden dat de knoppen op de onderste regel staan en er niet overheen
zweven: een knop is 40 pixels hoog, en dat is anderhalve tekstregel - hij kwam dus altijd over
dat labeltje heen. De tegel is daar een pixel of vijftien hoger van (ongeveer 126 in plaats van
110), en niets loopt meer over elkaar.
Datums staan afgekort: *9 Jun. 2031*. De maandnamen staan met de hand in `Tonen` en komen niet
uit de taalinstelling van het toestel; op een telefoon die op Engels staat las de lijst anders
"9 June 2031" terwijl de rest van het scherm Nederlands is.
Mijn kluis heeft bovenaan een keuzelijst om op soort te filteren, met de knop Toevoegen ernaast,
net als op de website. Het filteren gebeurt bij de server (`KluisAsync` neemt een categorie mee)
en niet in de lijst op het toestel: dan hoeft de telefoon niet alles op te halen om er drie
dingen uit te laten zien.
Bij Mijn kluis is bekijken en wijzigen hetzelfde scherm. Er was eerder ook een uitklap onder de
tegel die precies dezelfde gegevens en bijlagen toonde: twee plekken om te onderhouden en twee
manieren om hetzelfde te doen.
## Het logo
Het logo staat op vier plekken, allemaal vanuit hetzelfde bestand:
| Waar | Bestand |
|---|---|
| Inlogscherm van de app, groot | `src/MijnBesluit.AppUi/wwwroot/logo.png` |
| Merkteken in de balk van de app, klein | hetzelfde bestand |
| Kop en inlogpagina van de website | `src/MijnBesluit.Web/wwwroot/logo.png` |
| Pictogram en startscherm van de app | `Resources/AppIcon/appicon.png`, `Resources/Splash/splash.png` |
Het pictogram is **alleen het witte woordmerk op een doorzichtige achtergrond**, met het
logogroen eronder via `Color="#C1D13F"` in het csproj. Zo maakt Android er zelf een rond,
vierkant of druppelvormig pictogram van. Het woordmerk staat op zeventig procent van het vlak:
alles buiten die cirkel mag een toestel wegsnijden, en op die maat blijft het hele woordmerk
staan, hoe een telefoon het ook maskeert.
Op 26 pixels - het merkteken in de balk - is het woordmerk niet te lezen. Dat hoeft ook niet:
daar staat de naam in letters ernaast. Groot en leesbaar staat het op het inlogscherm.
Nog niet gedaan: de melding op de telefoon gebruikt het app-pictogram als klein teken, en Android
maakt daar een silhouet van. Een woordmerk wordt op 24 pixels een vlek. Een eigen, eenvoudig
tekentje in `Platforms/Android/Resources/drawable` zou dat oplossen.
## Wat er nog niet in zit
- Meldingen op de telefoon als er een bericht bezorgd wordt — de SocketServer kan uit zichzelf
een melding sturen, maar een push-melding als de app dicht is vraagt om Firebase
- iOS: `net10.0-ios` toevoegen aan `TargetFrameworks` en een Mac om op te bouwen
- Een grens aan hoe lang de app op slot mag blijven staan. Nu blijft de sessie liggen tot iemand
hem opent of uitlogt; na een maand ongebruikt zou hij zichzelf kunnen opruimen.
## Naar de Google Play Store
Stand op 16 september 2026. De eisen van Google wijzigen; kijk ze na op
<https://support.google.com/googleplay/android-developer>.
**Wat al klopt:** de app bouwt op `targetSdkVersion 36`, en dat is sinds 31 augustus 2026 de eis
voor nieuwe apps. De rechten zijn zuinig (internet, camera, media, vingerafdruk, meldingen) en de
meldingstoestemming wordt op het moment zelf gevraagd.
**Wat er is gebouwd omdat Google het eist:**
* **Account opheffen**, in de app én op `/account-verwijderen` zonder app. Zie
`docs/Beveiliging.md`.
* **Een privacyverklaring** op `/privacy`, met een link erheen vanaf het inlogscherm en vanuit
Instellingen in de app.
**Wat er nog moet:**
1. **Een organisatie-account** in de Play Console, op naam van het bedrijf - geen persoonlijk
account. Persoonlijke accounts van na 13 november 2023 moeten eerst een gesloten test draaien
met 12 testers die 14 dagen onafgebroken meedoen; organisatie-accounts niet. Een organisatie
wordt wel geverifieerd (KvK, D-U-N-S), en dat duurt weken. Begin daarmee.
2. **Een AAB in plaats van een APK:**
`dotnet publish -f net10.0-android -c Release -p:AndroidPackageFormat=aab`
3. **Een upload-keystore**, bewaard zoals de masterkey: apart, met back-up, buiten de projectmap.
Zet Play App Signing aan; dan kan Google een nieuwe upload-sleutel geven als hij ooit weg is.
Let op dat de app die gebruikers krijgen door Google opnieuw wordt ondertekend - voor de
pushmeldingen maakt dat niets uit, maar alles wat aan een certificaatvingerafdruk hangt moet
je met de sleutel van Google registreren.
4. **`ApplicationVersion` omhoog bij elke upload.** Staat nu op 1.
5. **Het Data safety-formulier**, de leeftijdsclassificatie en de winkelvermelding: icoon
512x512, banner 1024x500, minimaal twee schermafbeeldingen, korte en lange beschrijving, en
het adres van de privacyverklaring.
6. **"Bevat advertenties" aanvinken** zodra de advertentiebalk meedraait. De inhoud van die
advertenties valt dan onder het beleid van Google; dat is een reden te meer om ze vooraf zelf
goed te keuren.
+155
View File
@@ -0,0 +1,155 @@
# MijnBesluit — architectuur
## Wat het is
Een platform om je beslissingen bij leven vast te leggen in een digitale kluis, en om berichten
te versturen naar familie en vrienden — nu of in de toekomst, en ook pas na je overlijden.
## De vorm in één plaatje
```
Website (Blazor) Android / iOS (MAUI)
| |
| in-proces | TLS-socket, poort 5200
v v
+---------------------------------------------+
| MijnBesluit.Server (dispatcher, sessies) | <- alleen voor de socketkant
+---------------------------------------------+
\ /
v v
+---------------------------------------------+
| MijnBesluit.Application |
| AccountService, FamilyService, VaultService, |
| MessageService, LegacyService + crypto |
+---------------------------------------------+
|
+---------------------------------------------+
| MijnBesluit.Domain (entiteiten + regels) |
+---------------------------------------------+
|
+---------------------------------------------+
| MijnBesluit.Infrastructure (EF Core) |
+---------------------------------------------+
|
MariaDB + versleutelde bestanden op schijf
```
De regels staan één keer, in Application en Domain. De website en de app zijn allebei niet meer
dan een manier om diezelfde regels aan te roepen. Wat op de website niet mag, kan dus ook via de
app niet — dat hoeft nergens tweemaal te worden afgedwongen.
## De projecten
| Project | Wat het doet | Externe packages |
|---|---|---|
| `MijnBesluit.Domain` | Entiteiten en de regels: eigendom van profielen, overlijdensprocedure, vrijgave van berichten | geen |
| `MijnBesluit.Application` | De handelingen: registreren, uitnodigen, kluis, berichten, nalatenschap. Plus alle versleuteling | geen |
| `MijnBesluit.Protocol` | Het socketprotocol: framing, de server-luisteraar en de client voor de app | geen |
| `MijnBesluit.Server` | Vertaalt socketverzoeken naar Application-aanroepen; sessiebeheer | geen |
| `MijnBesluit.Infrastructure` | EF Core op MariaDB, sleutelbestand, bestandsopslag, e-mail | EF Core, Pomelo |
| `MijnBesluit.ServiceBeheer` | De Windows-service installeren, verwijderen, starten en stoppen | geen |
| `MijnBesluit.SocketServer` | De zelfstandige service voor de app, het beheerschermpje en de achtergrondtaken | Hosting, WinForms |
| `MijnBesluit.Web` | De website in Blazor | geen |
| `MijnBesluit.SelfCheck` | Controleert de regels zonder database of webserver | geen |
Dat de eerste vier geen enkel extern package nodig hebben is met opzet: daardoor zijn de regels
en het protocol te bouwen en te controleren zonder dat er ook maar iets geïnstalleerd hoeft te
worden, en is `MijnBesluit.Protocol` zonder gedoe te hergebruiken in de MAUI-app.
## Geen webservice, geen IIS
De website is Blazor Server en roept de Application-laag rechtstreeks aan, in hetzelfde proces.
Er zit dus geen webservice tussen die serialiseert, over HTTP praat en weer deserialiseert.
De app praat met `MijnBesluit.SocketServer`: een zelfstandig draaiende Windows-service (64 bits)
met een eigen TCP-luisteraar. Daar komt geen IIS aan te pas.
Dezelfde exe is ook zijn eigen installatieprogramma: start Windows hem, dan draait de server;
start jij hem met de rechtermuisknop als administrator, dan verschijnt een schermpje met een
poortnummer en een knop **Install** — of **UnInstall** als hij al geïnstalleerd is. Zie
`docs/SocketServer-installeren.md`.
```
dotnet publish src\MijnBesluit.SocketServer -c Release -r win-x64 --self-contained false -o D:\MijnBesluit\publish
```
Binnen de service:
* één thread accepteert verbindingen;
* elke verbinding krijgt een eigen thread met een blokkerende leeslus;
* elk verzoek gaat naar zijn eigen thread, zodat een trage databaseactie de volgende verzoeken
van diezelfde app niet ophoudt. Elk verzoek krijgt daarmee ook zijn eigen databasecontext.
## Hoe een profiel van eigenaar wisselt
Dit is de kern van hoe MijnBesluit zich verspreidt.
1. Jan maakt een account. Hij krijgt een profiel (dat hij meteen zelf bezit) en een familieproject.
2. Jan voegt zijn dochter Eva toe. Er komt een profiel voor Eva, **beheerd door Jan**. Jan kan
alles aan dat profiel wijzigen.
3. Jan stuurt een uitnodiging naar Eva's e-mailadres. In de database staat alleen de SHA-256 van
het uitnodigingstoken; het token zelf bestaat enkel in die ene e-mail.
4. Eva klikt op de link en kiest een wachtwoord. Op dat moment wordt het profiel **geclaimd**:
`claimed_by_user_id` wordt gevuld en `managed_by_user_id` leeggemaakt.
5. Vanaf nu kan Jan niets meer aan Eva's profiel wijzigen. Hij ziet haar nog steeds in zijn
familie staan, met het label "geclaimd".
6. Eva krijgt haar eigen familieproject en nodigt op haar beurt anderen uit.
De regel staat op één plek: `ProfileOwnership.CanEdit`. Alles wat een profiel wil wijzigen gaat
daarlangs.
## Overlijden en nalatenschap
0. Je wijst beheerders aan, op volgorde. Zij zien dat meteen staan onder *Aan mij toevertrouwd*,
met hun volgorde erbij — daar reken je tenslotte op als het zover is. Meer dan de aanwijzing
zien ze niet: geen datum, geen aantallen, geen kluis.
Weghalen kan ook (`legacy.remove-manager`). Dat hoort erbij: een beheerder kan zelf overlijden,
en aanwijzen zonder kunnen terugnemen is geen keuze. Eén geval kan niet — iemand die je kluis
al heeft gekregen. Dan is de procedure afgerond en heeft hij gezien wat erin stond; die regel
weghalen zou alleen de administratie opschonen en niet wat er gebeurd is.
1. **Iedereen uit je kring** kan melden dat je bent overleden: de mensen die jij hebt toegevoegd
en die hun uitnodiging hebben aangenomen. Eén melding per gebruiker, afgedwongen met een
unieke sleutel in de database.
2. **Minstens één van die meldingen komt van een aangewezen beheerder.** Zolang die er niet is,
begint er niets: geen bericht aan de betrokkene, geen termijn die verloopt.
Waarom niet "alleen beheerders mogen melden", wat voor de hand lag: dan heb je evenveel
beheerders nodig als er meldingen vereist zijn. Wijs je er twee aan terwijl er drie meldingen
nodig zijn — wat de meeste mensen zullen doen — dan kan de procedure nooit worden afgerond en
gebeurt er bij een overlijden niets. Stilzwijgend, want niemand merkt het.
Zo werkt het nu dus niet. Het *aantal* haal je met je kring, want die merkt een overlijden als
eerste en is groot genoeg. De *beheerder* is de stem die er in elk geval bij moet zitten. Twee
verschillende soorten mensen moeten het dus eens zijn voordat er iets in beweging komt.
Blijft die beheerdersmelding uit, dan zegt het nalatenschapsscherm dat met zoveel woorden
(`WachtOpBeheerder`). En wie nog niemand heeft aangewezen, krijgt daar een waarschuwing: zonder
beheerder blijft de kluis ook na een overlijden dicht.
3. Zodra aan allebei is voldaan (standaard drie meldingen, per profiel instelbaar), krijgt de
betrokkene zelf een e-mail: *klopt dit niet, laat het weten*.
4. Reageert die persoon binnen de bezwaartermijn (standaard 14 dagen), dan vervallen alle
meldingen en gebeurt er niets.
5. Reageert die persoon niet, dan wordt het overlijden bevestigd. De aangewezen
nalatenschapsbeheerders krijgen toegang tot precies die kluisitems die zijn gemarkeerd als
"vrijgeven na overlijden" — de rest blijft dicht. Berichten met "pas als ik er niet meer ben"
worden bezorgd.
Een **lopende** procedure ziet er bij de beheerder precies zo uit als iemand die springlevend is.
Dat is met opzet: anders kon hij aan zijn eigen scherm aflezen dat er meldingen binnen zijn over
iemand die dat nog kan tegenspreken. Pas bij stap 5 verandert de kaart.
Later kan een koppeling met de BRP (de opvolger van de GBA) stap 1 tot en met 4 vervangen door
één betrouwbare bron. De rest van de code verandert dan niet: alleen wie `DeathConfirmedAt`
vult, verandert.
## Achtergrondtaken
`AchtergrondTaken` in de SocketServer draait elk kwartier en doet drie dingen: berichten bezorgen
waarvan de datum is bereikt of de afzender bevestigd is overleden, bezwaartermijnen die zijn
afgelopen afronden, en verlopen sessies opruimen.
## Tijd
Alles wordt in UTC opgeslagen en pas op het scherm omgerekend naar lokale tijd. Een bericht dat
op 25 december bezorgd moet worden, verschuift zo nooit door zomertijd.
+208
View File
@@ -0,0 +1,208 @@
# MijnBesluit — beveiliging
In een kluis met wachtwoorden, pincodes en uitvaartwensen is beveiliging geen bijzaak. Dit
document beschrijft precies hoe het werkt, zodat je het kunt controleren en er later bewust van
kunt afwijken.
## Inloggen met een code per e-mail
Er zijn geen wachtwoorden. Je vult je e-mailadres in, krijgt een code van zes cijfers toegestuurd
en vult die in. Dat is dezelfde gang van zaken als in de WinDev-versie, met drie verschillen.
**De code staat niet leesbaar opgeslagen.** In de WinDev-versie stond hij in
`mlPeople.ActivationCode` en werd hij bovendien in een paginavariabele (`gsInlogcode`) aan de
browser meegegeven; wie de paginabron bekeek, kon zo inloggen. Hier gaat alleen een hash de
database in (PBKDF2-HMAC-SHA512), en de code verlaat de server nooit anders dan per e-mail.
**Zes cijfers in plaats van vier.** `Random(1111,9999)` gaf ruim achtduizend mogelijkheden; nu
zijn het er een miljoen. En `Random` is voorspelbaar zodra je een paar uitkomsten kent, dus de
code komt uit `RandomNumberGenerator`.
**Er zitten remmen op.** Een code is 15 minuten geldig, werkt één keer, en na vijf misgokken
wordt hij weggegooid. Een nieuwe code maakt de vorige ongeldig. Per e-mailadres mag er hooguit
één code per 30 seconden en vijf per uur worden aangevraagd, zodat niemands postvak volloopt.
Die vijf per uur tellen alleen de codes waarmee **niet** is ingelogd (`signed_in_at` leeg). De
rem is er tegen iemand die andermans postvak volgooit, en wie de code invult zit zelf in dat
postvak. Telden geslaagde aanmeldingen wel mee, dan sloot je jezelf buiten na vijf keer inloggen
in een uur — en dat gebeurt precies wanneer er iets anders aan de hand is en je het opnieuw
probeert. Een code die is verdrongen door een nieuwe aanvraag of die is stukgeraden telt wél
mee: allebei die dingen kan een vreemde doen die alleen het adres kent, en anders was de rem in
twee stappen leeg te trekken.
Alles staat als constante bovenin `AccountService`, dus aanpassen is één regel.
## Een account opheffen
Zelf, op elk moment, in de app bij Mijn profiel en op `/account-verwijderen`. Dat webadres werkt
ook zonder in te loggen en zonder de app - Google Play eist dat apart, en terecht: een
verwijderknop die alleen in de app zit helpt niet als je de app al hebt weggegooid.
**Er komt een verse code per e-mail aan te pas**, met een eigen doel (`LoginCodePurpose.Opheffen`)
zodat een gewone inlogcode hier niet volstaat. Een openstaande sessie op een telefoon die iemand
anders in handen krijgt, hoort een kluis niet leeg te kunnen gooien.
De regel waar alles op teruggaat: **weg is wat van jou is, blijven doet wat van een ander is.**
In deze applicatie liggen die twee dichter bij elkaar dan je zou denken, dus staat per soort in
`AccountOpheffenService` waarom het de ene of de andere kant op valt. Kort:
| Weg | Blijft |
|---|---|
| Je kluis, je bestanden, je sleutel | Berichten die al bezorgd zijn - die zijn van de ontvanger |
| Berichten die nog klaarstonden | Je naam in de lijst van wie jou heeft toegevoegd |
| Je eigen families en je aantekeningen over anderen | Aantekeningen die ook aan iemand anders hangen |
| Je aanwijzingen in nalatenschappen, beide kanten op | Het logboek (daar staat geen inhoud in) |
| Je toestellen, je sessies, je inlogcodes | |
Dat bezorgde berichten leesbaar blijven is geen toeval: bij het bezorgen krijgt de ontvanger zijn
eigen kopie van de sleutel (zie `MessageService`). Jouw sleutel weggooien maakt ze dus niet stuk.
Zonder die eigenschap zou "bezorgd blijft" een belofte zijn die we niet waarmaken.
Het scherm laat eerst zien wát er weggaat - aantallen, geen "weet je het zeker?". En wie ergens
als nalatenschapsbeheerder is aangewezen, krijgt dat er apart bij te zien: die aanwijzing vervalt,
en de ander rekent erop.
> **Wat dit betekent.** Wie bij iemands e-mail kan, kan bij diens kluis. Dat is de prijs van
> inloggen zonder wachtwoord — al gold dat bij wachtwoorden met e-mailherstel net zo goed. Het
> alternatief, wachtwoord én code, is veiliger maar legt een drempel bij precies de groep waar
> MijnBesluit voor bedoeld is. Wil je later toch twee stappen: het staat allemaal in
> `AccountService`, de rest van de code verandert niet.
Bij inloggen wordt gezegd dat een e-mailadres onbekend is ("Maak dan eerst een account aan").
Dat verraadt of iemand deelnemer is. Bewust: zonder die melding raken mensen die zich vergissen
in hun adres het spoor bijster. Wil je dat niet prijsgeven, geef dan altijd hetzelfde antwoord
en stuur bij een onbekend adres een mail met de tekst dat er geen account is.
## De kluis: drie lagen
```
inhoud van een item --AES-256-GCM--> cijfertekst in de database
^
| contentsleutel (CEK), per item willekeurig
|
CEK --AES-256-GCM--> wrapped_content_key (ingepakt met de datasleutel van de eigenaar)
^
| datasleutel (DEK), per gebruiker
|
DEK --AES-256-GCM--> wrapped_data_key (ingepakt met de mastersleutel van de server)
```
Er staat dus nergens een sleutel in leesbare vorm in de database. Wie de database kopieert heeft
er niets aan zolang hij het sleutelbestand niet ook heeft.
Waarom drie lagen en niet twee? Omdat toegang geven aan een ander dan neerkomt op één handeling:
de contentsleutel nóg een keer inpakken, nu met de datasleutel van die ander. Dat is precies wat
er gebeurt als een bericht wordt bezorgd of als een nalatenschapsbeheerder toegang krijgt. De
inhoud zelf hoeft niet opnieuw versleuteld te worden, en de eigenaar houdt zijn eigen sleutel.
AES-256-GCM is *authenticated encryption*: bij elke ontsleuteling wordt de authenticatietag
gecontroleerd. Één gewijzigde byte in de database en het openen mislukt met een duidelijke fout,
in plaats van dat er stilletjes onzin uit komt.
## Wat is niet versleuteld?
Bewust leesbaar, omdat er anders niet te zoeken of te sorteren valt:
- de titel en de categorie van een kluisitem;
- het onderwerp, de afzender, de ontvanger en de bezorgdatum van een bericht;
- namen, e-mailadressen en relaties.
Zet dus geen geheimen in een titel. Dat staat ook zo in het scherm waar je een item aanmaakt.
## De mastersleutel
Die staat in een sleutelbestand (standaard `keys/masterkey.json`), als een sleutelring: meerdere
genummerde sleutels waarvan er één de actieve is. In een ingepakte datasleutel staat het
versienummer vooraan, zodat je kunt roteren zonder oude gegevens onleesbaar te maken.
Eenmalig aanmaken:
```csharp
FileMasterKeyProtector.CreateKeyFile("keys/masterkey.json");
```
Drie dingen om goed te doen:
1. **Zet het bestand niet in dezelfde back-up als de database.** Anders heeft wie de back-up
steelt alsnog alles.
2. **Beperk de rechten** tot het account waaronder de service draait.
3. **Maak er een aparte back-up van, op een andere plek.** Raak je dit bestand kwijt, dan is
iedere kluis onherstelbaar dicht. Er is geen achterdeur — dat is het punt.
Voor productie is een sleutelkluis of HSM beter. Vervang dan alleen `FileMasterKeyProtector`
door een eigen implementatie van `IMasterKeyProtector`; de rest van de code verandert niet.
Merk op: omdat de server de mastersleutel heeft, kan iemand met volledige serverrechten in
theorie bij de kluisinhoud. Dat is de prijs van deze opzet, en dat is bewust gekozen: het maakt
wachtwoordherstel mogelijk en zorgt dat de nalatenschapsbeheerder ook echt toegang krijgt als de
overledene zelf niets meer kan doen. Wil je dat niet, dan is de hybride variant nodig waarbij de
datasleutel ook met het wachtwoord van de gebruiker wordt ingepakt — maar dan is een vergeten
wachtwoord gelijk aan een verloren kluis.
## Bestanden
Bijlagen staan versleuteld op schijf, buiten de database, in mappen per jaar en maand. De
oorspronkelijke bestandsnaam komt niet in het pad voor (die kan al iets verraden); wat op schijf
staat heet `<willekeurige-guid>.bin`. Van de onversleutelde inhoud wordt een SHA-256 bewaard,
zodat beschadiging vast te stellen is. Paden worden gecontroleerd, zodat een opslagsleutel nooit
buiten de map kan wijzen.
## Tokens
Uitnodigingstokens en vernieuwtokens zijn 32 willekeurige bytes uit een cryptografische
generator, URL-veilig gecodeerd. In de database staat alleen de SHA-256 ervan. Een uitnodiging is
30 dagen geldig en eenmalig bruikbaar.
## Verbindingen
De website draait achter HTTPS met HSTS. Het sessiecookie is `HttpOnly`, `SameSite=Lax` en
`Secure`.
De SocketServer gebruikt TLS 1.2 of 1.3, met een servercertificaat uit een pfx-bestand of uit de
certificaatopslag van Windows. Zonder certificaat start de server wel, maar onversleuteld, met
een waarschuwing in het logboek — dat is uitsluitend bedoeld voor lokaal testen. Er gaan
wachtwoorden en pincodes over deze verbinding.
> **Verschil met de RenH-socketserver.** Daar wordt de verbinding beveiligd met een
> "socketsleutel": een tijdstempel, versleuteld met AES-128-CBC waarvan de sleutel de MD5 is van
> een vast wachtwoord in de broncode. Dat werkt prima als bewijs dat de client bij het systeem
> hoort en dat het bericht vers is, maar het versleutelt de inhoud niet en het wachtwoord staat
> in elke uitgeleverde app. Voor een dashboard met verkoopcijfers is dat een andere afweging dan
> voor een kluis met pincodes. Daarom hier TLS, waarbij de hele verbinding versleuteld is en het
> certificaat aan de serverkant blijft.
## Het logboek
`audit_log` legt vast wie wat wanneer deed: inloggen, mislukte pogingen, profielwijzigingen,
uitnodigingen, het openen van een kluisitem, overlijdensmeldingen en het vrijgeven van een
nalatenschap. De applicatiegebruiker in de database heeft op die tabel alleen INSERT-rechten
(zie `db/02_applicatiegebruiker.sql`), zodat regels niet achteraf gewijzigd of verwijderd kunnen
worden.
## Foutmeldingen
De SocketServer stuurt bij een onverwachte fout altijd dezelfde neutrale tekst terug. Wat er echt
misging staat in het logboek van de server, niet op het scherm van de gebruiker. Zo lekken er
geen servernamen, tabelnamen of stacktraces naar buiten.
## Waar de geheimen staan
Zet de connectiereeks en het SMTP-wachtwoord **niet** in `appsettings.json` in je repository. Bij
ontwikkelen:
```
dotnet user-secrets set "ConnectionStrings:MijnBesluit" "Server=localhost;Database=mijnbesluit;User Id=...;Password=..." --project src\MijnBesluit.Web
```
In productie: omgevingsvariabelen of een sleutelkluis.
## Wat er nog niet is
Eerlijk overzicht van wat bewust nog ontbreekt:
- **Twee-factor-authenticatie.** Het veld staat in de database, de implementatie nog niet.
- **Snelheidsbegrenzing per IP-adres.** Er is wel een blokkade per account na vijf mislukte
pogingen, maar nog geen begrenzing op verzoekniveau.
- **Certificaat-pinning in de app.** Aan te raden zodra de app publiek is.
- **Een onafhankelijke beveiligingsaudit.** Voor een product dat dit soort gegevens bewaart is
dat geen luxe.
+228
View File
@@ -0,0 +1,228 @@
# E-mail: hoe MijnBesluit post verstuurt
Zonder e-mail werkt MijnBesluit niet. Inloggen gaat met een code per mail, en uitnodigingen aan
familie gaan per mail. Elke storing hier is dus een storing in het hart van de applicatie.
## Hoe het nu loopt
Sinds september 2026 gaat alle uitgaande post via de **eigen Kerio Connect** op dezelfde server
als de website. Het domein mijnbesluit.nl staat daar als domein ingericht, met de mailboxen
`noreply@mijnbesluit.nl` en `info@mijnbesluit.nl`.
```
MijnBesluit (website + SocketServer)
-> 127.0.0.1:25 (Kerio Connect, zelfde machine)
-> poort 25 naar buiten -> Gmail en de rest
```
De afzender is `noreply@mijnbesluit.nl`. Dat mag nu, want het domein staat op onze eigen server;
bij Solcon kon het niet (zie "Wat er al een keer misging").
## Waar de instellingen staan
In `C:\MijnBesluitData\appsettings.secrets.json` op de server, in het blok `Smtp`. Dat bestand
wordt gelezen door **zowel de website als de SocketServer**, zodat er één plek is:
```json
"Smtp": {
"Host": "127.0.0.1",
"Port": 25,
"UseStartTls": false,
"Username": "",
"Password": "",
"FromAddress": "noreply@mijnbesluit.nl",
"FromName": "MijnBesluit",
"WriteToDiskInsteadOfSending": false,
"RedirectAllTo": ""
}
```
### Waarom gebruikersnaam en wachtwoord leeg zijn
Kerio staat relayen toe voor de groep "Local clients", en `127.0.0.1` zit daarin. Post die van
deze server zelf komt hoeft dus niet in te loggen. Dat is niet alleen eenvoudiger maar ook beter:
er staat geen mailwachtwoord meer in dit bestand.
**Vul hier niet alsnog een wachtwoord in.** Klopt het niet, dan krijg je geen foutmelding maar
traagheid - zie hieronder.
Na een wijziging moet je herstarten; de instellingen worden één keer bij het opstarten gelezen
en daarna vastgehouden, ondanks `reloadOnChange`:
```powershell
Restart-Service MijnBesluitSocket
Restart-WebAppPool mijnbesluit
```
### Waarom `127.0.0.1` en niet `localhost`
`localhost` lost Windows eerst op naar het IPv6-adres `::1`. Luistert Kerio daar niet, dan wacht
.NET het verbindingstijdslimiet af voordat hij IPv4 probeert. Het resultaat: de mail komt wél
aan, maar het antwoord aan de app duurt meer dan tien seconden en het proefscript geeft een
time-out. Met een hard IPv4-adres is het binnen een seconde klaar.
**Dit geldt net zo goed voor de database.** MariaDB luistert standaard alleen op 127.0.0.1, dus
`Server=localhost` in de connectiereeks kost bij elke nieuwe verbinding dezelfde verloren
seconden - merkbaar als de eerste bezoeker na een herstart van de app-pool lang moet wachten.
Zet ook daar `Server=127.0.0.1`. Meet het verschil zo:
```powershell
Measure-Command { .\tools\SocketServer-proef.ps1 } # raakt de database niet
Measure-Command { .\tools\SocketServer-proef.ps1 -Email ... -Nieuw } # raakt hem wel
```
### Waarom geen TLS op die stap
Kerio presenteert op 587 nog een zelfondertekend certificaat. `System.Net.Mail.SmtpClient`
weigert dat botweg (`The remote certificate is invalid according to the validation procedure`,
met `RemoteCertificateNameMismatch, RemoteCertificateChainErrors`), en de controle uitzetten is
in een applicatie die wachtwoorden bewaart het verkeerde ruilmiddel.
Over loopback is dat te verdedigen: het pakket verlaat de machine niet. Maar het hoort tijdelijk
te zijn - zie "Wat hier nog niet goed aan is".
Let op: `SmtpClient` kan geen implicit TLS. Poort 465 werkt dus nooit; dat moet 587 met STARTTLS
zijn zodra er een geldig certificaat is.
## De DNS voor mijnbesluit.nl
Bij Strato. Alles hieronder is september 2026 ingericht; daarvóór had het domein helemaal geen
TXT-records.
| Naam | Type | Waarde |
|---|---|---|
| `mijnbesluit.nl` | A | 85.215.171.61 |
| `mijnbesluit.nl` | MX | `mail.kwik-bit.nl`, prioriteit hoog |
| `mijnbesluit.nl` | TXT | `v=spf1 ip4:85.215.171.61 ~all` |
| `mail._domainkey.mijnbesluit.nl` | TXT | de DKIM-sleutel die Kerio toont |
| `_dmarc.mijnbesluit.nl` | TXT | `v=DMARC1; p=none; rua=mailto:info@mijnbesluit.nl` |
Aandachtspunten:
* In het MX-record hoort een **naam**, geen IP-adres. Een MX naar `85.215.171.61.` wordt door
mailservers die zich aan de regels houden geweigerd.
* Strato's eigen SPF- en DMARC-knoppen staan op "geen", zodat er geen tweede record naast het
onze komt. Twee SPF-records betekent dat geen van beide werkt.
* De reverse lookup van 85.215.171.61 geeft `mail.kwik-bit.nl`, en die naam wijst terug naar
hetzelfde IP. Die heen-en-terugcontrole is wat Gmail eist; hij hoeft niet bij het
afzenderdomein te horen. De Internet hostname in Kerio moet gelijk zijn aan die PTR.
### Bekend probleem: ns3 loopt achter
De twee nameservers van Strato geven niet hetzelfde antwoord: `ns4.stratoserver.net` heeft het
DKIM-record, `ns3.stratoserver.net` valt terug op een wildcard-CNAME naar `mijnbesluit.nl`.
DKIM slaagt daardoor ongeveer de helft van de keren. SPF staat op het domein zelf en heeft er
geen last van, en SPF alleen is genoeg voor DMARC-uitlijning - de post komt dus gewoon aan.
Controleren:
```powershell
$ns = (Resolve-DnsName mijnbesluit.nl -Type NS -Server 8.8.8.8).NameHost
foreach ($s in $ns) {
"--- $s"
Resolve-DnsName mail._domainkey.mijnbesluit.nl -Type TXT -Server $s -ErrorAction SilentlyContinue |
Select-Object Type, @{n='Waarde';e={ if ($_.Strings) { ($_.Strings -join '').Substring(0,40) } else { $_.NameHost } }}
}
```
Staat ns3 er over een paar dagen nog steeds anders in, dan is dat een melding aan Strato:
*"`mail._domainkey.mijnbesluit.nl` geeft op ns3 een wildcard-CNAME en op ns4 het TXT-record."*
## Testen
Van binnen naar buiten, zodat je weet wáár het misgaat:
```powershell
# 1. de hele keten: socket, database, mailserver
.\tools\SocketServer-proef.ps1 -Email iemand@example.nl -Nieuw
# 2. alleen de mailserver
$c = Get-Credential
Send-MailMessage -SmtpServer mail.kwik-bit.nl -Port 587 -UseSsl -Credential $c `
-From "noreply@mijnbesluit.nl" -To "..." -Subject "Proef" -Body "Proef"
```
`-Nieuw` vraagt een registratiecode in plaats van een inlogcode, zodat je naar een adres kunt
testen waar nog geen account bij hoort. Er wordt pas een account aangemaakt als de code wordt
ingevuld.
Komt de mail aan bij Gmail, open hem dan via *Origineel weergeven*: daar staat of SPF, DKIM en
DMARC geslaagd zijn. Dat is de enige echte controle.
## Wat er al een keer misging
### 0. Een voorbeeldtekst als wachtwoord kost negen seconden per inlogcode
Het langste dat dit project ons heeft gekost, en het stond al die tijd in het instellingenbestand:
```json
"Password": "het wachtwoord van die mailbox",
```
Dat is de voorbeeldtekst uit de handleiding, nooit vervangen. Het gevolg is níet een foutmelding.
Kerio weigert de aanmelding, .NET probeert het opnieuw met een andere methode, en valt daarna
terug op versturen zonder aanmelden - wat mag, want het IP-adres staat in "Local clients". De
mail komt dus gewoon aan. Alleen duurt hij **negen seconden**, elke keer, en die staat de
gebruiker bij het inloggen uit te zitten.
Wat het zo lastig maakte om te vinden: alles wat je van buitenaf meet is snel. Kerio doet 5 ms,
met en zonder inloggen. Maar dat meet je met Windows PowerShell, en dat is .NET Framework 4.8 -
een andere implementatie van `SmtpClient` dan de .NET 8 waar de applicatie op draait, die andere
keuzes maakt bij het aanmelden. Appels met peren.
Lessen voor de volgende keer:
* Controleer het instellingenbestand **als eerste**, niet als laatste.
* Windows PowerShell is geen goede proef voor .NET-gedrag. Gebruik `pwsh` als je het na wilt doen.
* De tijdmeting in `SmtpEmailSender` splitst het wachten in "wachten op een vrije draad" en
"het SMTP-gesprek zelf". Die twee getallen wezen het aan; laat ze staan.
De applicatie waarschuwt nu bij het opstarten als het SMTP-wachtwoord spaties bevat - een echt
wachtwoord doet dat zelden en een voorbeeldzin altijd.
### 1. De afzender moet bij het account horen
Solcon weigerde elk afzenderadres dat niet bij het inlogaccount hoorde:
```
Mailbox unavailable. The server response was: Verification failed for <noreply@mijnbesluit.nl>
```
Dat is met de eigen mailserver verdwenen.
### 2. Elke mail heeft een Message-ID nodig
```
550-5.7.1 Messages missing a valid Message-ID header are not accepted
```
.NET zet die koptekst niet zelf, en niet elke mailserver vult hem aan. `SmtpEmailSender` zet hem
er daarom zelf op; zie `Berichtkenmerk` in de Application-laag.
### 3. Het domein moet zeggen wie er namens hem mag versturen
```
550-5.7.26 Gmail requires all senders to authenticate with either SPF or DKIM
```
Vandaar de records hierboven.
## Wat hier nog niet goed aan is
**Kerio heeft geen geldig certificaat.** Zelfondertekend, op de verkeerde naam. Daardoor moet de
applicatie over loopback zonder TLS praten, krijgt elke mailclient een waarschuwing, en kunnen
andere mailservers de TLS-verbinding weigeren. De oplossing is een Let's Encrypt-certificaat voor
`mail.kwik-bit.nl` met **win-acme** op die server - die kan via IIS op poort 80 valideren, en na
afloop een script draaien dat het certificaat in Kerio zet. Kerio's eigen Let's Encrypt-knop
werkt hier niet, want die wil poort 80 zelf hebben en die is van IIS.
Daarna kan het `Smtp`-blok terug naar `mail.kwik-bit.nl` op poort 587 met `UseStartTls: true`.
**Je ziet niet of een mail aankomt.** De applicatie weet alleen of Kerio hem heeft aangenomen.
Wordt hij daarna alsnog geweigerd, dan komt die melding als bounce terug op `noreply@` en ziet de
gebruiker alleen "de code is verstuurd" terwijl er niets aankomt. De DMARC-rapporten naar
`info@mijnbesluit.nl` geven daar wel zicht op, maar pas achteraf en per dag.
**`p=none` in DMARC.** Dat is de juiste stand om mee te beginnen: je krijgt rapporten zonder dat
er post geweigerd wordt. Gaat het een paar weken goed, dan kan het naar `p=quarantine` en het
SPF-record van `~all` naar `-all`.
+275
View File
@@ -0,0 +1,275 @@
# Wie is wie: dubbele profielen, claimen en samenvoegen
Dit document legt vast hoe MijnBesluit omgaat met de vraag "is dit dezelfde persoon?". Het is
geschreven na een ontwerpgesprek op 15 september 2026, omdat de keuzes hier niet vanzelfsprekend
zijn en de redenen erachter over een paar maanden niet meer uit de code te halen zijn.
## Het probleem
Jonathan voegt zijn dochter Ysabeau toe als dochter. Zijn ex-partner voegt dezelfde Ysabeau toe,
ook als dochter. Straks doen haar grootouders dat ook. Uiteindelijk maakt Ysabeau zelf een
account en wil ze haar eigen profiel beheren — en als ze daar een foto zet, horen haar familie en
vrienden die te zien.
Zonder maatregelen levert dat vier keer dezelfde persoon op.
## De kern van de oplossing
**Een niet-geclaimd profiel is een privé-aantekening van degene die hem maakte.**
Wat Jonathan heeft aangemaakt is niet "Ysabeau", het is *zijn aantekening over zijn dochter*. Dat
zijn ex haar eigen aantekening heeft is normaal en eerlijk. Dubbele profielen zijn vóór het
claimen dus geen fout.
Het wordt pas een probleem op het moment dat Ysabeau zelf zegt: die ben ik. Daarmee verschuift de
hele vraag — niet "hoe voorkom ik duplicaten bij het aanmaken", maar "hoe smelten ze samen op het
moment van claimen".
## Wat we bewust NIET doen
**Niet zoeken op naam en geboortedatum.** MyHeritage doet dat wel: je typt een naam en
geboortedatum en krijgt "bedoelt u deze?". Dat werkt daar, maar het lekt. Wie mag zo'n vraag
stellen? Als iemand "Ysabeau Flietstra, 12 maart 1998" intikt en het systeem zegt "die kennen
we", dan weet die persoon nu dat ze hier zit. Met genoeg pogingen is dat een zoekmachine op
mensen, en dat is precies wat MijnBesluit niet moet zijn.
**Niet stilzwijgend koppelen op e-mailadres.** (Wél vrágen; zie het hoofdstuk hieronder over de
vraag bij het aanmelden. Het verschil is wie er beslist.) Dit zat er wél in, tot 15 september 2026:
`AddMemberAsync` zocht een bestaand profiel op adres en hing dat aan je familie. Gevolg: wie het
adres van iemand kende, zag meteen wat een ander over die persoon had ingevuld — naam,
geboortedatum, profielfoto. Dat is dezelfde zoekmachine, via een andere deur.
De regel is nu: **je hergebruikt alleen je eigen aantekeningen, nooit die van een ander.** Voeg
je iemand toe aan een tweede familie van jezelf, dan is dat wél hetzelfde profiel — anders zie je
je eigen dochter twee keer.
## Hoe het dan wel samenkomt
**De uitnodiging is de match.** Jonathan nodigt Ysabeau uit op haar adres, zijn ex doet dat ook,
misschien op een ander adres. Ze neemt beide uitnodigingen aan. Daarmee heeft zíj gezegd dat
beide profielen haar zijn — en zij is de enige die dat mag zeggen.
Concreet:
| Situatie | Wat er gebeurt |
|---|---|
| Eerste uitnodiging, nog geen account | Er wordt een account gemaakt en het profiel wordt geclaimd. Zoals het altijd al ging. |
| Tweede uitnodiging, **ingelogd** | Het profiel uit de uitnodiging gaat op in het profiel dat je al hebt. |
| Tweede uitnodiging, **niet ingelogd**, adres is bekend | Geweigerd, met de uitleg dat je eerst moet inloggen. Een tweede account zou juist het probleem veroorzaken. |
Dit werkt ook als beide ouders een ander e-mailadres van haar hadden: het adres speelt geen rol
bij het koppelen, alleen de link die zij aanklikt.
## De vraag bij het aanmelden (17 september 2026)
De uitnodiging is de match, maar hij is niet de enige weg. Wat er in de praktijk gebeurde: iemand
voegt een vriend toe met zijn e-mailadres, die vriend maakt zelf een account aan, en dan staan er
twee losse dingen naast elkaar die allebei over dezelfde man gaan. De vriend moet dan wachten tot
er een uitnodiging komt die de ander misschien nooit verstuurt.
Automatisch koppelen op adres is daar niet het antwoord op, om twee redenen. Het lekt (zie
hierboven), en een adres is geen identiteit: twee mensen kunnen er één delen - een echtpaar, een
ouder die het adres van zijn kind gebruikt, een `info@` - en sinds `db/15` kunnen er ook meerdere
aantekeningen met hetzelfde adres bestaan. Automatisch koppelen moet dan gokken welke, en een
verkeerde gok maakt van twee mensen stilletjes één. Dat krijg je er niet meer uit.
Daarom vragen we het aan de enige die het antwoord weet. Wie is aangemeld, ziet bovenaan zijn
overzicht staan: *"X heeft een profiel voor je aangemaakt op jouw e-mailadres, als vriend. Ben jij
dat?"* Met twee knoppen, en dat is alles.
| Antwoord | Wat er gebeurt |
|---|---|
| **Ja, dat ben ik** | Dezelfde samenvoeging als bij een uitnodiging die je ingelogd aanneemt. |
| **Nee, dat ben ik niet** | Er verandert niets, en de vraag komt niet terug (`persons.link_declined_by_user_id`, `db/17`). |
| **Niets** | Er verandert niets. De vraag blijft staan tot hij hem beantwoordt. |
Waarom dat mag zonder dat het lekt: wie deze vraag krijgt, heeft zojuist met een code in dat
postvak bewezen dat het adres van hem is. De enige die iets te zien krijgt is dus de eigenaar van
het adres - en over zichzelf. Hij ziet de naam van degene die de aantekening bijhoudt en wat hij
van hem zou zijn; dat is precies genoeg om de vraag te kunnen beantwoorden, en niet meer.
Andersom gebeurt er niets: degene die de aantekening maakte krijgt geen melding dat er iets is
gevraagd, en geen melding dat het antwoord nee was. Anders zou het weigeren zélf een mededeling
worden over wie er wel of niet een account heeft.
De vraag staat op het overzicht en niet als popup bij het aanmelden. Iemand kan je ook toevoegen
nádat jij je al had aangemeld, en een popup die je wegklikt is een vraag die je nooit meer krijgt.
Er is hierdoor **geen apart goedkeuringsverzoek nodig** voor het toevoegen van iemand die al een
account heeft. Dat stond eerst in het plan, maar het lost zichzelf op: wie nooit andermans
profiel adopteert, geeft ook niets weg. Wil je die persoon er echt bij, dan nodig je uit — en die
uitnodiging *is* de toestemming.
## Wat er verhuist bij het samenvoegen
Zie `SamenvoegService`. Een niet-geclaimd profiel bezit weinig, want kluisitems horen bij de
persoon áchter een account. Wat er wel aan hangt:
- **lidmaatschappen** — staat het doel al in die familie, dan verdwijnt het dubbele lidmaatschap;
de relatie die er al stond blijft leidend, want die is ingevuld door de eigenaar van die familie
- **berichten** die voor het duplicaat klaarstonden of ervandaan kwamen
- **foto's** waar de persoon op staat; hoogstens één blijft profielfoto
- **uitnodigingen**, ook de gebruikte
- **rollen in een nalatenschap** — een dubbele aanwijzing of een tweede overlijdensmelding van
dezelfde gebruiker verdwijnt, anders vervalst dat het aantal
- **lege velden** worden aangevuld vanuit het duplicaat, nooit overschreven: wat bij het doel
staat komt van de persoon zelf en weegt zwaarder
Het duplicaat wordt **niet verwijderd**. Hij blijft staan met `merged_into_person_id` naar waar
hij in is opgegaan — er hangt geschiedenis aan, en een verwijderde rij is niet terug te halen.
Twee grenzen, allebei met een controle erop:
- Je kunt alleen samenvoegen met **je eigen** profiel.
- Twee **geclaimde** profielen gaan nooit automatisch samen. Dat zijn twee echte accounts met
ieder een eigen kluis; daar hoort een mens naar te kijken.
## De profielfoto
Hier lag een echte afweging. Zet Ysabeau zelf een foto, dan is die versleuteld met háár sleutel.
Haar vader zou hem dan niet kunnen zien — precies het tegenovergestelde van de bedoeling.
**Gekozen: de kleine versie is niet versleuteld, de foto op ware grootte wel.**
De redenering: een profielfoto is juist het ding dat je aan andere mensen laat zien. Hem
behandelen als kluismateriaal is niet eerlijk over wat het is, en zou betekenen dat je voor elke
kijker een sleutel moet uitdelen en weer intrekken zodra families veranderen.
De prijs is echt en bewust genomen: wie toegang heeft tot de database, ziet gezichten van 160
pixels. Alles waar het in MijnBesluit om gaat — wachtwoorden, polissen, uitvaartwensen, berichten
— blijft versleuteld, net als de foto op ware grootte.
**Wie hem mag zien:** iedereen met wie je in een familie zit. Dat is de kring die mensen zelf
samenstellen door elkaar toe te voegen en uit te nodigen. Die controle staat in `VaultService` en
niet in de schermen: een toegangsregel hoort niet af te hangen van de vraag of elk scherm eraan
denkt.
Kleine versies uit de allereerste opzet waren nog wél versleuteld. Die worden bij het eerste
gebruik automatisch vervangen; daar is geen migratie voor nodig.
**Waar hij staat:** overal waar een mens in een lijst staat — Mijn mensen, je beheerders, de
nalatenschappen die aan jou zijn toevertrouwd — en in app en website hetzelfde. Dat zit in één
onderdeel, `Penning.razor`. Dat bestand staat twee keer op schijf (de app en de site delen geen
project) en een zelfcontrole vergelijkt ze letterlijk, zodat ze niet uit elkaar lopen.
**Jezelf** sta je niet tussen je eigen mensen — je bent geen familielid van jezelf. Bovenaan Mijn
mensen staat daarom een eigen kaart die naar Mijn profiel gaat. Daar wijzig je je naam, je
geboortedatum, je foto en je e-mailadres; dat laatste met een code naar het nieuwe adres. Zonder
die kaart gingen mensen hun eigen gegevens zoeken in de lijst waar ze juist niet in staan.
## Het adres van een persoon is niet uniek
Dat lijkt een detail van de database, maar het is de hele regel hierboven in één index.
Op `persons.email` stond een `UNIQUE`-index. Het gevolg: voegde je iemand toe met een adres dat
al ergens in de database stond - bij wie dan ook, ook bij een wildvreemde - dan liep het opslaan
stuk. Voor de gebruiker was dat een knop die niets deed. Wie een adres het eerst invulde,
bepaalde daarmee dat niemand anders die persoon nog kon toevoegen. In een applicatie waar twee
ouders allebei hun dochter toevoegen is dat precies de verkeerde kant om.
Sinds `db/15_adres_niet_uniek.sql` is het een gewone index. Wat dat betekent voor de code:
- **Zoeken op adres kan meer dan één profiel opleveren.** `ListByEmailAsync` geeft ze allemaal;
`FindByEmailAsync` geeft er één, geclaimde eerst, zodat je in elk geval altijd dezelfde
terugkrijgt.
- **Wie zijn eigen profiel zoekt, moet daarop filteren** en niet op de eerste treffer. Met de
eerste de beste kreeg je die van een ander te pakken, viel de eigendomscontrole uit, en maakte
je een tweede aantekening aan voor iemand die je zelf al had staan.
- **Bij het wijzigen is alleen een botsing met je eigen profiel een bezwaar.** Dat een ander
dezelfde persoon kent, is geen fout maar het uitgangspunt.
- **Een account maken koppelt niet meer op adres.** Wie zonder uitnodiging een account maakt,
krijgt zijn eigen profiel; de aantekening van een ander komt bij hem terecht op het moment dat
hij díens uitnodiging aanneemt. Zoeken op adres betekende andermans aantekening adopteren
zonder dat die ander er iets over te zeggen had, en met meerdere aantekeningen was het
bovendien de vraag welke je dan kreeg.
Bij `users.email` blijft uniek juist wél gelden: dat is een account, en er kan er maar één per
adres zijn. Een zelfcontrole bewaakt allebei die kanten.
## Wat hier nog niet is opgelost
- **Mensen zonder e-mailadres** kunnen nooit claimen, dus hun duplicaten blijven bestaan. Voor
een kluis en berichten maakt dat niet uit. Zodra er ooit een gedeelde stamboom komt, wordt het
wel een vraag.
- **Samenvoegingen van vóór 15 september 2026** zijn niet terug te draaien. Daar is geen bonnetje
van (zie hieronder); de gegevens om het mee te doen bestaan eenvoudigweg niet. Dat is geen fout
die nog te repareren valt.
## Samenvoegen terugdraaien
Samenvoegen was lange tijd onomkeerbaar, en dat stond hier ook als open punt. Dat is het nu niet
meer.
Waarom het niet zomaar kon: ná een samenvoeging is niet meer te zien wélke lidmaatschappen,
berichten en bestanden bij het duplicaat hoorden. Ze staan gewoon bij het doel, tussen alles wat
daar al stond. Een gok daarop zou andermans gegevens meesleuren.
**Daarom schrijft elke samenvoeging nu een bonnetje.** In de tabel `samenvoegingen` komt per
samenvoeging één regel te staan met precies wat er is verhuisd, als lijstje stappen in JSON.
Terugdraaien is die lijst in omgekeerde volgorde aflopen. Regels die bij het samenvoegen zijn
weggegooid — een dubbel lidmaatschap, een dubbele beheerder — staan er als volledige kopie in,
want die moeten opnieuw worden aangemaakt.
Drie dingen die daarbij met opzet zo zijn:
- **Wat er ná de samenvoeging is bijgekomen, blijft staan.** Wat Ysabeau daarna zelf in haar kluis
heeft gelegd, is van haar en hoort bij haar. Alleen wat in het bonnetje staat gaat terug.
- **Een veld dat we hebben aangevuld gaat alleen leeg als het nog steeds is wat wij invulden.**
Heeft de persoon het daarna zelf aangepast, dan is het van hem en blijft het staan.
- **Terugdraaien mag alleen een beheerder.** Samenvoegen doet de persoon zelf — alleen hij mag
zeggen dat twee profielen hem zijn — maar het raakt ook de gegevens van degene die het
duplicaat ooit aanmaakte. Gaat het mis, dan hoort er een mens naar te kijken.
Is het doelprofiel zelf later ergens in opgegaan, dan weigert het terugdraaien: het bonnetje
wijst dan niet meer naar waar de gegevens staan. Die latere samenvoeging gaat eerst terug.
Het bonnetje blijft na afloop staan, met wanneer en waarom het is teruggedraaid. Dát er ooit is
samengevoegd, is zelf ook geschiedenis.
## De beheerder
Er is één beheerpagina, `/beheer`, en die is met opzet bijna leeg. Hij bestaat voor de twee
situaties waar niemand anders uit komt: een overlijden dat ten onrechte is bevestigd terwijl de
bezwaarlink verlopen is — het account staat dan op "overleden", inloggen kan niet meer, en er is
geen weg terug — en een samenvoeging die niet had gemoeten.
Wat een beheerder wél kan:
- een profiel opzoeken op het **volledige** e-mailadres
- een bevestigd overlijden terugdraaien, met een verplichte reden die in het logboek komt
- een samenvoeging terugdraaien, ook met een verplichte reden
Beide handelingen hebben iets gemeen dat ze veilig maakt: ze geven nooit toegang. De ene neemt
toegang weg, de andere zet terug wat er stond.
Wat hij niet kan, en ook nooit moet kunnen:
- **een kluis openen.** Zijn enige handeling neemt toegang wég en geeft hem nooit. Wie dat
verandert, haalt de bodem onder het hele product vandaan.
- **op naam zoeken.** Dat zou de zoekmachine op mensen zijn die hierboven wordt afgewezen. Je
moet al weten wie je zoekt, omdat diegene contact met je heeft opgenomen.
- **iemand beheerder maken.** Dat gaat met de hand in de database:
`UPDATE users SET is_beheerder = 1 WHERE email = '...'`. Wat niet via de applicatie kan, kan
ook niet via een lek in de applicatie.
Wie geen beheerder is, krijgt op die pagina "Niet gevonden" te zien — niet "geen toegang". Een
pagina waarvan je niet weet dat hij bestaat, ga je ook niet proberen.
### Het menu-item Beheer
In het menu staat Beheer alleen voor een beheerder. Of dat zo is, komt uit het inlogcookie
(de claim `mijnbesluit:beheer`) en niet uit de database.
Dat is niet uit zuinigheid. Het menu zit in de layout, en die staat op élke pagina; hij wordt
tegelijk met de pagina zelf opgebouwd. Vroeg de layout het op bij de database, dan liep die
vraag gelijk op met de vraag van de pagina — over dezelfde DbContext, en die kan er maar één
tegelijk aan. Het gevolg was `A second operation was started on this context instance`: een
foutpagina die er soms wel en soms niet was, afhankelijk van welke vraag net eerder klaar was.
Een layout hoort niets op te halen; de zelfcontrole "Een layout haalt zelf niets op" houdt dat
vast.
De prijs is dat iemand die net beheerder is gemaakt het menu-item pas ziet na opnieuw inloggen.
Dat is te overzien, want de claim is geen sleutel maar een lampje: `/beheer` en
`/beheer/adverteren` kijken zelf nog in de database of je het écht bent, en doen dat bij elke
handeling opnieuw. Wordt iemands beheerderschap ingetrokken, dan is hij dus meteen geweerd —
alleen blijft het menu-item nog staan tot hij uitlogt.
+148
View File
@@ -0,0 +1,148 @@
# Meldingen op de telefoon
Als er een bericht voor iemand wordt bezorgd, hoort zijn telefoon dat te laten weten - ook als
de app dicht is. Daarvoor is Firebase Cloud Messaging van Google nodig; een andere weg is er op
Android niet.
De applicatie is er klaar voor. Wat er nog moet gebeuren is een Firebase-project aanmaken en
twee bestanden op de juiste plek zetten. Aan de code hoeft niets te veranderen: het csproj gaat
vanzelf aan zodra het eerste bestand er staat.
## Wat er al werkt
| Onderdeel | Waar |
|---|---|
| Toestellen bijhouden | tabel `toestellen`, `MeldingService` |
| Aanmelden vanuit de app | bij elk inloggen en elke hervatte sessie |
| Afmelden bij uitloggen | zodat de volgende gebruiker op die telefoon niet jouw meldingen krijgt |
| Versturen bij bezorging | `MessageService.DeliverAsync` |
| Praten met Google | `FirebasePush`, met een zelfgeschreven ondertekening |
| Opruimen van dode kenmerken | een toestel dat Google niet meer kent wordt meteen afgemeld |
Zonder servicesleutel gebeurt er niets en werkt alles gewoon door - er gaan alleen geen
meldingen uit.
**Wat er bewust níét in een melding staat:** de inhoud. Een melding verschijnt op een scherm dat
ook zichtbaar is als de telefoon op tafel ligt. Er staat alleen dat er iets klaarstaat en van
wie. Daar is een controle voor in de zelftest.
## Stap 1: het Firebase-project
1. Ga naar console.firebase.google.com en maak een project, bijvoorbeeld **MijnBesluit**.
Google Analytics kun je overslaan.
2. Voeg een **Android-app** toe met pakketnaam `nl.mijnbesluit.app` - precies zoals hij in
`MijnBesluit.App.csproj` bij `ApplicationId` staat.
3. Download `google-services.json` en zet hem in `src\MijnBesluit.App\Platforms\Android\`.
Een Play Console-account is hier niet voor nodig; Firebase is gratis en staat los van de
Play Store.
## Stap 2: niets
Dit stond hier vroeger als een handmatige aanpassing van het csproj. Dat is nu niet meer nodig:
in `MijnBesluit.App.csproj` staat een blok dat zichzelf aanzet zodra `google-services.json` er
staat. Het haalt dan het package `Xamarin.Firebase.Messaging` erbij, koppelt het json-bestand,
en zet de schakelaar `FIREBASE` aan die bij de `#if` in `Toestelmeldingen.cs` hoort.
Staat het bestand er niet, dan gebeurt er niets en bouwt het project gewoon. Dat is met opzet:
er is nu geen stap die je een keer kunt overslaan, en het project blijft te bouwen op een machine
waar dat bestand niet staat.
Het versienummer in dat blok (`125.1.1.1`, bij `net10.0-android`) moet passen bij de
werkbelasting op je machine. Klaagt de build over versies, kijk dan in NuGet welke bij jouw
.NET-versie hoort en pas alleen dat nummer aan.
In hetzelfde blok staan vijf AndroidX-packages met een versienummer erbij. Die horen niet bij
Firebase zelf, maar lossen een botsing op: Firebase vraagt om oudere `.Ktx`-packages, terwijl
MAUI de bijbehorende basispackages al nieuwer binnenhaalt. Sinds Fragment 1.9.0 zitten de
Kotlin-uitbreidingen in het hoofdpackage, dus kwam dezelfde klasse twee keer binnen en brak de
build bij het dexen (`Type androidx.fragment.app.FragmentManagerKt is defined multiple times`).
Komen de NU1608-waarschuwingen ooit terug, dan heeft een nieuwere MAUI de basispackages weer
opgehoogd: zoek in NuGet de bijpassende `.Ktx`-versie op en hoog die nummers mee op. Weghalen
lost het niet op, dan komt de dubbele klasse terug.
Na het wijzigen van die versies: gooi `src\MijnBesluit.App\obj` en `bin` weg voordat je opnieuw
bouwt. In `obj` staan de uitgepakte Java-bibliotheken van de vorige build, en die worden niet
altijd opgeruimd.
## Stap 3: de servicesleutel op de server
1. In Firebase: **Projectinstellingen > Serviceaccounts > Nieuwe persoonlijke sleutel genereren**.
Er wordt een json-bestand gedownload.
2. Zet dat op de server als `C:\MijnBesluitData\keys\firebase.json`.
3. Zet in `C:\MijnBesluitData\appsettings.secrets.json`:
```json
"Push": {
"ServiceAccountFile": "C:\\MijnBesluitData\\keys\\firebase.json"
}
```
4. `Restart-Service MijnBesluitSocket`.
**Dat bestand is een sleutel tot je Firebase-project.** Bewaar het net zo zorgvuldig als
`masterkey.json` en zet het nooit in de publicatiemap - die wordt bij elke publicatie
overschreven, en erger: dan staat hij in het zip-bestand dat je rondstuurt.
In het logboek van de service staat bij het opstarten of het gelukt is:
```
Meldingen staan aan voor Firebase-project mijnbesluit-...
```
## Stap 4: controleren
1. App opnieuw installeren en inloggen. Android vraagt nu toestemming voor meldingen - die
vraag komt van `Permissions.PostNotifications`, verplicht sinds Android 13.
2. **Ga naar Mijn profiel en druk op "Stuur me een proefmelding".** Dat kan ook op de website,
bij Mijn profiel onder *Meldingen op je telefoon*.
Die knop loopt de hele keten langs en zegt waar het op vastloopt:
| Wat er staat | Wat eraan mankeert |
|---|---|
| "Op de server staat geen servicesleutel voor Firebase" | stap 3 is niet gedaan, of het pad klopt niet |
| "Er is geen toestel aangemeld" | de app heeft geen kenmerk; zie *Als er niets komt* hieronder |
| "Verstuurd naar je toestel" en je ziet niets | meldingen staan op de telefoon zelf uit |
| "Google nam de melding niet aan" | het kenmerk is verlopen; opnieuw inloggen in de app |
| Je ziet de melding | klaar |
Hij gaat alleen naar je eigen toestellen. Iemand anders een melding kunnen sturen zou een manier
zijn om mensen lastig te vallen; daar is een controle voor in de zelftest.
Wil je het echte pad ook een keer zien: zet vanaf de website een bericht voor jezelf klaar met
bezorging **meteen**. De achtergrondtaak van de SocketServer bezorgt het binnen een kwartier, en
dan hoort de melding vanzelf te verschijnen.
## Als er niets komt
* **Geen regel in `toestellen`** - de app krijgt geen kenmerk. Meestal ontbreekt
`google-services.json` in de APK die op het toestel staat (is hij ná het bouwen toegevoegd,
bouw dan opnieuw), of heeft de gebruiker de toestemmingsvraag weggeklikt.
```sql
SELECT id, user_id, platform, naam, laatst_gezien FROM toestellen;
```
* **Wel een regel, geen melding** - kijk in het logboek van de service. Staat er "Google gaf
geen toegangstoken", dan klopt de servicesleutel niet. Staat er "Melding niet afgeleverd",
dan zegt Google waarom.
* **"Verstuurd naar je toestel", maar niets te zien terwijl de app openstaat** - Android zet een
melding alleen zelf in de balk als de app op de achtergrond staat. Staat de app open, dan geeft
Android hem door aan `Meldingsdienst` in `Platforms\Android`, en die toont hem. Ontbreekt die
klasse, of is hij niet als dienst aangemeld, dan verdwijnt de melding geruisloos. Daarom gaan
titel en tekst twee keer mee in het bericht: in het `notification`-deel voor Android zelf, en
in het `data`-deel voor de app. Zie `FirebasePush.StuurAsync`.
* **Werkte eerst, nu niet meer** - het kenmerk van Firebase verandert af en toe vanzelf,
bijvoorbeeld na het terugzetten van een back-up. De app meldt zich daarom bij elke start
opnieuw aan; werkt dat niet, dan komt de app niet langs het inloggen.
## Wat hier nog niet in zit
* **iOS.** Daar loopt het via APNs, met een sleutel uit het Apple Developer Program (€99 per
jaar). Firebase kan dat doorgeven, dus de serverkant blijft hetzelfde.
* **Meldingen bij andere gebeurtenissen.** Nu alleen bij een bezorgd bericht. Een overlijdens-
melding of een uitnodiging zou net zo goed een melding kunnen zijn; de plek om dat aan te
roepen is `MeldingService.StuurAsync`.
* **Instellen per gebruiker.** Wie geen meldingen wil, kan ze nu alleen op het toestel zelf
uitzetten.
+201
View File
@@ -0,0 +1,201 @@
# De SocketServer installeren als Windows-service
`MijnBesluit.SocketServer.exe` is twee dingen tegelijk, net als je in WinDev gewend bent:
* start **Windows** hem, dan draait de server;
* start **jij** hem, dan verschijnt een beheerschermpje.
Eén exe om uit te leveren dus, en geen los installatieprogramma.
## Bouwen
```powershell
D:\MijnBesluit\tools\SocketServer-publiceren.ps1
```
Dat levert `D:\publish\mijnbesluit-socketserver.zip`. Of met de hand:
```
dotnet publish src\MijnBesluit.SocketServer -c Release -r win-x64 --self-contained false -o D:\MijnBesluit\publish
```
Het project staat vast op `x64`; er komt dus geen 32-bits versie uit, ook niet op een machine
waar dat de standaard zou zijn. Wil je dat er op de doelmachine helemaal geen .NET geïnstalleerd
hoeft te zijn, gebruik dan `--self-contained true`; de map wordt dan een stuk groter maar draait
op zichzelf.
## Op mijnbesluit.nl, naast de website
De SocketServer draait náást IIS op dezelfde machine en praat met dezelfde database. Drie dingen
moeten daarbij kloppen, anders krijgt de app straks een lege of onleesbare kluis te zien.
**1. Dezelfde geheimen.** `appsettings.json` wijst met `ExtraConfigFile` naar
`C:\MijnBesluitData\appsettings.secrets.json` — precies het bestand dat de website al gebruikt.
Eén plek dus voor de databasegegevens en de SMTP-instellingen, en ze overleven een nieuwe
publicatie. Ontbreekt dat bestand, dan stopt de service meteen met een duidelijke melding in
plaats van een onbegrijpelijke "Access denied" van de database.
**2. Hetzelfde sleutelbestand.** `Security:MasterKeyFile` staat op
`C:\MijnBesluitData\keys\masterkey.json`, hetzelfde bestand als de website gebruikt. Met een
ánder sleutelbestand is geen enkel kluisitem te openen dat via de website is opgeslagen. Ook
`Storage:Path` moet dezelfde map zijn.
Het serviceaccount **LocalSystem** moet dat sleutelbestand kunnen lezen.
**3. Een certificaat.** Zonder certificaat luistert de server onversleuteld, en daar gaan
wachtwoorden en pincodes overheen. Het certificaat van mijnbesluit.nl uit IIS is te hergebruiken:
De snelste weg is IIS vragen welk certificaat hij voor de site gebruikt:
```powershell
Import-Module WebAdministration
Get-ChildItem IIS:\SslBindings | Select-Object IPAddress, Port, Host, Thumbprint, Store
```
Staat er geen regel met `mijnbesluit.nl`, dan gebruikt die site de regel met IP-adres
`0.0.0.0` — dat is het certificaat voor alle sites zonder eigen regel.
Om te zien welke namen zo'n certificaat dekt:
```powershell
Get-ChildItem Cert:\LocalMachine\WebHosting |
Select-Object Subject, @{n='Namen';e={$_.DnsNameList -join ', '}}, Thumbprint, NotAfter, HasPrivateKey
```
Zet de vingerafdruk in `Socket:CertificateThumbprint`. De server kijkt in de opslagen **My** én
**WebHosting**; die laatste is waar hostingpanelen en Let's Encrypt-tools hun certificaten
neerzetten. Spaties maken niet uit. Let op de
vervaldatum: wordt het certificaat vernieuwd, dan verandert de vingerafdruk en moet deze
instelling mee — een herstart van de service is dan nodig.
**Poort 5200 open.** In de firewall van de server:
```powershell
New-NetFirewallRule -DisplayName "MijnBesluit SocketServer" -Direction Inbound `
-Protocol TCP -LocalPort 5200 -Action Allow
```
En bij je hoster, als daar nog een firewall voor staat. Controleer het daarna vanaf je eigen pc
met `Test-NetConnection mijnbesluit.nl -Port 5200`.
## Installeren
1. Klik met de **rechtermuisknop** op `MijnBesluit.SocketServer.exe` en kies
**Als administrator uitvoeren**.
2. Het schermpje verschijnt met de status *Niet geïnstalleerd*.
3. Vul het poortnummer in (standaard 5200). Onder het veld zie je meteen of die poort vrij is.
4. Klik op **Install**.
Wat er dan gebeurt:
* het poortnummer wordt in `appsettings.json` naast de exe gezet — dat bestand leest de service
straks zelf;
* de service wordt aangemaakt onder de naam `MijnBesluitSocket`, met weergavenaam
*MijnBesluit SocketServer*, opstarttype **Automatisch** en account **LocalSystem**;
* Windows krijgt de opdracht de service zelf opnieuw te starten als hij onverwacht stopt: na
10 seconden, dan na 30, en daarna elke 2 minuten;
* de service wordt meteen gestart.
## Verwijderen
Rechtermuisknop → **Als administrator uitvoeren**. Het schermpje laat nu *Draait* of
*Geïnstalleerd, gestopt* zien, en de knop heet **UnInstall**. Er wordt eerst gevraagd of je het
zeker weet; daarna wordt de service gestopt en verwijderd.
Zolang de service bestaat kun je de poort niet wijzigen — dat staat er ook bij. Wil je een andere
poort: verwijderen, poort aanpassen, opnieuw installeren.
## Zonder beheerdersrechten
Start je de exe gewoon met een dubbelklik, dan verschijnt hetzelfde schermpje, maar zijn de
knoppen uitgeschakeld en staat er een knop **Opnieuw starten als administrator**. Die start
dezelfde exe opnieuw met de UAC-vraag. Klik je die weg, dan gebeurt er niets.
Het programma vraagt dus niet uit zichzelf om rechten bij het starten: alleen als je iets wilt
doen waarvoor ze nodig zijn.
## Waar de service zijn spullen zoekt
Windows start een service met `C:\Windows\System32` als werkmap. Alle relatieve paden in
`appsettings.json` worden daarom omgezet naar paden **naast de exe**:
| Instelling | Standaard | Wordt |
|---|---|---|
| `Security:MasterKeyFile` | `keys/masterkey.json` | `<publish-map>\keys\masterkey.json` |
| `Storage:Path` | `kluisbestanden` | `<publish-map>\kluisbestanden` |
| `Smtp:DropFolder` | `mail-uitgaand` | `<publish-map>\mail-uitgaand` |
Zet je er een volledig pad neer (`D:\MijnBesluit\keys\masterkey.json`), dan wordt dat gebruikt
zoals het er staat. Voor het sleutelbestand is dat vaak handiger: dan overleeft het een nieuwe
publish.
Het serviceaccount **LocalSystem** moet het sleutelbestand kunnen lezen en in de map met
kluisbestanden kunnen schrijven.
## Hoe de server met verbindingen omgaat
* Eén thread accepteert verbindingen en doet verder niets.
* Elke verbinding krijgt zijn **eigen thread**, met een blokkerende leeslus. Die thread heet
`MijnBesluit-verbinding-<nummer>`, dus in een dump of in de debugger zie je meteen welke
verbinding waar hangt.
* Elk binnengekomen verzoek krijgt een **eigen thread**. De leeslus gaat meteen door met lezen,
dus een trage databaseactie houdt de volgende verzoeken van diezelfde app niet op. Per
verbinding lopen er maximaal `Socket:MaxVerzoekenPerVerbinding` tegelijk (standaard 8) en over
de hele server `Socket:MaxVerzoekenTotaal` (standaard 200); daarboven wordt er van die
verbinding even niet gelezen, wat vanzelf tegendruk geeft.
Bewust een echte thread en niet de threadpool: de afhandeling blokkeert op de database en op
bestanden, en dat zou de pool uithongeren — die groeit maar langzaam, waardoor verzoeken
alsnog achter elkaar zouden gaan lopen in plaats van naast elkaar. Een verzoekthread krijgt
512 kB stapelruimte (`Socket` hoeft daar niets voor te weten; zie `RequestThreadStackBytes`).
* Het wegschrijven van antwoorden is geserialiseerd: er kunnen nooit twee antwoorden door elkaar
over de lijn gaan. Antwoorden kunnen wél in een andere volgorde terugkomen dan de verzoeken
binnenkwamen — de app koppelt ze op `requestId`.
* Bij afsluiten wacht de server tot de lopende verzoeken van een verbinding klaar zijn, zodat er
niet naar een al opgeruimde verbinding wordt geschreven.
Omdat elk verzoek op zijn eigen thread loopt, krijgt het ook zijn eigen databasecontext: een
EF Core-context is niet bedoeld om door meerdere threads tegelijk gebruikt te worden.
## Instellingen
```json
"Socket": {
"Address": "0.0.0.0",
"Port": 5200,
"MaxVerbindingen": 500,
"MaxVerzoekenPerVerbinding": 8,
"MaxVerzoekenTotaal": 200,
"CertificateFile": "D:\\MijnBesluit\\certificaat.pfx",
"CertificatePassword": "...",
"CertificateThumbprint": ""
}
```
`CertificateThumbprint` pakt een certificaat uit de machine-certificaatopslag van Windows —
handig als je er al een hebt dat automatisch vernieuwd wordt. Spaties in de vingerafdruk maken
niet uit; die worden eruit gehaald.
## Als er iets misgaat
* **Je wilt meekijken terwijl hij draait.** Stop de service en start dezelfde exe zelf met
`--service`:
```
net stop MijnBesluitSocket
cd C:\MijnBesluitSocket
MijnBesluit.SocketServer.exe --service
```
Er verschijnt dan een venster met het logboek erin; daar komt ook langs waarom een verzoek
misging (`Fout bij '<actie>': …`). Afsluiten met Ctrl+C, en daarna `net start MijnBesluitSocket`
niet vergeten. Liever een bestand? Zet er `> log.txt 2>&1` achter; dan blijft het venster weg
en vult dat bestand zich terwijl hij draait.
* **De service start niet.** Kijk in *Logboeken → Windows-logboeken → Toepassing*. De meest
voorkomende oorzaak is dat de poort al bezet is; dat staat er dan letterlijk.
* **"De service is verwijderd maar Windows is er nog niet mee klaar."** Er staat nog een venster
*Services* open met de service geselecteerd. Sluit dat en probeer het over een halve minuut
opnieuw.
* **Het schermpje zegt dat de poort bezet is.** Met `netstat -ano | findstr :5200` zie je welk
proces erop luistert.
+176
View File
@@ -0,0 +1,176 @@
# Het socketprotocol van MijnBesluit
De Android- en iOS-app praten met `MijnBesluit.SocketServer` over een eigen TCP-verbinding.
Geen IIS, geen webservice.
## De vorm van een bericht
```
[4 bytes lengte, big-endian][UTF-8 JSON]
```
De lengte staat er dus vóór. De ontvanger weet daardoor exact wanneer een bericht af is.
> **Waarom niet de `**BYE**`-markering uit de RenH-socketserver?**
> Omdat TCP geen berichtgrenzen kent. Wie in de datastroom naar een markering zoekt, loopt vast
> zodra die tekencombinatie toevallig in de inhoud voorkomt — precies het probleem dat in
> `RHSocketClient` op 10-09-2026 is gerepareerd, toen een leesgrens midden in een groot antwoord
> op een fragment "BYE" viel. Met een lengte vooraan kan dat niet gebeuren, en het scheelt ook
> het base64-coderen van alles wat er over de lijn gaat. `SelfCheck` bevat een controle die
> precies dit randgeval nabootst: een bericht met `**BYE**` en `BYE` in de inhoud, 400 kB groot,
> dat ongeschonden aankomt.
## Eén verbinding, meerdere verzoeken
`RHSocketClient` zet per aanroep een nieuwe verbinding op. Hier blijft één verbinding open:
- verzoeken mogen elkaar overlappen; antwoorden worden teruggekoppeld op `requestId`;
- de server kan uit zichzelf een bericht sturen (een bericht dat binnenkomt, een
overlijdensmelding over jouw profiel) — de app herkent dat aan het ontbreken van een
bijbehorend openstaand verzoek;
- er is niet bij elk verzoek opnieuw een TCP- en TLS-handdruk nodig.
## Verzoek
```json
{
"requestId": "8f2c...",
"action": "vault.list",
"token": "<sessietoken>",
"version": 1,
"device": "Pixel 8",
"payload": { "category": 3 }
}
```
## Antwoord
```json
{
"requestId": "8f2c...",
"status": "Ok",
"message": null,
"payload": [ { "id": 12, "title": "Bank", "category": 3 } ]
}
```
`status` is `"Ok"` of `"Error"` — dezelfde opzet als je RenH-socketserver, zodat het vertrouwd
aanvoelt. Bij `"Error"` staat in `message` een tekst die je zo aan de gebruiker kunt tonen.
## Acties
| Actie | Sessie nodig | Wat het doet |
|---|---|---|
| `ping` | nee | Controle of de server leeft; meldt ook of de verbinding versleuteld is |
| `auth.request-code` | nee | Stuurt een inlogcode naar een bestaand account |
| `auth.signin` | nee | Inloggen met e-mailadres en code; geeft een sessietoken |
| `auth.request-register-code` | nee | Stuurt een code om een account aan te maken; naam gaat mee |
| `auth.register` | nee | Maakt het account aan met de ontvangen code |
| `auth.accept-invitation` | nee | Uitnodiging aannemen en het profiel claimen (geen code nodig) |
| `auth.signout` | ja | Sessie beëindigen |
| `family.members` | ja | Familieleden met hun eigendomsstatus |
| `family.add-member` | ja | Iemand toevoegen |
| `family.invite` | ja | Uitnodiging per e-mail versturen |
| `vault.list` | ja | Titels en categorieën (geen inhoud) |
| `vault.open` | ja | Eén item ontsleutelen |
| `vault.save` | ja | Item versleuteld opslaan |
| `message.inbox` / `message.outbox` | ja | Berichten voor en door jou |
| `message.read` | ja | Bericht lezen en als gelezen markeren |
| `message.compose` | ja | Bericht klaarzetten |
| `legacy.declare-death` | ja | Overlijden melden |
| `legacy.object` | ja | Een melding over jezelf tegenspreken |
| `legacy.status` | ja | Stand van de procedure |
| `home.advertentie` | ja | De advertentie voor het overzicht, of leeg. Het opvragen telt de vertoning mee |
| `legacy.appoint-manager` | ja | Nalatenschapsbeheerder aanwijzen |
| `legacy.remove-manager` | ja | Een beheerder weer weghalen |
Een actie die een sessie vereist en die zonder geldig token binnenkomt, krijgt altijd:
*Je sessie is verlopen. Log opnieuw in.*, met `"code": "sessie-verlopen"` erbij.
Dat `code`-veld is er voor de gevallen waarin de app iets anders moet doen dan de melding tonen.
Bij `sessie-verlopen` gooit de client het bewaarde token weg en stuurt de schil je naar het
inlogscherm. Op de tekst van de melding sturen zou ook werken, tot iemand er een komma in zet.
De app krijgt `sessie-verlopen` alleen als de server de sessie echt niet meer kent. Een mislukt
verzoek is iets anders: geen bereik, een server die net opstart. Dan blijft het token staan en
probeert de app het de volgende keer gewoon opnieuw. Dat onderscheid is het verschil tussen een
hik en een e-mail met een code.
## Gebruik vanuit de app
`MijnBesluit.Protocol` heeft geen enkel extern package nodig en kan daardoor rechtstreeks in het
MAUI-project worden opgenomen.
```csharp
var verbinding = new MijnBesluitSocketClient("socket.mijnbesluit.nl", 5200, useTls: true);
verbinding.PushReceived += melding =>
Console.WriteLine($"Van de server: {melding.Message}");
// Stap 1: code aanvragen. Die komt per e-mail binnen.
await verbinding.SendAsync(SocketActions.RequestLoginCode, new { email = "jan@example.nl" });
// Stap 2: inloggen met de code die de gebruiker heeft ingetypt.
var inloggen = await verbinding.SendAsync(SocketActions.SignIn,
new { email = "jan@example.nl", code = ingevuldeCode });
if (inloggen.Status == "Ok")
{
verbinding.Token = /* token uit inloggen.Payload */;
var kluis = await verbinding.SendAsync(SocketActions.VaultList);
}
```
Tijdens ontwikkelen mag `AcceptAnyCertificate = true` om met een zelfondertekend certificaat te
werken. Zet dat in een uitgeleverde app nooit aan: dan is TLS zijn waarde kwijt.
## Threads
De server accepteert verbindingen op één thread, geeft elke verbinding een eigen thread met een
blokkerende leeslus, en handelt elk binnengekomen verzoek af op zijn eigen thread. Antwoorden
kunnen daardoor in een andere volgorde terugkomen dan de verzoeken binnenkwamen; daar is
`requestId` voor. Het wegschrijven is geserialiseerd, dus twee antwoorden raken nooit door elkaar.
Zie `docs/SocketServer-installeren.md` voor de instellingen en de grenzen.
## Sessies
Een sessie leeft veertien dagen en schuift op bij elk verzoek. Ze staan in het geheugen én in de
tabel `sessies` (`db/16_sessies.sql`).
Het geheugen is er voor de snelheid: een gewoon verzoek raakt geen database. De tabel is er om
een herstart te overleven. Dat laatste is geen luxe: de app blijft aangemeld en gaat daarna open
met een vingerafdruk of de code van de telefoon. Stonden de sessies alleen in het geheugen, dan
was elke uitrol, elke Windows-update 's nachts en elke reboot genoeg om iedereen weer een code
per e-mail te laten opvragen - het toestel wist dan nog wie je was, de server niet meer. De
database wordt alleen geraadpleegd bij een token dat niet in het geheugen staat, dus in de
praktijk bij het eerste verzoek van elk toestel na een herstart.
In de tabel staat de SHA-256 van het token, nooit het token zelf: uit een hash valt geen token
terug te rekenen. Dezelfde afspraak als bij de inlogcodes.
De vervaltijd schuift op bij gebruik, maar wordt niet bij elk verzoek weggeschreven - zie
`SessionStore.SchuifopDrempel`. Bij een termijn van veertien dagen maakt een uur speling niets
uit, en het scheelt een schrijfactie per aanroep.
Gaat de database niet, dan komt die fout naar buiten als een gewone fout en niet als
"je sessie is verlopen". Ook dat is het verschil tussen een hik en een e-mail met een code.
De tabel `refresh_tokens` uit het oorspronkelijke schema wordt nergens gebruikt; `sessies` is
niet hetzelfde (die heeft de persoon erbij, en er is hier geen apart toegangstoken).
## Poort en certificaat
Standaard poort 5200. In te stellen in `appsettings.json`:
```json
"Socket": {
"Address": "0.0.0.0",
"Port": 5200,
"CertificateFile": "D:\\MijnBesluit\\certificaat.pfx",
"CertificatePassword": "..."
}
```
Of met de vingerafdruk van een certificaat uit de Windows-certificaatopslag
(`CertificateThumbprint`), bijvoorbeeld een certificaat dat je al met Let's Encrypt vernieuwt.
@@ -0,0 +1,118 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/Contents 11 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
7 0 obj
<<
/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
8 0 obj
<<
/PageMode /UseNone /Pages 10 0 R /Type /Catalog
>>
endobj
9 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260918054241+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260918054241+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (Stappenplan: Google Play en Apple App Store \204 MijnBesluit) /Trapped /False
>>
endobj
10 0 obj
<<
/Count 3 /Kids [ 5 0 R 6 0 R 7 0 R ] /Type /Pages
>>
endobj
11 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1726
>>
stream
Gatm;=``:f&:XAWkXUdC'9-nkc)tlgQ#!^A2Q9==18'I@F:>2P.fT,<NJ'QW!?9c:+O?bGSj'mfRZiF9Dh$q1dFEnV^Z37LRf"H!17'>[k`<j)Ii!LEe$k_efb(-t$Q+s&#k)2i3UK%?:1uYM:!<r]a)e1Q/oW-ZLmd<8@?]N)0Ya69HQi"I+>TuPNP5Zq;CnfIFXa@-1g/8f-&lj3Jl7*YF%A$P%huW%SK$h+Q.]K-)]b<(h,_%83e?`(*3.KOZW\,GZH=(ce("'nHEVIS8c"m4?R-#j>cf]s03oN.HX0DqBA3OM3,S0`E5BE4jW(#4+ESWaVo,(@Tmtp+'^Z2e\UMK60FBPUkk`s%^@*&R]Ap2i]tUtf^;ZVn!8J*53uC*9aKjRt?V(Dke>69iH&_\RjD9RkeFC'E()3enTg\#UZU1#g29f[+MDo(C>([Z9pTd8,a8_17N3C8jPNT7,Y4$KNd9>keqk]f9l=qWg9']q&";\=kQ\W)mL.9W(p0FqAD#/b2>>\<+%5otqi4(6+MuTfO6H:S?cFqUERIs;h=g`D1eAeoJV%?4FJ[hZ`=%oGAc/![dXB%6>c3-P(rEK0W^pU+Ub4H%A[4F7ORF+p\>*^ngfZ5hMg&3W,+$:aRJj-N"KDWk2%HDRl\h)i=X*FL#W"O[#6JprY3O\Z9,trjCO\=?q+bi":]a2oYT.fY]bOGdT\aIAliipf*ZFFC/6<4pei[*2>oTI`C\8tgin1DT4G5rD%60<+E_0g]U[(CC$$X[1'7u3]YZMZ;-nm0,f?F]$6Vq9FASHj\"r=I>cS`1u=OBRJlmNfDAE:hE9V9'oe-DF>%W0J?\q-5i^TL:AeONZjCOp2ou$7D_UHPT5>1VG]>A<JnV:odmH!']2ZlmH-'PX']<2ukD,FkS!*#65c4!8UE2-QIcOGbS^7ZQ6rYK9\!6KOb"mhnmB0\O_cAla&lIT\aT>L'uN%Pi[-t4k4Suq3H*(B+&EFZ_iOU4(c(W*3mfTGL!p_eQQV9FF(Jia/qMXWWF/FMV3QDf1'X16s?)4h?o.P88h'2D,g4d<'`$5F"1@gcG_lR1!4T<JVALMfLL/5FMTODdLPAa<]2GpT-m!r_ZN\VP^Bj6,6,1tr1j2?)6?tP>/\IupeP%2EM:Nc;p/u(X'-2lD*U8#AQj>/*BX/K=@<J!PcSPn;9B9pE)BaWV@fH+-4&WFNTm!N9[A/?@t%KJ.PojBQW_N'<3^>Hm$LYdPNRj%/_AYOMQ1q.!a%$r?+luCW+D\r-1RlcC,)n*R7MjOFV8Sa>R(/5XVX0fn_WLcD_`"SPMH_iWL2Y].T>9F$)ZL)XSH.6WMJBE"pkFD$om9:^`BhV[L3OtfESF699ddC0*&g!=nMm2b>Xr9o?`Kh]F57)k:k3cbqVT7EqJF.Y[iL#,quFZ&r->tB[gDnUOE71BX!"ahqB(2`=<m8;pp;geOG<Ri2i'OTh?qW4Y&9hN\%:*1sP31a^Yb)UhNpj'ItQ2M[.-)5$4Qb\dZdRN^\,.Q3!ScA"&7"3c7tM`,?l1:fZAdR8*uMl#hbfW&$F\BAMVcJ\ohe&Dh9>f\q)Pd6_#@gj:_3h%Z^nX[/t-dfgTmRhbY<^.mqX'k1)Vqg+OmI*9TS7B1:;k&\)5EL7m^VD_jiTd5LFFVrk)lEj^\E#JetkQhd^-Xut/KhZ@gI;o(m!ncDNY;MiZDG=@4I=iPaO6IjT0)c*pfgmL~>endstream
endobj
12 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2270
>>
stream
Gatm<gN)%.&q0LUoYXXf1*n[772I;Y&';a]4<#Z>OcjNTq#qrc)E(0+#YI_eom7UQ-tsqe^;MKo"EA4IDO`n@97;1mq]069YG_$jX\5fedlFsi'@?@RSPDu[3H:OS6dHX)?KfgN0QPrf(nr+P*T-jYj,a(dk`O=9HiCgY1-*1<DEH+!VAZ9#a;saaT#VLd?55mY$/[S=F=HI[UHVC)T08t62,/R1"9-7%O:hG!bD;^Wr4oS$nO+Ja<V7B)-["5'Gj(-qIY4ln_,T^4OX+>RX\@oR%)nC%@_>CF5d0age10l<ASIBV6.Wj7DuFkCcFLO3?V?S8<97LNT2j?N1OH%sK5SH%9'4ZQ5E\$4a_D8Wa6;_V<^iu<*U/f+0a&dXQ84F?qG\"KUpq+*<L\-DT:UNTQ'9-g?iU"'cFt^;EXiqNb6lSoT$lbO-)M<L![t0&b*kdRk#t5SQSK`,VA6i:(r_lH34e_^\.JJ<X=DkaU!s#%^#I#AXNn3K:K9%.rqt'h^_la8nSm[rjS/QGMp@h[,oi+-E!EI,G,Z8\KFj[Rp;:3h@&qm%]O*<F*,P9dh/b3pF!!-'47jT!0!p(\KgBZkWkS.!9`h^1/[9PIZiW3WUY8jaL!RMj,A;qO[6rSP*J+Jb@=O+0BhTOk*>cEFMS1_6f9Z7uBV.?@E0iVuHVm<%F73UWd`WG0-;0BhALS81pepF@alBf#`S$/\]6jNXP-1$#B6oF.G)PjMD8m+n<AL]AEFr*W.)RTMNc,#m#u<\r:%.%SpAK=Ln\*;N1Vt*APc688k=)A6;Ij(V`4AW1Y)K=/4ul_1IgJL%?J"*ULHb!*,qgh4aloafLe[da1b`+b*#+%(Frf5;g\3Is;@s#uQKrWYf92q=ho7^@.do,MF7YYJDe'=_XlR\Sb_3DeFPZ:9IsK[Z:\:"R@"3IYTu7g2-MgNCqK!5Lg-heNM#8pS^d49tk]0)g(IFBdBl8T5^p2.RU-O+pk;nO-%=qjqc1GJ1aAU156s$*bk;8<u$onmE7.b3Qckg7/,)(VN`(KXuY:>V+aq1lO%$).)ff0QsD9>2>A$7$_=E[@bmk:<<2cEIWY`IBMl)[]1/BG@k_nTb/iFeS54Bo:08'Wq\X]NgT&5ZX.".0%/\riB1`Z$fudN\6>U)Uk;]PSA],r:Ojq4JCs<:mm\B);J(`_>m[QuPXpT=)^H_m'ZJ7CtVn,LV,2W)a1Z6fT2r2C_q,!_iB@iN1R+j(ai,4D"W6XD#C/e_4_#\4JQR8E[+2,b;Vo51PST$4e)pfib;=G<$US.H:a:AgNPgDA>[Nei.,,.!Wd"k=TUnP$bEB>i"L&2fPDS]"u9ik7)oAZMU_*Gg:P0*;7`^T'@S8'HY0/)kq]#;>0)\Pcs?>\VN=+::^hXnB&g]pg>c\LP3R?W:M!\*B:9$:kE"ZHo+[`$pf1J>UEZmf/'C2TV7)/@UZF$l7,2.KZpB9du-U;Ah$g2pH"e[AP6T2`VTak3EWCT?#AMlk*;rERGeE\S0Ct1Ep.GbGnbmR'.N*F`V\-<:YFHJbNcZ&G2s#f>]FfGSRJ0ub!kk6<jpI"TgI/V3Ijn1be]g(6$asel(,K]A8*iGYe.q?CZh5HkQ<Y_`WU#d$l-r"!$53r5\LID8Aue9,81I([6O#G)*[-QeP'84r!&3N9L`&A<ejNL"96t#c8Z57kW@hP3:L5OqQ<'Rl*#:Yc@As_!@OXMi&cPkEfu>OYO*cNX,;gP'ojcu<@G#NpcPi^`Po7m[VT$=\?60^X9`Pcr]G?Ki.[X>J\qqO_sNZH0i.2JH3d@$Y]B*\"YA`8Mm(J6[1h0n9OMW&iS"N$^;Ou7Dc.'L"NTSZNNj/.*fLptJa9QO>^P`SN8%EmcAq;N@kL)#>o^nRR,#W&k>`41KHX!gQ+E!X-;s/(I,La7Sn699_t'hm2HKs-4DK&LNUgSCJJTq2[1q$_@m^@ek%;N`>X-gd7"MWW(j[DV0uFS)'UUT9Z%q1MSYYgfXZc((0^g>',r_dI;_8sVge'E#5l-$F96%#U@lG'F/aDt4!CJee*a2]Zs%;?g3oTBM,L&`LT72u,;4ZiHq7U15h]DqEG3]tO*;RUS:)7t5DA$j"fUQ`Ee8m&jO"4^cVA;'_^91&jVC5U?6EFo+fCBA?imF0N)YruR3k^o2rFb4Yh,HVYA"jST67Z&AeJm`Q+u%'OmBAbHdjD)`PmU8]C#%u-[SO#*#\V`Bo;(PM;Tp9B?FFY,\pnL4D[q7Y$KcS:g?ds0Zpd6%npftTN[4~>endstream
endobj
13 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 969
>>
stream
Gas1]bEC=.&A70Vjqt$bCKZ+,K7]qm[Yi+o>;!3EnLCsn,:hH!Y@hAN-OU<%-'"dhmN-(G*!1H]oNUtgD[O"bjhO53p:qmg6#d.uMUhG95=_RObE@NKOJIKu4NT%5Q<BeJJnA.,`Bg'rbOU0a_)<h2o7@+>=Kjat!k*Pg&b[kZ_1YEH^PRT"3;e]#(:#_p*-%Ps^dK1)&I6=0s1m7\`'cC4M_bi0Y_\&hoH8D$dNfUO<!W/hN\@6,3D6(0J]FAUju+JC-G5FiES1QI`?QRsACmN07k$#0BP7i5TJUc1%.;XR-k>FGB"U"fepR2nOE-;'V8X3mNR2'IO/a4k_+mu@:?`)HS1cMj+8"EoTi7``$QFtiYhlL#AVKX4m05&nUQle^<FsG3(qkL6EMc]HVi5sfCe7/FV$$DT*_:r("rNo*K%pAsOSc:E.Yj8_du[*^0m]"r3P$9Dg8j"_@1#5]&uF*.(A&4:T(Gp(<5Jk)is=0;<\;hDJJki3j!,_u<r-chDM2_AKMih`28*4MC(g8k%VT'9LE6#A"t-G]%A$BPg<>Xn+R,Z#>4h#4(h4T)@1Z6^mbPO2l\9S1rWfWbq^p>UfAoTOVH1sY\QbhDk]husqs=],WO/jM"!.KWID,69/YXQNWHLN)"Kkeh>CgO01GfCe*HWQpPYmF,ndnD=]epfk-2MI'Y`k?Dh[Jjq+;s,G4l-Shl$N2t"]u\\j'oRu@QHaGSZes"^KLo79F"XM=!>liI9At_5"\/-jC(f5P0:i6NrXWOF)]Rn$_nYnWq&)=lE:hH$SKl6YKrZ$S7NSo0#=:?3VY;AJq)d%55SU/53J#'-BBQ9a=_Cn-;ho5PJ=@lCXfO?<h*(=[Ah.bo$*Io_tn[6j1G_@IWW*L-.EUlSMXI%qEq)#p_1dfduCuql_3/.5)9XmIg46r@-mAR4t)OsgVSeNBBc8e`\\^Qk>)$OZYs7E$PnngO8~>endstream
endobj
xref
0 14
0000000000 65535 f
0000000061 00000 n
0000000112 00000 n
0000000219 00000 n
0000000331 00000 n
0000000436 00000 n
0000000641 00000 n
0000000846 00000 n
0000001051 00000 n
0000001120 00000 n
0000001447 00000 n
0000001519 00000 n
0000003337 00000 n
0000005699 00000 n
trailer
<<
/ID
[<8caba89788e8ba064a7d9a534fafdf0e><8caba89788e8ba064a7d9a534fafdf0e>]
% ReportLab generated PDF document -- digest (opensource)
/Info 9 0 R
/Root 8 0 R
/Size 14
>>
startxref
6759
%%EOF
+105
View File
@@ -0,0 +1,105 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 5 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Symbol /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
>>
endobj
6 0 obj
<<
/Contents 11 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
7 0 obj
<<
/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
8 0 obj
<<
/PageMode /UseNone /Pages 10 0 R /Type /Catalog
>>
endobj
9 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260918051107+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260918051107+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (Testscenario: overlijden melden \204 MijnBesluit) /Trapped /False
>>
endobj
10 0 obj
<<
/Count 2 /Kids [ 6 0 R 7 0 R ] /Type /Pages
>>
endobj
11 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2101
>>
stream
Gb!;d?$"^h'Rf.GgddkXZ;t=goV#dHD'tl"P;Gu)_TRNH=<o.A#rAF,s5%fu+:08S[c<cLN%kNNpO8Z[mTB\GGR3nb[O/R,e)bM"KL=c[$.&0Ur/Q4Hp\sT;_ju?g"X@Y*B]osY0a^BPLWc!!I`QOsgDr:bfqII&0'/!4p(T)7j#'Lij;8R.pDjEg'tPRtq.`=rYq!n*VG`iJlj[O8.So[d;CS_JJsQi`YA!ik?Crj&Q_3d2o$e=):J]aXm#EbpR(*;]AU1f?%\$OD(jJE!=K)7+YL72d,[4R&`!M/tmZaFY*m<4fgSSOZI9`^FWRb"5P@H\jdH(U\FI\YXJp)D<dBg]P-NlO;LS<l<ics:Q97Qrt9?dr^nq)u=L*KG[6D*`$4p[]l'Iu9]96a19qHmc).n+.fkliZh[/>5lJ`kinJWVfH7I]UefTp<q+G):dL94NqA36Xq@nukebn@#-d@Ko">*2Ed.<Eod);e*nLL!UXJY]Y4+K94`cga\[2!cSgMr(>`Y,!9Z%04S34g-?qa$fR'3d*-u>fHpUP\itKat=bHqpG*451\>lm>p'"ZL)taaS+dpV@iA:d@PCa9V6[u;O5K$;Pp]cA?dnhdcmJ^aVAn/rPIVoiP2AGmpMkhq3l/BVZec5AET8$<aOOHm0:9iPWS/;.h-_?Y;^EIP@ns='&]K4<AJ-AA/eI0@^^sO%[.uF,"mGt[Zm8A81+;QLMm'!Q.l5UnUn.OLMO[?4-5j-9J4*t8GF6D^tI1)*5f'V3$i'he&uX`<A"K/ii'M_Ff%i>)FE/W(eNQ*fs5.MAQ^'S>mk%YaJ5-M[FZmV%%M_>`OH"Hg2%Z75;P@-XY:$b`#Cng..c@.j2AES0VtGoG`sYH^ZsKaccWGk#c7aMfgrHk(J!?Da5'S)3Y]sUEJ3Ze7s8j==eZ,Yl%QGLh4p`27sfQ+aO#3+H[aACJu3i_2/=0mC8k/BBFq-/%#K_9g>Ji@!?&t$hl8OVaB!J1[0N?Fa*ZZ\YuX:6`K`kNi*K\#fQ*0h5ktlV1Boq]=*#t[0%<Y`:I=1eYZJr:Z7Ioe37&7+k`Psj6/L"++K!B,F_=fpcPrYuXX00g]OA".8],K5iBf,m9VV,jqK-(CGsS3s3ZGjTq*.g\38^n?DDGcQ".UbK@Qc5=2u<Q;QrjgQ^u^\]HA3bYB\+&4.Ug:;iu:A(cn6(RFU-sE%(8H]*QSf'eWO79ce^SlY'Q%<7+:dAQe'YDj.Og:[6(l=0'FQXC"./ccCfpK<-+g;cP%-ejX+_[-D"DNBGr)K67O9W^q:VH^4LY-OXcVN*tDMBa."Gd4p\k;oBqNE54G.pq-UG7bd0\lf@;\^5nS<NrTso8*U)d=l9-Q!imaDC\bKdk,G/GX%%oOWMK?I7Pc(:R_>EQ+eD,6qRZ>'rKTarcVb=Na:UA$:\^^YgH7?2qY\!jLF2^#I(`7;NWB;eoj$9M6[U@JQ0:j[rXdrp\$+2dIDOOY^:99/.>Y<B1"3;`UP"PLrMh?[hE!Bdig&u-Z^/f)Lft+V""qBuKclT&GT?n7s.!W6Ld-*^D*:U]"*-`iBf21YJ<joPW$@Ld[:3E!T"8UuO"h5TD)X.3hDIcp\_\b)53a^r5+3o]ODJ&`i#FeoY"3Ta`2K0jtee/A8+?8h@O*NW^7I^tS%+ni#s.ltRd,-iA)QnjO.CY#qeHVu\c9\n<mIT6ilVphXNLq*p1WHu'CNMcE[j$2:[iuBEa0"7]ErR"^mX?sYq_*QU]BALXE]KKP$7,]WWQ]n'**9S\Lb%):hs6Tt^T(E'A`UjZDG[q+*f-#^/p+)D-O?enGE$aBV81V@XR_+T4?B]Jb$<\/g$Dk+biDPC:>u'\"#)D/%K.A;9Ea6K9^ik$L1(S98TM`3UIcQOYh^b^A3:MQ(YoA0aYFDYMJU"2&6)u=a[OV%@NNWb^4=EKTK1USF?=IEQ_3CjMA?B)-EJgNC+Gn@`XR!%[*;bP-THcGfjO2j_&PE7c-;DgU".pdVQj7BnNkeCM:t;=RP,%a,:A'JHS&f^0bEN*\^K[G%RX:NL-$qf"$0tk5$k\2_EfBh)KiDsf8jWSmPUoh-\4I#)GGYG;=B!bfI/o~>endstream
endobj
12 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 420
>>
stream
GasbV;,;fu'SYH?(&*h.dnseOokhn%`,'G)5457jQ$#E]V/r'>hL[^'jM?r+h"7,?pY6HMj?MH!AI1'P0YYGTA)OP@#pR1JE6En.!-cRB7Oa65L+HmAZ]o`Bn2)];c@Y3Rkgr1P-mKR-5W#5]_1#1!OsTUF+./?j!qBen)>-E!.h<`Aa3b(I2ic3eOpH:=3,+h"iija=,$l;;h`](ZP$tRTKG1n=b3$,fRi<1Fhg$1:[Y5+&1K.9i#OTlK^Fur.:0.Y%Pl8s,eS)+++\uCsRCbOJ=GK&07o\3PKeO]PG#Frkm!5bgRU9?fAkEiUWgOK;4)!k;rV3ZsdLAdg4loXk*egg+j`pHqbeN@7>P[4M;ONM&q2f/N!bMAn\=DZ^.YTeEq,$i([`M_>k$\'R6pDgo&=^]?_&%PWZ1e~>endstream
endobj
xref
0 13
0000000000 65535 f
0000000061 00000 n
0000000122 00000 n
0000000229 00000 n
0000000341 00000 n
0000000418 00000 n
0000000523 00000 n
0000000728 00000 n
0000000933 00000 n
0000001002 00000 n
0000001317 00000 n
0000001383 00000 n
0000003576 00000 n
trailer
<<
/ID
[<d96d84fe3f7af5feff08db6aa9f36b11><d96d84fe3f7af5feff08db6aa9f36b11>]
% ReportLab generated PDF document -- digest (opensource)
/Info 9 0 R
/Root 8 0 R
/Size 13
>>
startxref
4087
%%EOF
+253
View File
@@ -0,0 +1,253 @@
# Uitrollen met een venster
Twee programma's: eentje op je ontwikkelmachine met vinkjes en een knop, en eentje op de server
die het pakket aanneemt en installeert.
Ze vervangen de PowerShell-scripts niet — ze roepen ze aan. `Publiceren.ps1`, `Uitrollen.ps1` en
`SocketServer-uitrollen.ps1` doen dit werk al maanden, inclusief het bewaren van de vorige versie
en het terugzetten daarvan als er iets misgaat. Die logica nog een keer in C# schrijven zou
betekenen dat er twee versies zijn van iets dat maar op één manier goed is.
## Drie werkwijzen
De agent kan het op drie manieren, en welke je nodig hebt hangt af van hoe je bij de server kunt.
**Wachten op een opdracht — dit is meestal wat je wilt.** Je start de agent op de server, kiest
"wachten", en laat dat venster staan. Vanaf dat moment doe je alles op je eigen pc: vinkjes
aanzetten, op **Bouwen en uitrollen** drukken, en meekijken. Het uitrolvenster zet de pakketten in
je publicatiemap en legt er een briefje bij (`uitrol-opdracht.json`); de agent ziet dat briefje
via de doorgegeven schijf van je externe-bureaubladverbinding, controleert per pakket de hash, en
rolt uit in de vaste volgorde. Wat hij doet schrijft hij terug in `uitrol-log.txt` in diezelfde
map, en het venster op je pc leest dat regel voor regel mee.
Geen poort, geen certificaat, geen gedeeld geheim — en op de server hoef je niets in te tikken.
Met een VPN werkt dezelfde weg, alleen wijst `Opdrachtmap` dan naar een gedeelde map op je pc in
plaats van naar `\\tsclient`.
Het blijft met opzet een apart briefje en niet "er staat een nieuwe zip, ga je gang": anders rolt
de agent uit zodra je om een heel andere reden een keer bouwt. Een opdracht hoort te ontstaan
doordat iemand op een knop drukt.
**Zelf uitrollen, zonder netwerk.** Je staat al op de server — via RDP of aan de console — en het
pakket staat ergens waar die server bij kan, bijvoorbeeld op de schijf die je met je
externe-bureaubladverbinding hebt doorgegeven. Start de agent, kies "een pakket uitrollen dat op
deze server staat", en geef het pad op:
```
\\tsclient\D\publish\mijnbesluit.zip
```
Hier komt geen poort aan te pas, geen certificaat en geen gedeeld geheim. Dat is geen gat in de
beveiliging: dat je op deze server bent ingelogd ís de toegangscontrole. Wie zover komt, heeft
geen pakket nodig om schade aan te richten.
**Op afstand, met een druk op de knop.** Je pc stuurt het pakket naar de agent en die rolt het
uit. Dat vraagt wél een poort, en daarmee alles hieronder: TLS, een handtekening en een firewall.
> **Let op: RDP is geen tunnel.** Je ziet het bureaublad van de server, maar je pc kan daarmee
> niet bij een poort op die server. De luisterstand heeft dus alleen zin als je de server echt
> over een netwerk kunt bereiken — een VPN. Zolang dat er niet is, gebruik je de eerste werkwijze:
> bouwen op je pc, het pakket over de doorgegeven schijf, en uitrollen op de server.
Het uitrolvenster op je pc is in beide gevallen nuttig: bouwen en inpakken doet het altijd. Alleen
het versturen werkt pas als de luisterstand kan.
## Waar je je bewust van moet zijn
Dit gaat over de luisterstand. Rol je uit terwijl je zelf op de server staat, dan is er niets van
dit alles nodig.
**Dit is een kanaal waarmee code op je server terechtkomt.** Dat is niet een bijwerking maar het
doel, en het is dezelfde server waar de kluizen en de masterkey staan. Wie bij dit kanaal kan,
kan alles wat de server kan.
Daarom zitten er vier grendels op, en die zijn geen van alle optioneel:
1. **De poort staat alleen open op het besloten netwerk.** De agent luistert wel op alles, maar de
firewall van de server hoort alles te weigeren behalve het VPN-bereik. Dit is geen poort voor
het open internet.
2. **TLS**, met hetzelfde certificaat als de SocketServer. Zonder certificaat weigert de agent te
starten.
3. **Een handtekening per opdracht**, met een gedeeld geheim. TLS zegt dat niemand meeleest, niet
wie er aanbelt — en op een besloten netwerk zit meer dan alleen jouw pc. In de handtekening
zitten de soort, de hash van het pakket, een tijdstip en een eenmalig nummer. Een opgevangen
opdracht is dus na twee minuten niets meer waard, kan geen tweede keer, en past niet op een
ander pakket.
4. **Een vaste lijst met wat de agent mag**: de site uitrollen, de SocketServer uitrollen, de
databasescripts draaien. Er zit geen "voer dit uit" bij. Een agent die opdrachten van buiten
uitvoert is een achterdeur met een nette naam.
En hij draait alleen als jij hem start. Geen Windows-service: een poort die dag en nacht openstaat
voor iets wat je een paar keer per week doet, staat vooral open als je er niet bent.
Vijf zelfcontroles bewaken de handtekening: verkeerd geheim, te kort geheim, een ander pakket
onder dezelfde handtekening, dezelfde opdracht twee keer, en een opdracht die te oud is.
## Eenmalig inrichten
Stap 1 en de firewallregel zijn alleen nodig voor de luisterstand. Rol je uit terwijl je zelf op
de server staat, dan volstaan stap 2 (zonder `Geheim` en zonder certificaat) en stap 3.
### 1. Een geheim maken — alleen voor de luisterstand
Op de server, in de map waar de agent staat:
```
MijnBesluit.Uitrolagent.exe --nieuwe-sleutel
```
Zet de uitkomst in `uitrolagent.json` **op de server** en in `uitroller.json` **op je pc**. Niet in
de projectmap, niet in een e-mail, niet in een schermafbeelding.
### 2. De agent neerzetten
Publiceer `src\MijnBesluit.Agentvenster` en zet de uitkomst op de server, bijvoorbeeld in
`C:\Beheer\uitrolagent`. Dat is het venster; de agent zelf komt als projectverwijzing mee, dus in
die ene map staan daarna allebei:
| Bestand | Waarvoor |
|---|---|
| `MijnBesluit.Agentvenster.exe` | Het venster. Dubbelklikken, hij staat te wachten. Dit is wat je dagelijks gebruikt. |
| `MijnBesluit.Uitrolagent.exe` | Hetzelfde werk zonder venster, plus `--nieuwe-sleutel`, `--zip` en `--luister` |
Vul `uitrolagent.json` in:
| Instelling | Wat erin hoort |
|---|---|
| `Geheim` | De sleutel uit stap 1 |
| `Poort` | 5210, of iets anders dat vrij is |
| `CertificateThumbprint` | Hetzelfde certificaat als de SocketServer gebruikt |
| `Scriptmap` | Waar `Uitrollen.ps1` en `SocketServer-uitrollen.ps1` staan, bijvoorbeeld `C:\Beheer` |
| `Werkmap` | Waar binnengekomen pakketten landen |
| `Opdrachtmap` | Waar de agent naar een briefje van je pc kijkt als hij staat te wachten. Over RDP: `\\tsclient\D\publish` |
| `DbGebruiker` / `DbWachtwoord` | Alleen nodig voor databasescripts. Neem een eigen account dat mag wijzigen; niet root als het niet hoeft |
> **Let op bij opnieuw publiceren.** Een publicatie zet `uitrolagent.json` terug op de versie uit de
> projectmap — met lege velden. Alles wat je niet elke keer opnieuw wilt intikken hoort daarom in
> `C:\MijnBesluitData\uitrolagent.secrets.json`. Dat bestand gaat **per sleutel** over
> `uitrolagent.json` heen, dus je zet er alleen in wat afwijkt: `Geheim`, `MysqlExe`, `DbGebruiker`,
> `DbWachtwoord`, en `Opdrachtmap` of `Scriptmap` als die bij jou anders zijn. Het agentvenster zegt
> bij het opstarten in zijn logboek welke twee bestanden hij heeft gelezen.
Zet de firewall goed: poort 5210 alleen vanaf het VPN-bereik.
### 3. Het venster neerzetten
Publiceer `src\MijnBesluit.Uitroller` en vul `uitroller.json` in: de server, de poort, hetzelfde
geheim, en waar je projectmap staat.
## De vinkjes staan er al
Bij het openen kijkt het venster wat er sinds de vorige uitrol is gewijzigd, en zet de vinkjes
daarop. Het zegt er ook bij waarom: *"De website: Home.razor is gewijzigd."*
Waarom dat de moeite is: na een middag werken weet niemand meer precies wat hij heeft aangeraakt.
Dan vink je te veel aan — kost tijd — of te weinig, en dan draait er een website tegen een
SocketServer die iets anders verwacht. Dat laatste kost een avond zoeken naar een fout die er niet
is.
Wat waarin terechtkomt staat in `Wijzigingen.cs` en wordt door een zelfcontrole bewaakt. De
gedeelde projecten tellen mee bij allebei: een wijziging in Domain of Application zit zowel in de
website als in de SocketServer. Protocol zit in de SocketServer en in de app, maar niet in de
website — die praat niet over de socket.
Twee dingen die met opzet zo zijn:
- **Bij twijfel staat hij aan.** Is er nog nooit vanuit dit venster uitgerold, dan staat alles
aan. Te veel uitrollen kost een minuut.
- **Onthouden gebeurt op het moment van het nieuwste bestand**, niet op de klok van het moment
waarop het lukte. Wijzig je iets terwijl hij bouwt, dan zou dat laatste die wijziging als
uitgerold aanmerken terwijl hij er niet in zit — en dan staat het vinkje de volgende keer ten
onrechte uit.
Het blijft een voorstel: je kunt elk vinkje zelf aan- en uitzetten. Wat er onthouden wordt staat
in `laatste-uitrol.json` naast de exe.
## Uitrollen met een wachtende agent
1. Op de server, één keer: dubbelklik `MijnBesluit.Agentvenster.exe`. Hij staat meteen te
wachten — er is geen knop om eerst in te drukken. Laat dat venster staan zolang je werkt.
Controleer dat `Opdrachtmap` in `uitrolagent.json` naar je publicatiemap wijst; over een
externe-bureaubladverbinding is dat `\\tsclient\D\publish`.
2. Op je pc: start `MijnBesluit.Uitroller.exe`, zet de vinkjes en druk op **Bouwen en uitrollen**.
Meer is het niet. Je ziet in het venster op je eigen scherm wat er op de server gebeurt, inclusief
de regels van `Uitrollen.ps1` zelf.
### Het agentvenster
Twee vensters die op elkaar lijken, en dat is de bedoeling: op je pc staat het uitrolvenster, op de
server het agentvenster. Dezelfde regels komen op allebei de schermen voorbij.
Wat je op de server ziet:
- **Een balk bovenin** met wat hij doet: *Wacht op een opdracht van je pc* of *Bezig met uitrollen*.
Die regel is van een meter afstand te lezen, want dit venster staat op een machine waar je niet
naar zit te kijken.
- **Het logboek**, regel voor regel, inclusief de uitvoer van de PowerShell-scripts. Dat is niet
anders dan vroeger — het gaat alleen niet meer naar een consolescherm.
- **Een voortgangsbalk** die loopt zolang er iets gebeurt. Uitrollen duurt minuten; zonder teken
van leven ga je je afvragen of hij nog wel iets doet.
- **Stoppen met wachten**, als je hem wilt laten staan zonder dat hij opdrachten oppakt.
Sluiten terwijl er een uitrol loopt vraagt hij na. Een uitrol halverwege afbreken laat een website
achter die niet meer bij zijn database past, en dat merk je pas als er iemand op zit.
Er draait geen PowerShell-venster meer waar je omheen moet werken: het venster start PowerShell
zelf, vangt elke regel op en zet die in het logboek. De console-agent bestaat nog wel, en die heb
je ook nodig — voor `--nieuwe-sleutel`, voor `--zip` als je een pakket uitrolt dat al op de server
staat, en voor `--luister` zodra er een VPN is.
Valt de verbinding weg terwijl de agent bezig is, dan maakt hij zijn werk gewoon af — alleen het
meekijken stopt. Op het scherm van de server staat hetzelfde.
## Uitrollen terwijl je zelf op de server staat
1. Op je pc: start `MijnBesluit.Uitroller.exe`, zet de vinkjes en druk op **Bouwen en uitrollen**.
Kan hij de agent niet bereiken, dan staan de pakketten alsnog klaar in je publicatiemap —
bouwen en inpakken gebeurt namelijk hier.
2. Op de server: start `MijnBesluit.Uitrolagent.exe`, kies **2** en geef het pad op naar de zip op
je doorgegeven schijf.
Of, als je liever niets aanwijst, in één regel:
```
MijnBesluit.Uitrolagent.exe --zip \\tsclient\D\publish\mijnbesluit.zip --soort site
```
`--soort` is `site`, `socketserver` of `database`.
## Uitrollen op afstand (vraagt een VPN)
1. Op de server: start `MijnBesluit.Uitrolagent.exe` en kies **3**, of start hem met `--luister`.
Er verschijnt een venster dat zegt dat hij luistert. Laat het openstaan.
2. Op je pc: start `MijnBesluit.Uitroller.exe`, druk eerst op **Verbinding proberen**. Dat kost
een seconde en scheelt de teleurstelling van een kwartier bouwen voor niets.
3. Zet de vinkjes en druk op **Bouwen en uitrollen**.
**De volgorde ligt vast en is niet die van de vinkjes:** eerst de databasescripts, dan de website,
dan de SocketServer, en de APK helemaal achteraan. Een nieuwe kolom moet er zijn voordat de code
erom vraagt; andersom staat de site stuk in de tussentijd. De SocketServer is als laatste omdat
het herstarten daarvan iedereen met de app uitlogt.
Wat er op de server gebeurt lees je mee in het venster, regel voor regel. Loopt de verbinding weg
halverwege, dan gaat het uitrollen op de server gewoon door — daar staat hetzelfde in beeld.
## De databasescripts
De agent draait de scripts uit `db\` **vanaf nummer 03**, in volgorde. Nummer 01 maakt het schema
aan en 02 maakt de applicatiegebruiker met zijn wachtwoord; dat is inrichten, geen bijwerken, en
dat hoort een mens één keer met de hand te doen. Een agent die accounts kan aanmaken is een agent
die meer kan dan hij moet.
Alle scripts gaan er elke keer overheen. Dat mag: ze zijn zo geschreven dat opnieuw draaien niets
doet — kijk maar in de kop van elk script. Dat is eerlijker dan bijhouden wat er al gedraaid is;
zo'n administratie kan ernaast zitten, en dan mis je er precies één.
## Als er iets misgaat
Het uitrolscript zet bij een fout de vorige versie zelf terug en zegt dat in het logboek. Gebeurt
dat niet, dan staan de vorige versies op de server in `_vorige-versies` naast de sitemap.
De agent doet één ding tegelijk. Krijg je "er loopt al een uitrol", dan is er een eerdere poging
nog bezig of vastgelopen; kijk op het scherm van de agent.
+332
View File
@@ -0,0 +1,332 @@
# MijnBesluit uitrollen op een Windows-server met IIS
Uitgangspunt: `mijnbesluit.nl` wijst al naar de server en daar draait IIS.
> **Dit is geen webservice zoals in WinDev.** IIS doet hier alleen de voordeur: hij neemt het
> verzoek aan en geeft het door aan het proces van de applicatie. Er zit geen SOAP, geen extra
> serialisatie en geen tweede laag tussen. De website praat vanuit dat proces rechtstreeks met
> MariaDB.
## 1. Wat er op de server moet staan
**MariaDB 10.6 of nieuwer.** Haal de MSI van mariadb.org, installeer met een sterk
root-wachtwoord en laat "Enable networking" uit staan als de website op dezelfde server draait —
dan is de database alleen via localhost te bereiken en kan niemand er van buitenaf bij. Staat de
database op een andere machine, zet `bind-address` dan op het interne adres en open poort 3306
alleen voor de webserver.
**Het .NET 8 Hosting Bundle** — dus niet de SDK. Zoek op "ASP.NET Core Hosting Bundle" en neem
de 8.0-versie. Die installeert de runtime én de IIS-module die het verzoek doorgeeft. Daarna:
```
net stop was /y
net start w3svc
```
**WebSocket Protocol in IIS.** Dit wordt vaak vergeten en dan werkt de site half: pagina's laden
wel, maar knoppen doen niets. Blazor Server houdt namelijk een websocket open. Aanzetten via
Serverbeheer → Rollen en onderdelen toevoegen → Webserver (IIS) → Webserver →
Toepassingsontwikkeling → **WebSocket Protocol**.
## 2. Mappen op de server
Zet de gegevens **buiten** de map waar je publiceert. Alles in de publicatiemap wordt bij een
volgende publicatie overschreven, en juist deze drie dingen mag je nooit kwijtraken:
```
D:\MijnBesluitData\
keys\masterkey.json de sleutel van alle kluizen
dataprotection\ cookiesleutels van ASP.NET
kluisbestanden\ versleutelde bijlagen
appsettings.secrets.json connectiereeks en e-mailinstellingen
D:\Sites\mijnbesluit\ hier komt de gepubliceerde applicatie
```
## 3. Database inrichten
### 3a. MariaDB installeren (als hij er nog niet staat)
Haal de Windows-MSI van <https://mariadb.org/download/> — neem de "Stable"-versie, 64 bits.
Tijdens het installeren zijn er drie keuzes die er echt toe doen:
**Root-wachtwoord.** Bedenk een lang, willekeurig wachtwoord en zet het in je wachtwoordbeheer.
Je hebt het hierna twee keer nodig en daarna bijna nooit meer.
**"Enable networking".** Draait de website op dezelfde server, **haal dit vinkje dan weg**, of
zet daaronder het adres op `127.0.0.1`. De database is dan alleen vanaf de server zelf te
bereiken en er is van buitenaf simpelweg geen deur om op te kloppen. Dat scheelt je de hele
categorie problemen van een database die per ongeluk aan het internet hangt.
**"Use UTF8 as default server's character set".** Aanvinken. Het schema zet utf8mb4 overal zelf
al, maar dit voorkomt verrassingen bij alles wat je er later handmatig bij maakt.
De installatie zet een Windows-service neer die `MariaDB` heet en automatisch meestart. Controleer
dat even in `services.msc`.
Installeer op de server meteen ook **HeidiSQL** (zit bij de MariaDB-installatie, of los te
downloaden). Dat werkt prettiger dan de opdrachtregel, en omdat je toch op de server bent
ingelogd hoef je de database niet open te zetten.
### 3b. Database en gebruiker aanmaken
Kopieer de map `db` uit het project naar de server, bijvoorbeeld naar `D:\db`.
Pas eerst in `02_applicatiegebruiker.sql` de regel met `VERVANG_DIT_WACHTWOORD` aan. Kies een
ander wachtwoord dan op je ontwikkelmachine: raakt die ene uit, dan staat de andere er niet mee
open.
**Met HeidiSQL op de server:** verbinden als `root` op `127.0.0.1`, dan *Bestand →
SQL-bestand uitvoeren* (Ctrl+Shift+O), eerst `01_schema.sql`, daarna `02_applicatiegebruiker.sql`.
**Of vanaf de opdrachtregel**, in een **cmd**-venster (niet PowerShell, die kent `<` niet):
```
"C:\Program Files\MariaDB 11.4\bin\mysql.exe" -u root -p < D:\db\01_schema.sql
"C:\Program Files\MariaDB 11.4\bin\mysql.exe" -u root -p < D:\db\02_applicatiegebruiker.sql
```
Het versienummer in dat pad verschilt per installatie; kijk even in `C:\Program Files`.
Beide scripts mogen meerdere keren gedraaid worden; bestaande tabellen en rechten blijven staan.
### 3c. Controleren
```sql
SHOW DATABASES;
USE mijnbesluit;
SHOW TABLES; -- hoort 14 tabellen te geven
SHOW GRANTS FOR 'mijnbesluit_app'@'localhost';
```
Bij de rechten hoort `audit_log` alleen SELECT en INSERT te hebben, de andere tabellen ook UPDATE
en DELETE, en nergens CREATE, ALTER of DROP. Zo kan de applicatie het logboek wel vullen maar
niet opschonen, en kan een fout in de code nooit een tabel weggooien.
Test daarna of het applicatieaccount echt werkt, want daar loopt het straks op vast:
```
"C:\Program Files\MariaDB 11.4\bin\mysql.exe" -u mijnbesluit_app -p -D mijnbesluit -e "SELECT COUNT(*) FROM users;"
```
Komt daar `0` uit, dan klopt zowel het wachtwoord als de rechten. Dat wachtwoord zet je straks in
`appsettings.secrets.json` (stap 6).
### 3d. Vanaf je eigen pc meekijken
Zet MariaDB daarvoor **niet** open naar buiten. Twee betere manieren: werk in HeidiSQL op de
server zelf via de externe-bureaubladverbinding, of maak in HeidiSQL op je eigen pc een
verbinding van het type *SSH-tunnel* als er een SSH-server op de machine draait. Een database die
rechtstreeks aan het internet hangt wordt binnen een dag gevonden en geprobeerd.
## 4. Publiceren — in het vervolg met twee opdrachten
> Liever met vinkjes en een knop dan met twee PowerShell-vensters? Zie
> [Uitrollen met een venster](Uitrollen-met-het-venster.md). Die twee programma's roepen dezelfde
> scripts aan als hieronder staan; de scripts blijven dus de waarheid.
Net als de "publish site" uit WinDev, maar dan in twee stappen omdat de server niet bij jouw
schijven kan.
**Op je eigen pc:**
```powershell
cd D:\MijnBesluit
.\tools\Publiceren.ps1
```
Dat bouwt de Release-versie, gooit `appsettings.Development.json` eruit (die hoort niet op een
server) en maakt er `D:\publish\mijnbesluit.zip` van.
**Op de server**, in een PowerShell-venster als administrator:
```powershell
.\Uitrollen.ps1 -Zip \\tsclient\D\publish\mijnbesluit.zip
```
Dat script:
* haalt het pad en de toepassingsgroep uit IIS, dus je hoeft niets in te typen;
* waarschuwt als de toepassingsgroep niet op "Geen beheerde code" staat;
* pakt eerst helemaal uit en zet de site pas daarna een paar seconden stil;
* bewaart de vorige versie in `_vorige-versies` naast de sitemap;
* zet bij een fout de vorige versie automatisch terug;
* controleert daarna of `https://mijnbesluit.nl/inloggen` antwoordt — het leidt dat adres af uit
de bindingen van de site;
* ruimt alles op behalve de laatste vijf versies.
Kopieer `Uitrollen.ps1` één keer naar de server, bijvoorbeeld naar `C:\Beheer`. Werkt de
stationsdoorverwijzing van je externe-bureaubladverbinding niet, kopieer dan eerst het
zip-bestand naar de server en geef dat pad op:
```powershell
.\Uitrollen.ps1 -Zip D:\mijnbesluit.zip
```
Terugvallen op de vorige versie kan altijd; het script laat de precieze opdrachten zien als de
controle mislukt.
### Handmatig publiceren
Liever zelf, of eenmalig:
## 4b. Handmatig publiceren
Op je ontwikkelmachine:
```
dotnet publish src\MijnBesluit.Web -c Release -o D:\publish\mijnbesluit
```
Kopieer de inhoud van `D:\publish\mijnbesluit` naar `D:\Sites\mijnbesluit` op de server. In
Visual Studio kan het ook met Publiceren → Map, of met Web Deploy als dat op de server aanstaat.
`dotnet publish` maakt zelf een `web.config` aan met de juiste verwijzing naar de IIS-module;
daar hoef je niets aan te doen.
## 5. De site in IIS
**Toepassingsgroep** aanmaken, naam `MijnBesluit`:
* .NET CLR-versie: **Geen beheerde code**. Dat lijkt vreemd, maar klopt: de applicatie draait in
een eigen proces en IIS hoeft er geen .NET voor te laden.
* Identiteit: `ApplicationPoolIdentity` (heet dan `IIS AppPool\MijnBesluit`).
* Bij Geavanceerde instellingen: **Leegloop-time-out op 0** en **Vaste interval voor recyclen op
0**. Anders wordt de applicatie na twintig minuten zonder bezoek afgesloten en duurt het
eerste bezoek daarna onnodig lang.
**Website** aanmaken:
* Fysiek pad: `D:\Sites\mijnbesluit`
* Toepassingsgroep: `MijnBesluit`
* Binding: https op poort 443, hostnaam `mijnbesluit.nl`, met je certificaat. Doe hetzelfde voor
`www.mijnbesluit.nl` als je die gebruikt.
* Een binding op poort 80 mag, maar laat die alleen doorsturen naar https — de applicatie doet
dat zelf ook.
**Rechten.** Geef `IIS AppPool\MijnBesluit`:
* Lezen en uitvoeren op `D:\Sites\mijnbesluit`
* **Wijzigen** op `D:\MijnBesluitData` (hij moet in `dataprotection` en `kluisbestanden` kunnen
schrijven, en `keys\masterkey.json` kunnen lezen)
## 6. Instellingen op de server
In `appsettings.Production.json` (die is meegepubliceerd) staan alleen paden en de site-URL.
De geheimen komen uit het bestand waar `ExtraConfigFile` naar wijst. Maak
`D:\MijnBesluitData\appsettings.secrets.json`:
```json
{
"ConnectionStrings": {
"MijnBesluit": "Server=localhost;Port=3306;Database=mijnbesluit;User Id=mijnbesluit_app;Password=HET-WACHTWOORD;"
},
"Smtp": {
"Host": "smtp.solcon.nl",
"Port": 587,
"UseStartTls": true,
"Username": "flietstra03",
"Password": "HET-MAILWACHTWOORD",
"FromAddress": "jonathan@flietstra.nl",
"FromName": "MijnBesluit",
"WriteToDiskInsteadOfSending": false
}
}
```
Let op het afzenderadres. Een mailserver van een provider accepteert vaak alleen een afzender
die bij het account hoort: Solcon weigert `noreply@mijnbesluit.nl` met *"Verification failed"*.
Gebruik daarom voorlopig je eigen adres. Zodra mijnbesluit.nl een eigen mailbox of verzenddienst
heeft, zet je `Host`, `Username`, `Password` en `FromAddress` daarop om — dat is ook nodig voor
SPF en DKIM, anders komen uitnodigingen bij Gmail en Outlook in de spam.
Zo test je een afzenderadres los van de applicatie, op de server zelf:
```powershell
$cred = Get-Credential
Send-MailMessage -SmtpServer smtp.solcon.nl -Port 587 -UseSsl -Credential $cred `
-From "jonathan@flietstra.nl" -To "jonathan@flietstra.nl" -Subject "test" -Body "test"
```
Let op: **geen** `RedirectAllTo` hier. Die hoort alleen op je testmachine; op de server moet post
gewoon naar de echte ontvanger.
Geef dit bestand alleen leesrecht aan `IIS AppPool\MijnBesluit` en aan beheerders.
## 7. Het sleutelbestand aanmaken
Eenmalig, op de server, in een opdrachtvenster:
```
cd D:\Sites\mijnbesluit
MijnBesluit.Web.exe --maak-sleutel D:\MijnBesluitData\keys\masterkey.json
```
Hij weigert een bestaand bestand te overschrijven — met opzet, want dat zou elke bestaande kluis
in één klap onleesbaar maken.
**Maak hier meteen een back-up van, op een andere plek dan je database-back-up.** Zonder dit
bestand is niets meer te openen; mét dit bestand én een databasekopie is alles leesbaar. Die twee
horen dus niet bij elkaar te liggen.
## 8. Controleren
Ga naar `https://mijnbesluit.nl/inloggen`. Werkt dat, controleer dan ook:
* Vraag een code aan. Komt de e-mail aan, dan klopt de SMTP-instelling.
* Log in en klik door naar Kluis. Werken de knoppen, dan doet de websocket het.
* Herstart de toepassingsgroep in IIS en ververs de pagina. Blijf je ingelogd, dan staat de
sleutelring van ASP.NET goed. Word je uitgelogd, kijk dan naar `Security:DataProtectionPath`
en de schrijfrechten op die map.
## 9. Back-up
Drie dingen, en het liefst niet op dezelfde plek:
1. De database. Bijvoorbeeld dagelijks met een taak:
`mysqldump -u root -p --single-transaction mijnbesluit > D:\Backup\mijnbesluit-%DATE%.sql`
2. `D:\MijnBesluitData\kluisbestanden` — de versleutelde bijlagen.
3. `D:\MijnBesluitData\keys\masterkey.json` — **apart**, en niet in dezelfde back-upset als 1 en 2.
Beproef ook eens een keer of terugzetten werkt. Een back-up die je nooit hebt teruggezet is een
aanname, geen back-up.
## 10. Als er iets misgaat
**Kijk eerst in de log.** De applicatie schrijft zelf naar `C:\MijnBesluitData\logs`, één
bestand per dag. Daar staat de échte fout in; de bezoeker krijgt alleen een nette melding.
```powershell
Get-ChildItem C:\MijnBesluitData\logs | Sort-Object LastWriteTime -Descending |
Select-Object -First 1 | Get-Content -Tail 60
```
**"We konden de e-mail met je code niet versturen"** — de SMTP-instellingen. Ontbreekt het
blok `Smtp` in `appsettings.secrets.json`, dan probeert de applicatie `localhost:587` en is er
niets om verbinding mee te maken. Staat het blok er wel, dan zegt de log wat de mailserver
antwoordde: een time-out betekent dat de poort dichtstaat op de server, en een 5xx-antwoord dat
de mailserver de server niet als afzender accepteert.
**HTTP 500.19** — IIS kan `web.config` niet lezen. Meestal is het Hosting Bundle niet
geïnstalleerd, of is het na IIS geïnstalleerd. Installeer het (opnieuw) en herstart IIS.
**HTTP 500.30** — de applicatie start niet. Zet in `web.config` tijdelijk
`stdoutLogEnabled="true"` en kijk in de map `logs`. Negen van de tien keer is het de
connectiereeks of een ontbrekend sleutelbestand.
**HTTP 502.5** — verkeerde runtime-versie, of het Hosting Bundle ontbreekt.
**De pagina laadt maar knoppen doen niets** — WebSocket Protocol staat niet aan in IIS. Zie stap 1.
**HTTP 400 bij het insturen van een formulier** — de sleutelring van ASP.NET is weg. Zie stap 8.
**"Access denied for user"** — het wachtwoord in `appsettings.secrets.json` komt niet overeen met
dat in de database. Test het eerst met de MariaDB-client op de server zelf.
## 11. En de SocketServer?
Die heb je pas nodig zodra de Android-app er is; de website werkt er los van. Wil je hem alvast
draaien: publiceer `MijnBesluit.SocketServer`, zet hem op dezelfde server neer, gebruik hetzelfde
`D:\MijnBesluitData` en installeer hem als service met het beheerschermpje. Open dan poort 5200
in de firewall. Zie `docs/SocketServer-installeren.md`.
+72
View File
@@ -0,0 +1,72 @@
# Volgende stappen
Wat er staat, en wat er nog moet. Eerlijk overzicht.
## Werkt en is gecontroleerd
- Domein en regels: eigendom van profielen, overlijdensprocedure, vrijgave van berichten.
- Versleuteling: AES-256-GCM in drie lagen, wachtwoordhashing, tokens.
- De handelingen: registreren, inloggen, familie, uitnodigen, claimen, kluis, berichten,
nalatenschap.
- Het socketprotocol: framing, server, client, sessies, dispatcher.
- De website in Blazor: inloggen, registreren, uitnodiging aannemen, overzicht, familie, kluis,
momenten, nalatenschap.
- Databaseschema voor MariaDB.
- De SocketServer als 64-bits Windows-service, met een beheerschermpje voor Install/UnInstall,
een eigen thread per verbinding en elk verzoek op zijn eigen thread.
- Inloggen en registreren met een code per e-mail, zonder wachtwoorden.
- De app-kant: `MijnBesluit.Client` (aanroepen over de socket) en `MijnBesluit.AppUi` (de
schermen). Zie `docs/Android-app.md`.
- Zesenvijftig controles in `MijnBesluit.SelfCheck`, alle groen.
## Nog te bouwen
### 1. De Android-app afmaken
De drie projecten staan er: `MijnBesluit.Client`, `MijnBesluit.AppUi` en de schil
`MijnBesluit.App`. De eerste twee bouwen en zijn getest; de schil is alleen op een toestel te
beproeven. Hoe je hem draait staat in `docs/Android-app.md`.
De serverkant staat: de SocketServer draait op mijnbesluit.nl, poort 5200 is open, en de
verbinding loopt over TLS 1.3 met het certificaat van het domein. Database en e-mail werken er
ook overheen.
Wat nu nog moet: de app op een echt toestel beproeven (`tools\Apk-maken.ps1 -Debug`), en daarna: opnieuw verbinden als het toestel uit de slaapstand komt, biometrisch ontgrendelen
voordat de kluis opengaat, en push-meldingen (Firebase) voor een bericht dat bezorgd wordt
terwijl de app dicht is.
### 2. Bijlagen in de schermen
`VaultService.StoreFileAsync` werkt, maar er is nog geen scherm om een document, foto of video te
uploaden en terug te bekijken. Denk aan een maximumgrootte en aan streamen in plaats van het hele
bestand in het geheugen laden.
### 3. Videoberichten
`MessageKind.Video` bestaat in het model. Opnemen in de app, versleuteld opslaan als
`VaultFile`, en tonen bij het lezen van het bericht.
### 5. Twee-factor-authenticatie
Het veld staat in de database. TOTP is de logische keuze; het geheim hoort versleuteld te worden
met dezelfde envelope-opzet als de kluis.
### 6. Koppeling met de BRP
Dit vervangt op termijn de drie meldingen. Alleen de plek waar `DeathConfirmedAt` gevuld wordt
verandert; de rest van de code niet. Houd de handmatige procedure ernaast: niet elke deelnemer
komt uit de BRP.
### 7. iOS
Zodra Android draait, is dat vooral een kwestie van een tweede doelplatform in de MAUI-csproj en
een Mac om mee te bouwen.
### 8. Voordat het live gaat
- Een onafhankelijke beveiligingsaudit.
- Snelheidsbegrenzing per IP-adres op inloggen en registreren.
- Beproef het terugzetten van een back-up — inclusief het sleutelbestand.
- Verwerkersovereenkomst en privacyverklaring: dit zijn bijzondere persoonsgegevens.
- Bedenk wat er gebeurt als MijnBesluit zelf ooit stopt. Mensen vertrouwen je hun
uitvaartwensen toe; daar hoort een antwoord bij.
+215
View File
@@ -0,0 +1,215 @@
# De website lokaal testen
Drie stappen, daarna draait hij.
> **Draaide de website al met een oudere versie?** Draai dan eerst
> `db\03_inloggen_met_code.sql`: die voegt de tabel voor de inlogcodes toe en haalt de
> wachtwoordkolom weg. Op een verse database is dat niet nodig.
## 1. Database aanmaken
Het makkelijkst gaat dit **in HeidiSQL**: maak verbinding, dan *Bestand → SQL-bestand uitvoeren*
(Ctrl+Shift+O) en kies `D:\MijnBesluit\db\01_schema.sql`. Geen gedoe met paden of shells.
Wil je het toch vanaf de opdrachtregel, let dan op: **dit werkt niet in PowerShell.** PowerShell
kent het teken `<` niet en geeft *"The '<' operator is reserved for future use."* Gebruik een
**cmd**-venster:
```
mysql -u root -p < D:\MijnBesluit\db\01_schema.sql
```
Moet het per se in PowerShell, dan kan het zo:
```powershell
Get-Content D:\MijnBesluit\db\01_schema.sql | mysql -u root -p
```
Staat `mysql` niet in je pad, gebruik dan het volledige pad — bij MariaDB is dat meestal
`"C:\Program Files\MariaDB 11.4\bin\mysql.exe"`. Bij nieuwere versies heet het programma
`mariadb.exe`; dat werkt precies hetzelfde.
Daarna hetzelfde voor `02_applicatiegebruiker.sql`, nadat je daarin `VERVANG_DIT_WACHTWOORD` hebt
aangepast. Wil je voor het testen gewoon je eigen MariaDB-account gebruiken, dan kun je dat tweede
script overslaan.
Beide scripts mogen meerdere keren gedraaid worden; bestaande tabellen en rechten blijven staan.
Controleer achteraf in HeidiSQL of de database `mijnbesluit` er staat met dertien tabellen.
## 2. Wachtwoord en connectiereeks
Bedenk eerst één wachtwoord voor de databasegebruiker. Dat moet op **twee** plekken hetzelfde
staan, en dat is waar het vaak misgaat.
**In de database.** Heb je `02_applicatiegebruiker.sql` gedraaid zonder
`VERVANG_DIT_WACHTWOORD` aan te passen, dan is dat nu letterlijk het wachtwoord. Zet het goed:
```sql
ALTER USER 'mijnbesluit_app'@'localhost' IDENTIFIED BY 'HIER-JOUW-WACHTWOORD';
FLUSH PRIVILEGES;
```
**In de applicatie.** Niet in `appsettings.json` (dat bestand gaat mee de broncodebeheer in),
maar in user secrets:
```
cd D:\MijnBesluit
dotnet user-secrets set "ConnectionStrings:MijnBesluit" "Server=localhost;Port=3306;Database=mijnbesluit;User Id=mijnbesluit_app;Password=HIER-JOUW-WACHTWOORD;" --project src\MijnBesluit.Web
```
Controleer met `dotnet user-secrets list --project src\MijnBesluit.Web` wat er echt staat.
In Visual Studio kan het ook: rechtsklik op het project → *Gebruikersgeheimen beheren*.
Wil je eerst alleen zien dát alles draait, zet dan tijdelijk je eigen `root`-account in de
reeks. Weet je dan dat het werkt, schakel daarna pas over op `mijnbesluit_app`.
## 3. Starten
Zorg dat het ontwikkelcertificaat van .NET vertrouwd is (eenmalig):
```
dotnet dev-certs https --trust
```
Start dan het project:
```
dotnet run --project src\MijnBesluit.Web --launch-profile https
```
Of in Visual Studio: zet `MijnBesluit.Web` als startproject en druk op F5. Kies het profiel
**https**; het inlogcookie is met opzet als *Secure* gemarkeerd en werkt dus niet over gewoon http.
De site komt op **https://localhost:7014**.
Het sleutelbestand hoef je niet zelf aan te maken: bij de eerste start zet de website
`keys/masterkey.json` neer in de uitvoermap, omdat dat in `appsettings.Development.json` aanstaat.
Gooi die map weg en alles wat je tijdens het testen in de kluis hebt gezet, is onleesbaar — dat is
precies de bedoeling.
## Een rondje door de applicatie
1. **Registreren** op `/registreren`. Vul je naam en e-mailadres in en klik op *Code aanvragen*.
Er is geen wachtwoord: je krijgt een code van zes cijfers toegestuurd. Tijdens ontwikkelen
wordt die niet verstuurd maar als html-bestand neergezet in
`src\MijnBesluit.Web\bin\Debug\net8.0\mail-uitgaand` &mdash; open het nieuwste bestand, lees de
code en vul hem in. Daarna heb je meteen een eigen profiel en familieproject.
2. **Kluis** → *Toevoegen*. Kies bijvoorbeeld *Uitvaartwens* en zet de vrijgave op
"Na mijn overlijden". Sla op en klik daarna op *Bekijken*: de inhoud wordt ter plekke
ontsleuteld. Kijk in HeidiSQL in de tabel `vault_items`: in de kolom `ciphertext` staat
onleesbare rommel, alleen `title` en `category` zijn leesbaar.
3. **Familie** → iemand toevoegen met een e-mailadres, daarna *Uitnodiging sturen*.
Er wordt niets verstuurd tijdens ontwikkelen: de mail komt als html-bestand in
`src\MijnBesluit.Web\bin\Debug\net8.0\mail-uitgaand`. Open het nieuwste bestand in je browser
en klik op de link — of kopieer de `/uitnodiging/...`-link naar een **privévenster**.
4. **De uitnodiging aannemen** in dat privévenster (anders log je jezelf uit). Er is niets in te
vullen: de link uit de e-mail is zelf al het bewijs dat je bij dat adres kunt. Ga daarna terug
naar je eerste venster, ververs *Familie*: het profiel staat nu op **Geclaimd** en je kunt er
niets meer aan wijzigen.
5. **Momenten** → *Nieuw bericht* voor die persoon, met "Pas als ik er niet meer ben". In het
privévenster zie je bij Momenten nog niets staan — zo hoort het.
6. **Nalatenschap** → wijs die persoon aan als beheerder. Zet het aantal meldingen op 1 en de
bezwaartermijn op 0 dagen om de procedure te kunnen doorlopen zonder drie accounts.
Meld jezelf vanuit het privévenster als overleden, en kijk wat er gebeurt.
Voor stap 6 heb je geen achtergrondtaak nodig zolang het aantal meldingen gehaald wordt en de
bezwaartermijn 0 is; de bevestiging gebeurt dan meteen. Staat er wél een termijn, dan is het de
achtergrondtaak in de SocketServer die dat later afrondt.
## Als er iets niet werkt
**"The '<' operator is reserved for future use"** — je zit in PowerShell. Zie stap 1.
**"'mysql' is not recognized as an internal or external command"** — het programma staat niet in
je pad. Gebruik het volledige pad naar `mysql.exe` of `mariadb.exe`, of draai het script in
HeidiSQL.
**Je krijgt geen e-mail met de code** — tijdens ontwikkelen wordt er niets verstuurd. Kijk in
`src\MijnBesluit.Web\bin\Debug\net8.0\mail-uitgaand`; daar staat de mail als html-bestand.
Wil je wel echt versturen, zet dan `Smtp:WriteToDiskInsteadOfSending` op `false` en vul de
SMTP-gegevens in (zie hieronder).
**"Deze code is verlopen of al gebruikt"** — een code werkt één keer en 15 minuten. Vraag een
nieuwe aan met de knop onder het codeveld.
**"Access denied for user 'root'@'localhost'"** — verkeerd wachtwoord. Let op: achter `-p` komt
géén spatie als je het wachtwoord meteen meegeeft; laat `-p` liever leeg, dan wordt er om
gevraagd.
**Je krijgt geen e-mail met de code** — tijdens ontwikkelen wordt er niets verstuurd. Kijk in
`src\MijnBesluit.Web\bin\Debug\net8.0\mail-uitgaand`; daar staat de mail als html-bestand.
Wil je wel echt versturen, zet dan `Smtp:WriteToDiskInsteadOfSending` op `false` en vul de
SMTP-gegevens in (zie hieronder).
**"Deze code is verlopen of al gebruikt"** — een code werkt één keer en 15 minuten. Vraag een
nieuwe aan met de knop onder het codeveld.
**"Access denied for user 'mijnbesluit_app'@'localhost' (using password: YES)"** — het
wachtwoord in user secrets is niet hetzelfde als dat in de database. Zie stap 2. Het ligt niet
aan `localhost` versus `127.0.0.1`: een account dat als `@localhost` is aangemaakt, werkt ook
over TCP. Test het account eerst in HeidiSQL met precies hetzelfde wachtwoord.
**"Unable to connect to any of the specified MySQL hosts"** — MariaDB draait niet, of de
connectiereeks klopt niet. Test de reeks eerst in HeidiSQL met hetzelfde account.
**"Geen connectiereeks 'MijnBesluit' gevonden"** — de user secret staat op het verkeerde project.
Controleer met `dotnet user-secrets list --project src\MijnBesluit.Web`.
**Je wordt na het inloggen meteen weer uitgelogd** — je draait over http in plaats van https.
Gebruik het profiel *https*.
**Waarschuwing over het certificaat in de browser** — `dotnet dev-certs https --trust` nog niet
gedraaid, of de browser moet opnieuw opgestart worden.
**Je ziet je eigen wijziging niet** — Blazor Server houdt een verbinding open. Ververs met
Ctrl+F5 als een scherm blijft hangen.
## De app erbij
De website heeft de SocketServer niet nodig; die is alleen voor de Android/iOS-app. Wil je beide
tegelijk draaien, start het SocketServer-project dan met het argument `--service`, anders krijg je
het installatieschermpje in plaats van de server. Zie `docs/SocketServer-installeren.md`.
## Echte e-mail versturen
Tijdens ontwikkelen worden mails weggeschreven naar een map. Wil je ze echt versturen, zet dan de
SMTP-gegevens in user secrets &mdash; niet in `appsettings.json`, want dat bestand gaat mee de
broncodebeheer in:
```
cd D:\MijnBesluit
dotnet user-secrets set "Smtp:Host" "mail.jouwprovider.nl" --project src\MijnBesluit.Web
dotnet user-secrets set "Smtp:Port" "587" --project src\MijnBesluit.Web
dotnet user-secrets set "Smtp:UseStartTls" "true" --project src\MijnBesluit.Web
dotnet user-secrets set "Smtp:Username" "noreply@mijnbesluit.nl" --project src\MijnBesluit.Web
dotnet user-secrets set "Smtp:Password" "HIER-HET-WACHTWOORD" --project src\MijnBesluit.Web
dotnet user-secrets set "Smtp:FromAddress" "noreply@mijnbesluit.nl" --project src\MijnBesluit.Web
dotnet user-secrets set "Smtp:FromName" "MijnBesluit" --project src\MijnBesluit.Web
dotnet user-secrets set "Smtp:WriteToDiskInsteadOfSending" "false" --project src\MijnBesluit.Web
```
### Veilig oefenen met echte adressen
Zodra je echt verstuurt, komen uitnodigingen ook echt aan. Wil je met de namen en adressen van je
eigen familie oefenen zonder ze lastig te vallen, zet dan:
```
dotnet user-secrets set "Smtp:RedirectAllTo" "jouw@eigenadres.nl" --project src\MijnBesluit.Web
```
Alle uitgaande post gaat dan naar dat ene adres. Het oorspronkelijke adres staat in het onderwerp
(`[TEST aan eva@example.nl] ...`) en bovenaan de mail, dus je ziet precies wie hem gekregen zou
hebben. Leeglaten &mdash; of de instelling verwijderen &mdash; en het gaat weer gewoon naar de
echte ontvanger.
Dezelfde regels gelden voor `--project src\MijnBesluit.SocketServer`; die verstuurt ook mail
(uitnodigingen, bezorgde berichten, meldingen over de nalatenschap).
Voor de SocketServer als Windows-service werkt user secrets niet &mdash; die draait onder
LocalSystem. Zet de instellingen daar in `appsettings.json` naast de exe, of beter: in
omgevingsvariabelen (`Smtp__Password`), zodat het wachtwoord niet in een bestand staat dat
meegekopieerd wordt.
Wil je later van provider wisselen, dan is dit de enige plek die verandert; in de code staat
nergens een mailserver.
+72
View File
@@ -0,0 +1,72 @@
# De iOS-app
De app voor iPhone en iPad is dezelfde app als die voor Android. `MijnBesluit.AppUi` (alle
schermen), `MijnBesluit.Client` en het socketprotocol zijn gedeeld; op iOS draait diezelfde HTML
in een WKWebView in plaats van een Android WebView. Wat verschilt is alleen wat het toestel zelf
doet.
Dit document gaat over dat verschil, en over wat Apple wil.
## Wat er klaarstaat
- `net10.0-ios` staat in `MijnBesluit.App.csproj`, maar **standaard uit**. Aanzetten met
`-p:MetIos=true`, of door `MetIos` blijvend te zetten op de machine die aan een Mac hangt.
Dat moet wel zo: staat de iOS-doelomgeving er onvoorwaardelijk in, dan wil ook een build van
alleen de Android-APK de iOS-werkbelasting en een Mac hebben, en struikelt het maken van de
APK over iets wat er niets mee te maken heeft.
- `Platforms/iOS/` heeft `AppDelegate.cs`, `Program.cs` en `Info.plist`.
- `Toestelslot` heeft een iOS-tak: Face ID of Touch ID via LocalAuthentication, met de code van
de telefoon als terugval.
- De gebruikstoelichtingen staan in `Info.plist`.
## Wat er nog moet
**Een Mac.** Apple's bouwgereedschap draait alleen op macOS. Visual Studio op Windows verbindt
er over het netwerk mee ("Pair to Mac", over SSH) en laat de Mac compileren en ondertekenen. Op
die Mac moet Xcode staan, één keer met de hand geopend zijn, en Remote Login aan. Staat er geen
Mac, haal `net10.0-ios` dan tijdelijk uit `TargetFrameworks`; Android blijft dan gewoon bouwen.
**Het Apple Developer Program**, $99 per jaar. Als organisatie heb je nodig: een echte
rechtspersoon, een D-U-N-S-nummer daarvoor, een e-mailadres op je eigen domein en een werkende
website op dat domein. Dat D-U-N-S-nummer heb je voor Google Play ook nodig - vraag het één keer
aan.
**Meldingen via APNs.** `Toestelmeldingen` geeft op iOS nu null terug: geen kenmerk, dus geen
meldingen, en verder werkt alles. Dat komt erbij zodra het account er is, want het hangt eraan
vast: een APNs-sleutel, de Push Notifications-capability, een provisioning profile en een
`Entitlements.plist`.
## Twee vallen waar je vroeg in loopt
**Ontbrekende gebruikstoelichting = de app sluit.** Vraag je om Face ID zonder
`NSFaceIDUsageDescription` in `Info.plist`, dan sluit iOS de app ter plekke af. Geen foutmelding,
geen crashmelding die ergens op wijst - hij is gewoon weg. Hetzelfde geldt voor camera, microfoon
en fotobibliotheek. Ze staan er nu allemaal in; komt er ooit iets bij wat het toestel moet
vrijgeven, dan hoort de tekst er in dezelfde wijziging bij te staan. Een zelfcontrole bewaakt dat.
**De socket op poort 5200.** Dat is een eigen TLS-verbinding en geen URL-verkeer, dus App
Transport Security gaat er niet over en je hebt er géén ATS-uitzondering voor nodig. Een
uitzondering toevoegen die je niet nodig hebt levert alleen vragen op bij de beoordeling. Maar
probeer die verbinding wel vroeg op een echt toestel, en ook over 4G en niet alleen over wifi.
## Wat Apple bij het inzenden vraagt
- **Account verwijderen in de app.** Verplicht. Staat er al.
- **App Privacy** - hetzelfde soort formulier als Databeveiliging bij Google. De
privacyverklaring staat in `Privacytekst.razor`.
- **Exportverklaring** (`ITSAppUsesNonExemptEncryption`). Deze app versleutelt een kluis, dus dat
antwoord is niet vanzelfsprekend "nee" zoals bij een app die alleen HTTPS gebruikt. De sleutel
staat bewust NIET in `Info.plist`: dan vraagt App Store Connect het bij elke inzending en
beantwoord je het bewust. Zie de toelichting in dat bestand.
- **Sign in with Apple** is NIET verplicht. Dat geldt alleen als je inloggen via Google of
Facebook aanbiedt; MijnBesluit doet e-mail met een code.
## De volgorde
1. D-U-N-S aanvragen voor de B.V. (kan weken duren, en Google Play wacht er ook op)
2. Apple Developer Program, als organisatie
3. Mac regelen, Xcode erop, Pair to Mac aan de praat
4. Bouwen en op een echt toestel zetten
5. Uitproberen: inloggen met een code, de socket, de kluis, het slot met Face ID
6. Meldingen via APNs
7. Inzenden