Eerste commit
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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>?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
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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` — 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 — 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 — of de instelling verwijderen — 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 — 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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user