site: full Astro source + blog posts, Gitea Actions deploy to CT148
Replaces the placeholder repo with the asger.weirsoe.dk site source. Adds 6 blog posts (Proxmox, Authentik/OIDC, NPMplus migration, JS injection, ah-fail) and .gitea/workflows/deploy.yml (bun build + rsync over SSH). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 847 KiB |
@@ -0,0 +1,15 @@
|
||||
---
|
||||
import { SOCIALS, SITE } from '../consts';
|
||||
const year = new Date().getFullYear();
|
||||
---
|
||||
<footer class="mt-24 border-t border-border/70">
|
||||
<div class="mx-auto flex max-w-3xl flex-col items-center gap-3 px-5 py-10 text-sm text-muted sm:flex-row sm:justify-between">
|
||||
<p>© {year} {SITE.name}</p>
|
||||
<div class="flex gap-4">
|
||||
{SOCIALS.map((s) => (
|
||||
<a class="transition-colors hover:text-accent" href={s.href} rel="me noopener" target="_blank">{s.label}</a>
|
||||
))}
|
||||
<a class="transition-colors hover:text-accent" href="/rss.xml">RSS</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
import { SITE } from '../consts';
|
||||
import { baseGraph } from '../seo';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
type?: 'website' | 'article';
|
||||
publishedTime?: string;
|
||||
modifiedTime?: string;
|
||||
tags?: string[];
|
||||
jsonLd?: unknown[];
|
||||
}
|
||||
const {
|
||||
title, description, image, type = 'website',
|
||||
publishedTime, modifiedTime, tags = [], jsonLd = [],
|
||||
} = Astro.props;
|
||||
|
||||
const pageTitle = title ? `${title} · ${SITE.name}` : `${SITE.title} — Technical Solution Architect & Datalog`;
|
||||
const desc = description ?? SITE.description;
|
||||
const canonical = new URL(Astro.url.pathname, SITE.url).href;
|
||||
const ogImage = new URL(image ?? '/og.png', SITE.url).href;
|
||||
const graphs = [baseGraph, ...jsonLd];
|
||||
---
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={desc} />
|
||||
<meta name="author" content="Asger Weirsøe" />
|
||||
<meta name="google-site-verification" content="l5LfnJZFSkVCauhwN9pND_gvxdnZJbx3FM6fpEK8nzE" />
|
||||
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
|
||||
<link rel="canonical" href={canonical} />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:site_name" content={SITE.name} />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={desc} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:locale" content="da_DK" />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content={pageTitle} />
|
||||
{type === 'article' && publishedTime && <meta property="article:published_time" content={publishedTime} />}
|
||||
{type === 'article' && modifiedTime && <meta property="article:modified_time" content={modifiedTime} />}
|
||||
{type === 'article' && <meta property="article:author" content="Asger Weirsøe" />}
|
||||
{type === 'article' && tags.map((t) => <meta property="article:tag" content={t} />)}
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={pageTitle} />
|
||||
<meta name="twitter:description" content={desc} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
|
||||
<meta name="theme-color" content="#0b0f17" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#f6f8fb" media="(prefers-color-scheme: light)" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<link rel="alternate" type="application/rss+xml" title={SITE.name} href="/rss.xml" />
|
||||
|
||||
{graphs.map((g) => <script type="application/ld+json" set:html={JSON.stringify(g)} />)}
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
import { NAV, SITE } from '../consts';
|
||||
const path = Astro.url.pathname.replace(/\/$/, '') || '/';
|
||||
const isActive = (href: string) => (href === '/' ? path === '/' : path.startsWith(href));
|
||||
---
|
||||
<header class="sticky top-0 z-50 border-b border-border/70 backdrop-blur-md bg-bg/70">
|
||||
<nav class="mx-auto flex max-w-3xl items-center justify-between px-5 py-3.5">
|
||||
<a href="/" class="font-bold tracking-tight text-lg">{SITE.name}<span class="text-accent">.</span></a>
|
||||
<div class="flex items-center gap-1">
|
||||
<ul class="flex items-center gap-1 text-sm">
|
||||
{NAV.map((item) => (
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
class:list={[
|
||||
'rounded-lg px-3 py-1.5 transition-colors hover:text-text',
|
||||
isActive(item.href) ? 'text-text bg-surface-2' : 'text-muted',
|
||||
]}
|
||||
>{item.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" data-theme-toggle class="theme-toggle ml-1" title="Skift mellem lyst og mørkt tema" aria-label="Skift tema">
|
||||
<svg class="sun-and-moon" aria-hidden="true" width="22" height="22" viewBox="0 0 24 24">
|
||||
<mask class="moon" id="moon-mask">
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="white" />
|
||||
<circle cx="24" cy="10" r="6" fill="black" />
|
||||
</mask>
|
||||
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
|
||||
<g class="sun-beams" stroke="currentColor">
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
interface Props { href: string; title: string; date: Date; description?: string; tags?: string[]; }
|
||||
const { href, title, date, description, tags = [] } = Astro.props;
|
||||
const d = date.toLocaleDateString('da-DK', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
---
|
||||
<article class="group rounded-2xl border border-border bg-surface/60 p-5 transition-colors hover:border-accent/50">
|
||||
<a href={href} class="block">
|
||||
<div class="text-xs text-muted">{d}</div>
|
||||
<h3 class="mt-1 text-lg font-semibold tracking-tight group-hover:text-accent">{title}</h3>
|
||||
{description && <p class="mt-2 text-sm text-muted">{description}</p>}
|
||||
</a>
|
||||
{tags.length > 0 && (
|
||||
<div class="mt-3 flex flex-wrap gap-1.5">
|
||||
{tags.map((t) => (
|
||||
<a href={`/tags/${t}`} class="rounded-full border border-border px-2.5 py-0.5 text-xs text-muted transition-colors hover:border-accent hover:text-accent">#{t}</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
@@ -0,0 +1,31 @@
|
||||
export const SITE = {
|
||||
name: 'Asger Weirsøe',
|
||||
title: 'Asger Weirsøe',
|
||||
description: 'Asger Weirsøe — Technical Solution Architect hos Digisense. Peppol, NemHandel, e-faktura/elektronisk fakturering, selvhosting, Proxmox og AI.',
|
||||
url: 'https://asger.xn--weirse-fya.dk',
|
||||
lang: 'da',
|
||||
email: 'asger@weircon.dk',
|
||||
location: 'Nørresundby, Danmark',
|
||||
giteaUser: 'agw',
|
||||
giteaBase: 'https://gitea.weircon.dk',
|
||||
};
|
||||
|
||||
export const NAV = [
|
||||
{ href: '/', label: 'Forside' },
|
||||
{ href: '/cv', label: 'CV' },
|
||||
{ href: '/projekter', label: 'Projekter' },
|
||||
{ href: '/blog', label: 'Blog' },
|
||||
];
|
||||
|
||||
export const SOCIALS = [
|
||||
{ href: 'https://gitea.weircon.dk/agw', label: 'Gitea' },
|
||||
{ href: 'https://www.linkedin.com/in/ag-weirs%C3%B8e/', label: 'LinkedIn' },
|
||||
{ href: 'mailto:asger@weircon.dk', label: 'Email' },
|
||||
];
|
||||
|
||||
// Styr hvad der vises på /projekter.
|
||||
// hide: repo-navne der skjules helt. pinned: vises først (i den rækkefølge).
|
||||
export const PROJECTS = {
|
||||
hide: [] as string[],
|
||||
pinned: [] as string[],
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
import { glob } from 'astro/loaders';
|
||||
|
||||
const blog = defineCollection({
|
||||
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
|
||||
schema: () =>
|
||||
z.object({
|
||||
title: z.string(),
|
||||
description: z.string().default(''),
|
||||
date: z.coerce.date(),
|
||||
updated: z.coerce.date().optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
draft: z.boolean().default(false),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { blog };
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
title: "Ah ah ah, you didn't say the magic word.. en skærmlås-prank i Rust"
|
||||
description: "Hvorfor jeg droppede ANSI C til fordel for Rust, og hvordan du installerer ah-fail på Wayland, X11 og macOS."
|
||||
date: 2026-06-11
|
||||
tags: [rust, pam, gtklock, wayland, linux, macos]
|
||||
draft: false
|
||||
---
|
||||
|
||||
Et af mine mere fjollede sideprojekter: **ah-fail**. Taster du dit kodeord forkert
|
||||
på låseskærmen, popper der en loopende animation op af **mit eget ansigt
|
||||
photoshoppet ind på Dennis Nedrys krop** fra Jurassic Park, mens "ah ah ah, you
|
||||
didn't say the magic word" spiller i fuld lydstyrke. Hvert forkert gæt tilføjer en
|
||||
ny sprite på en tilfældig position på skærmen. Lydstyrken tvinges til 100 % på
|
||||
første fejl og sættes tilbage, når du endelig låser op.
|
||||
|
||||
Det er en prank. Men vejen dertil lærte mig en del om, hvor følsomt
|
||||
authentication-laget på et system egentlig er.
|
||||
|
||||
## Hvorfor jeg skrottede ANSI C
|
||||
|
||||
Jeg startede projektet i **ANSI C**. Det giver god mening på papiret: det her hooker
|
||||
ind i **PAM**, og PAM er skrevet i C. Men der er et problem.. PAM sidder **direkte i
|
||||
authentication-kritiske sti**. Min kode bliver indlæst ind i selve den proces, der
|
||||
afgør, om du må komme ind på maskinen.
|
||||
|
||||
Og med manuel hukommelsesstyring blev det hurtigt for risikabelt. Jeg jagtede
|
||||
use-after-free og små lækager i en kontekst, hvor en enkelt fejl i værste fald
|
||||
**låser dig ude af dit eget system** eller svækker login. En segfault i et lille
|
||||
hobby-PAM-modul er ikke bare en crash.. det er en crash i din låseskærm.
|
||||
|
||||
Så jeg skrev det hele om til **Rust**. Hukommelsessikkerheden er ikke et nice-to-have
|
||||
her, den er hele pointen: jeg vil have compileren til at fange den slags fejl, før de
|
||||
nogensinde rammer en authentication-proces. Resten af projektet blev til en **Cargo
|
||||
workspace**, bygget med **Meson**, og koden, der rører PAM, blev holdt så minimal som
|
||||
overhovedet muligt.
|
||||
|
||||
## Sådan hænger det sammen
|
||||
|
||||
Integrationen afhænger af din display-server, og det er her, det bliver lidt sjovt:
|
||||
|
||||
| Platform | Display-server | Modul | Hvordan |
|
||||
|---|---|---|---|
|
||||
| Linux | **Wayland** | `ahfail-module.so` | Indlæses direkte af `gtklock` via dens modul-API |
|
||||
| Linux | **X11** | `libahfail_pam.so` + `ahfail-display` | PAM-hook spawner display-binæren efter hver fejlet auth |
|
||||
| macOS | .. | `libahfail_pam.so` + `ahfail-display` | Samme PAM-tilgang, hooker ind i screensaver-stakken |
|
||||
|
||||
På **Wayland** kan jeg bruge gtklocks interne vindues-API og tegne sprites direkte
|
||||
oven på låseskærmen. På **X11 og macOS** findes der ikke sådan et plugin-API, så her
|
||||
gør jeg det via et **PAM-modul**: det registrerer et cleanup-callback, der fyrer ved
|
||||
hver fejlet authentication, og **double-forker** en selvstændig display-binær
|
||||
(`ahfail-display`), som kører helt uafhængigt af PAM-stakken. Det vigtigste:
|
||||
PAM-modulet **rører aldrig dine credentials**. Det reagerer kun på, at et forsøg
|
||||
fejlede.
|
||||
|
||||
Al den fælles logik (animation, lyd, deadzone, volumen) ligger i én delt crate,
|
||||
`ahfail-ui`, så gtklock-modulet og display-binæren deler præcis samme adfærd.
|
||||
|
||||
## Installation: Wayland (gtklock)
|
||||
|
||||
På Arch henter du afhængighederne og bygger med Meson:
|
||||
|
||||
```bash
|
||||
sudo pacman -S meson ninja rust gtk3 gstreamer gst-plugins-base \
|
||||
gst-plugins-good gst-plugins-bad gtklock
|
||||
meson setup builddir --prefix=/usr
|
||||
meson compile -C builddir
|
||||
sudo meson install -C builddir
|
||||
```
|
||||
|
||||
`--prefix=/usr` er vigtig.. uden den defaulter Meson til `/usr/local`, og så matcher
|
||||
den indbyggede sti i binæren ikke der, hvor filerne faktisk lander. Derefter starter
|
||||
du bare gtklock med modulet:
|
||||
|
||||
```bash
|
||||
gtklock -m /usr/lib/gtklock/ahfail-module.so
|
||||
```
|
||||
|
||||
Hold dit password-felt frit for sprites med en deadzone:
|
||||
|
||||
```bash
|
||||
gtklock -m /usr/lib/gtklock/ahfail-module.so -- --deadzone=860,440,200,200
|
||||
```
|
||||
|
||||
## Installation: X11 (i3lock, xscreensaver, ..)
|
||||
|
||||
PAM-modulet virker med enhver X11-locker, der authenticator via PAM. Du tilføjer en
|
||||
linje til lockerens PAM-service-fil (fx `/etc/pam.d/i3lock` eller
|
||||
`/etc/pam.d/xscreensaver`).
|
||||
|
||||
Her er **den fælde der kostede mig mest tid**: `ahfail`-linjen skal stå **før**
|
||||
`auth include system-auth`. Står den efter, så betyder PAM's interne flow-kontrol
|
||||
inde i `system-auth` (`pam_faillock` med `[default=die]`), at mit modul aldrig
|
||||
bliver nået ved et fejlet forsøg.. det bliver kun kaldt ved succes, hvilket er det
|
||||
stik modsatte af, hvad jeg vil.
|
||||
|
||||
Stien til modulet afhænger af distro:
|
||||
|
||||
```bash
|
||||
# Arch / standard (--prefix=/usr)
|
||||
#%PAM-1.0
|
||||
auth optional /usr/lib/ahfail/libahfail_pam.so
|
||||
auth include system-auth
|
||||
```
|
||||
|
||||
```bash
|
||||
# Debian / Ubuntu (multiarch)
|
||||
#%PAM-1.0
|
||||
auth optional /usr/lib/x86_64-linux-gnu/ahfail/libahfail_pam.so
|
||||
auth include system-auth
|
||||
```
|
||||
|
||||
```bash
|
||||
# Fedora / RHEL (multilib)
|
||||
#%PAM-1.0
|
||||
auth optional /usr/lib64/ahfail/libahfail_pam.so
|
||||
auth include system-auth
|
||||
```
|
||||
|
||||
Den fulde sti er nødvendig.. `$(libdir)/ahfail` ligger ikke i PAM's
|
||||
standard-søgesti.
|
||||
|
||||
## Installation: macOS
|
||||
|
||||
Den nemme vej er **Homebrew**:
|
||||
|
||||
```bash
|
||||
brew tap agw/ahfail https://gitea.weircon.dk/agw/homebrew-ahfail
|
||||
brew install ahfail
|
||||
```
|
||||
|
||||
Bagefter printer Homebrew den ene PAM-linje, du skal tilføje til
|
||||
`/etc/pam.d/screensaverui` (macOS 13 og nyere) eller `/etc/pam.d/screensaver`
|
||||
(macOS 12 og ældre). Den manuelle ét-linjes-rettelse er det eneste trin tilbage, og
|
||||
opgradering er bare `brew upgrade ahfail`.
|
||||
|
||||
Vil du hellere bygge fra kilde, klarer install-scriptet dependencies, build og
|
||||
PAM-konfiguration på én gang:
|
||||
|
||||
```bash
|
||||
git clone https://gitea.weircon.dk/agw/gtk-ahfail.git
|
||||
cd gtk-ahfail
|
||||
bash scripts/install-macos.sh
|
||||
```
|
||||
|
||||
Et lille Apple Silicon-gotcha jeg ramte: Homebrew bruger `/opt/homebrew` som prefix
|
||||
på ARM, men `/usr/local` på Intel. Den indbyggede default-sti skal sættes ved
|
||||
build-tid (`-Dlibdir`), ellers peger PAM-modulet på den forkerte placering på den
|
||||
ene af de to.
|
||||
|
||||
## En ærlig advarsel
|
||||
|
||||
Det her hooker ind i PAM og/eller din lockers plugin-API. **PAM sidder i
|
||||
authentication-kritiske sti**.. en fejl i modulet kan i værste fald låse dig ude
|
||||
eller svække dit login. Jeg giver ingen garantier. Installerer du det, ændrer du en
|
||||
sikkerhedskritisk komponent i dit system og påtager dig selv ansvaret. Det er
|
||||
fjollet i sin natur, men det skal behandles seriøst.
|
||||
|
||||
## Hvad der ikke virker (endnu)
|
||||
|
||||
På **swaylock** og **hyprlock** fyrer PAM-modulet fint ved fejlede forsøg, men
|
||||
animationen bliver ikke vist. Waylands session-lock-protokol
|
||||
(`ext-session-lock-v1`) tillader kun lockeren selv at tegne, så compositoren
|
||||
undertrykker min sprite, og kun lyden spiller. Fuld support kræver, at swaylock og
|
||||
hyprlock eksponerer et plugin-API ligesom gtklock. Indtil da er det Wayland via
|
||||
gtklock, eller X11.
|
||||
|
||||
## Koden
|
||||
|
||||
Hele projektet ligger offentligt på
|
||||
[gitea.weircon.dk/agw/gtk-ahfail](https://gitea.weircon.dk/agw/gtk-ahfail).
|
||||
Spriten er mit ansigt på Nedrys krop, men du kan nemt bytte den ud med dit eget
|
||||
PNG-sekvens i `assets/` og bygge om. God fornøjelse.. og husk din deadzone.
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: "Authentik og OIDC: de faldgruber der koster en weekend"
|
||||
description: "redirect_uri-mismatch, X-Forwarded-Proto, sub vs. email og de andre OIDC-fælder jeg er faldet i med Authentik."
|
||||
date: 2026-06-01
|
||||
tags: [authentik, oidc, sso, selfhost]
|
||||
draft: false
|
||||
---
|
||||
|
||||
Jeg kører **Authentik** som central identitetsudbyder i mit homelab, så jeg kan
|
||||
logge ind ét sted og få **SSO** på tværs af alle mine tjenester via **OIDC**. Det
|
||||
fungerer rigtig godt .. lige indtil det ikke gør. Her er de faldgruber jeg selv
|
||||
er faldet i, og hvordan jeg slap ud af dem igen.
|
||||
|
||||
## redirect_uri stemmer ikke
|
||||
|
||||
Den klassiker. Du logger ind, Authentik sender dig tilbage, og applikationen råber
|
||||
`redirect_uri_mismatch`. I næsten alle tilfælde er det en af tre ting:
|
||||
|
||||
- En **trailing slash** der er med det ene sted og ikke det andet.
|
||||
- **http kontra https** .. appen bygger callback-URL'en med `http://`, mens
|
||||
Authentik forventer `https://`.
|
||||
- Et **ekstra query-parameter** der får regex'en for tilladte redirect-URI'er til
|
||||
at fejle.
|
||||
|
||||
Callback-URL'en i appen skal matche en af redirect-URI'erne i Authentik helt
|
||||
nøjagtigt. Jeg holder én regel: kopiér URL'en, lim den ind begge steder, og rør
|
||||
den ikke med håndkraft.
|
||||
|
||||
## X-Forwarded-Proto er din ven
|
||||
|
||||
Den med http/https er næsten altid en proxy der ikke fortæller appen, at den
|
||||
oprindelige forbindelse var krypteret. Hvis din reverse proxy ikke sætter
|
||||
`X-Forwarded-Proto: https`, så genererer appen sine egne URL'er med `http://`, og
|
||||
så er redirect-mismatchen i hus. Sørg for at headeren er sat, så appen bygger
|
||||
korrekte redirect-URI'er. Det er en af de hyppigste årsager til OAuth-fejl med
|
||||
Authentik bag fx Portainer.
|
||||
|
||||
## Bland ikke ID-token og access-token sammen
|
||||
|
||||
OIDC giver dig to tokens, og de har hver sin opgave:
|
||||
|
||||
- **ID-tokenet** beviser *hvem* brugeren er. Det skal bruges til at oprette
|
||||
sessionen i din OIDC-klient.
|
||||
- **Access-tokenet** giver adgang til *API'er*. Det skal sendes med i
|
||||
`Authorization`-headeren til din backend.
|
||||
|
||||
Sender du et ID-token til et API, har det forkert `aud` (audience). Bruger du et
|
||||
access-token til at identificere sessionen, mangler det en defineret
|
||||
claim-struktur. En af de mest udbredte OIDC-bugs er netop at bytte rundt på de to.
|
||||
|
||||
## Bind sessionen til `sub`, ikke email
|
||||
|
||||
Det er fristende at bruge email som den unikke nøgle for en bruger. Lad være.
|
||||
**Email kan ændre sig**, mens `sub` er det stabile id. Binder du brugerkontoen til
|
||||
email, så bryder alt den dag nogen skifter adresse. Brug `sub`.
|
||||
|
||||
## Hold ID-tokenet småt og brug /userinfo
|
||||
|
||||
Smid ikke alle claims ind i ID-tokenet via store scopes. Hold det lille, og hent
|
||||
de rige profildata fra **/userinfo**-endpointet når du har brug for dem.
|
||||
ID-tokenet indeholder ikke nødvendigvis alle claims for de scopes du beder om, men
|
||||
/userinfo har det fulde sæt.
|
||||
|
||||
## PKCE på alt
|
||||
|
||||
Brug **PKCE** på alle flows, både offentlige og fortrolige klienter. Mange public
|
||||
clients springer det stadig over, selvom det i dag reelt er et krav for dem og
|
||||
stærkt anbefalet generelt. Det koster ingenting at slå til, og det lukker en hel
|
||||
klasse af angreb.
|
||||
|
||||
## Når en opgradering vender op og ned på det
|
||||
|
||||
En sidste advarsel: efter en opgradering fra Authentik 2025.12.1 til 2025.12.2
|
||||
begyndte OAuth via en **Proxy Provider** at fejle med `redirect_uri_mismatch`,
|
||||
fordi Authentik byggede callback'en med applikationens domæne i stedet for sit
|
||||
eget. Pointen: når noget pludselig knækker uden du har rørt din config, så kig på
|
||||
changelog'en før du river håret ud.
|
||||
|
||||
## Kilder og videre læsning
|
||||
|
||||
- [Authentik #19883: redirect_uri via Proxy Provider](https://github.com/goauthentik/authentik/issues/19883)
|
||||
- [Fix OAuth login med Authentik i Portainer](https://oneuptime.com/blog/post/2026-03-20-portainer-oauth-authentik/view)
|
||||
- [Authentik #3445: forward auth redirect URI](https://github.com/goauthentik/authentik/issues/3445)
|
||||
- [Okta: OIDC primer (sub, claims, tokens)](https://developer.okta.com/blog/2017/07/25/oidc-primer-part-1)
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: "Authentik bag en reverse proxy: forward auth, outposts og 502-fælden"
|
||||
description: "Forward auth eller proxy provider? Og hvorfor store auth-headers giver 502 i nginx, når Authentik sidder bagved."
|
||||
date: 2026-06-05
|
||||
tags: [authentik, reverse-proxy, oidc, nginx]
|
||||
draft: false
|
||||
---
|
||||
|
||||
Når du sætter **Authentik** bag en reverse proxy, skal du tidligt træffe et valg:
|
||||
skal proxyen selv håndtere applikationstrafikken og kun spørge Authentik
|
||||
*"må den her bruger?"*, eller skal Authentik stå helt foran? De to modeller hedder
|
||||
**forward auth** og **proxy provider**, og de løser hver sit problem.
|
||||
|
||||
## Forward auth kontra proxy provider
|
||||
|
||||
Med **forward auth** bruger du din eksisterende reverse proxy til selve trafikken,
|
||||
og Authentiks **outpost** bliver kun spurgt om authentication og authorization.
|
||||
Det er den model jeg foretrækker, fordi min proxy i forvejen styrer TLS, routing
|
||||
og certifikater .. Authentik skal bare sige god eller ej.
|
||||
|
||||
Med en **proxy provider** lader du Authentik proxye trafikken hele vejen. Det er
|
||||
enklere for enkeltstående apps, men du giver afkald på noget kontrol over edge'en.
|
||||
|
||||
For at bruge forward auth vælger du en af forward-auth-tilstandene på proxy
|
||||
provideren og konfigurerer din reverse proxy til at sende auth-tjek til outposten.
|
||||
Vælg **domain-level** hvis du vil dele login på tværs af mange subdomæner, og
|
||||
**single application** hvis det kun gælder én app.
|
||||
|
||||
## 502-fælden: for store auth-headers
|
||||
|
||||
Her er den der koster timer. Alt virker, men en gang imellem .. eller for nogle
|
||||
brugere .. svarer appen med **502 Bad Gateway**. Det er ofte fordi auth-proxyen
|
||||
sender **meget store response-headers** tilbage (tænk mange grupper-claims eller
|
||||
lange tokens), og nginx' default-buffere er for små til at rumme dem.
|
||||
|
||||
Løsningen er at skrue op for buffer-størrelserne i nginx:
|
||||
|
||||
```nginx
|
||||
proxy_buffers 8 16k;
|
||||
proxy_buffer_size 32k;
|
||||
```
|
||||
|
||||
Justér tallene op hvis du har mange grupper på din bruger. Det er ikke en
|
||||
Authentik-bug, det er nginx der nægter at videresende en header den synes er for
|
||||
stor.
|
||||
|
||||
## WebSockets og HTTP/1.1 er et krav
|
||||
|
||||
Authentik kommunikerer med sine **outposts over WebSockets**, og din reverse proxy
|
||||
**skal** kunne tale HTTP/1.1 eller nyere. Kører proxyen i en gammel
|
||||
HTTP/1.0-tilstand, eller mangler den `Upgrade`- og `Connection`-headerne, så får
|
||||
outposten aldrig forbindelse, og din UI viser bare `502 authentik starting...` i
|
||||
en uendelighed.
|
||||
|
||||
## Sørg for at proto-headeren er sat
|
||||
|
||||
Det samme råd som med ren OIDC gælder her: din proxy skal sende
|
||||
`X-Forwarded-Proto`, `X-Forwarded-Host` og `X-Forwarded-For` videre. Gør den ikke
|
||||
det, bygger Authentik forkerte redirect-URI'er, og du ender i den klassiske
|
||||
`redirect_uri`-fejl .. nu bare med et ekstra lag proxy at fejlsøge igennem.
|
||||
|
||||
## Min anbefaling
|
||||
|
||||
Start med **forward auth** hvis du allerede har en reverse proxy du er glad for.
|
||||
Hold proxyens buffere store nok fra dag ét, tjek at WebSockets går igennem, og
|
||||
send dine forwarded-headers korrekt. Så er du fri for langt de fleste af de fejl
|
||||
jeg har brugt aftener på.
|
||||
|
||||
## Kilder og videre læsning
|
||||
|
||||
- [Authentik docs: Forward auth](https://docs.goauthentik.io/add-secure-apps/providers/proxy/forward_auth/)
|
||||
- [Authentik docs: Reverse proxy](https://docs.goauthentik.io/install-config/reverse-proxy/)
|
||||
- [Authentik #2247: 502 "authentik starting..."](https://github.com/goauthentik/authentik/issues/2247)
|
||||
- [Authentik #18445: nginx-proxy-manager setup](https://github.com/goauthentik/authentik/discussions/18445)
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
title: "Injicér JavaScript på alle dine sider med NPMplus og sub_filter"
|
||||
description: "Sådan sprøjter du et script ind i HTML for hver app bag din reverse proxy med nginx sub_filter og lidt Lua .. uden at røre den enkelte app."
|
||||
date: 2026-06-08
|
||||
tags: [npmplus, nginx, sub-filter, reverse-proxy, selfhost, matomo]
|
||||
draft: false
|
||||
---
|
||||
|
||||
Jeg ville have **Matomo-analytics** på tværs af alle mine selvhostede apps uden at
|
||||
skulle ind og redigere hver enkelt app's HTML. Nogle af dem kan jeg slet ikke rette
|
||||
i.. de er tredjeparts-images. Løsningen sidder ét sted: i min reverse proxy.
|
||||
**NPMplus** bygger på OpenResty, så jeg har både **`sub_filter`** og **Lua** til
|
||||
rådighed, og dermed kan jeg injicere et `<script>` ind i ethvert HTML-svar, der
|
||||
passerer igennem proxyen.
|
||||
|
||||
Her er hele opskriften, inklusive de to fælder der koster tid: **gzip** og **CSP**.
|
||||
|
||||
## Den korte version
|
||||
|
||||
Kernen er to ting: find `</head>` i svaret og sæt mit script ind lige før det.
|
||||
|
||||
```nginx
|
||||
# Slå upstream-komprimering fra, ellers ser sub_filter kun gzippede bytes
|
||||
proxy_set_header Accept-Encoding "";
|
||||
|
||||
sub_filter '</head>' '<script src="/_inject/injector.js" defer></script></head>';
|
||||
sub_filter_once on;
|
||||
sub_filter_types text/html;
|
||||
```
|
||||
|
||||
I NPMplus lægger du det enten på den enkelte proxy host under **Advanced > Custom
|
||||
Nginx Configuration**, eller globalt i `custom_nginx/server_proxy.conf`, så det
|
||||
gælder *alle* hosts på én gang. Det sidste er det jeg gør.
|
||||
|
||||
## Fælde 1: gzip gør sub_filter usynlig
|
||||
|
||||
Det her er den klassiske aha-fejl. Din upstream-app svarer som regel **gzippet**,
|
||||
og `sub_filter` arbejder på den rå svar-body. Får den komprimerede bytes, finder
|
||||
den aldrig `</head>`, og der sker .. ingenting. Ingen fejl, bare tavshed.
|
||||
|
||||
Løsningen er linjen `proxy_set_header Accept-Encoding "";`. Den fortæller upstream
|
||||
at du ikke vil have komprimering, så nginx får ren HTML at arbejde på. Husk den,
|
||||
ellers leder du efter spøgelser.
|
||||
|
||||
`sub_filter_types text/html;` sikrer samtidig, at vi kun rører HTML og ikke
|
||||
forsøger at rode i JSON, billeder eller dine JavaScript-filer.
|
||||
|
||||
## Fælde 2: CSP blokerer dit tredjepartsscript
|
||||
|
||||
Mange apps sender en **Content-Security-Policy**, der kun tillader scripts fra
|
||||
deres eget origin. Injicerer du et `<script src="https://assets.example.dk/...">`,
|
||||
bliver det blokeret af browseren. Tricket er at gøre scriptet **same-origin**: lad
|
||||
proxyen servere det fra en sti på selve domænet og hente det bagved fra din
|
||||
asset-host.
|
||||
|
||||
```nginx
|
||||
# Servér injector-scriptet same-origin, så CSP ikke blokerer det
|
||||
location = /_inject/injector.js {
|
||||
proxy_pass https://assets.example.dk/injector.js?origin=$host;
|
||||
proxy_set_header Host assets.example.dk;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# SNI når vi henter over HTTPS
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_name assets.example.dk;
|
||||
|
||||
proxy_buffering off;
|
||||
add_header Cache-Control "public, max-age=60";
|
||||
}
|
||||
```
|
||||
|
||||
Nu peger mit injicerede script på `/_inject/injector.js` på *samme* domæne som
|
||||
siden selv, og CSP er glad. Bag kulissen henter proxyen den rigtige fil fra
|
||||
`assets.example.dk` og sender `?origin=$host` med, så scriptet ved hvilken side det
|
||||
kører på.
|
||||
|
||||
## Gør injektionen dynamisk med Lua
|
||||
|
||||
Hvis du injicerer på *alle* hosts, så opstår der ét problem: du kommer også til at
|
||||
injicere ind i din **analytics-host selv**, og i asset-hosten der serverer scriptet.
|
||||
Det vil du ikke. Med et `set_by_lua_block` kan jeg bygge script-strengen dynamisk og
|
||||
returnere en tom streng for de hosts, der skal holdes fri:
|
||||
|
||||
```nginx
|
||||
set_by_lua_block $inject_script {
|
||||
local host = ngx.var.host or ""
|
||||
-- Injicér intet på asset- og analytics-hosten (undgår loops)
|
||||
if host == "assets.example.dk" or host == "matomo.example.dk" then
|
||||
return ""
|
||||
end
|
||||
return "<script src='/_inject/injector.js' defer></script>"
|
||||
}
|
||||
|
||||
sub_filter '</head>' '$inject_script</head>';
|
||||
sub_filter_once on;
|
||||
sub_filter_types text/html;
|
||||
```
|
||||
|
||||
`sub_filter` kan godt indsætte en nginx-variabel, så `$inject_script` bliver til
|
||||
enten et script-tag eller ingenting, afhængigt af hvilken host der svarer.
|
||||
|
||||
## Bonus: en first-party cookie på tværs af subdomæner
|
||||
|
||||
Vil du følge den samme besøgende på tværs af `app1.example.dk`,
|
||||
`app2.example.dk` osv., så kan du sætte en **first-party cookie på todomænet** med
|
||||
`header_filter_by_lua_block`. Pointen er at sætte den på `.example.dk`, så den
|
||||
deles, og kun på rigtige sidevisninger (ikke baggrundskald), så du undgår en
|
||||
Set-Cookie-løkke:
|
||||
|
||||
```nginx
|
||||
header_filter_by_lua_block {
|
||||
local cookie = ngx.var.http_cookie or ""
|
||||
local vid = cookie:match("VID=([^;]+)")
|
||||
local is_new = false
|
||||
|
||||
if not vid or vid == "" then
|
||||
vid = ngx.var.request_id
|
||||
is_new = true
|
||||
end
|
||||
|
||||
-- Sæt kun cookien på spritny besøgende eller ægte HTML-sideload
|
||||
local accept = ngx.var.http_accept or ""
|
||||
local is_html = string.find(accept, "text/html")
|
||||
|
||||
if is_new or is_html then
|
||||
local secure = (ngx.var.scheme == "https") and "; Secure" or ""
|
||||
local sc = "VID=" .. vid ..
|
||||
"; Domain=.example.dk; Path=/; Max-Age=157680000; HttpOnly; SameSite=Lax" ..
|
||||
secure
|
||||
local existing = ngx.header["Set-Cookie"]
|
||||
if existing == nil then
|
||||
ngx.header["Set-Cookie"] = sc
|
||||
elseif type(existing) == "table" then
|
||||
table.insert(existing, sc)
|
||||
ngx.header["Set-Cookie"] = existing
|
||||
else
|
||||
ngx.header["Set-Cookie"] = { existing, sc }
|
||||
end
|
||||
end
|
||||
}
|
||||
```
|
||||
|
||||
`Max-Age=157680000` er fem år. Bemærk `HttpOnly`.. cookien er kun til server-side
|
||||
sammenkædning, ikke til JavaScript.
|
||||
|
||||
## Et par advarsler
|
||||
|
||||
- **Hold dig til dine egne sider.** At injicere scripts i HTML, du ikke ejer, er
|
||||
præcis den slags man bør lade være med. Det her er til *mine* selvhostede apps.
|
||||
- **`sub_filter_once on`** sikrer, at vi kun rammer det første `</head>`. Slå det
|
||||
fra, hvis du bevidst vil ramme flere forekomster, men det er sjældent det du vil.
|
||||
- **Test med `curl -H "Accept-Encoding: identity"`** og kig efter dit tag, hvis det
|
||||
ikke dukker op i browseren. Så ved du hurtigt, om det er gzip eller CSP, der
|
||||
driller.
|
||||
|
||||
Når det først sidder globalt i `server_proxy.conf`, får hver ny app bag proxyen
|
||||
analytics gratis, uden at jeg rører appen. Det er hele pointen med at gøre det i
|
||||
edge-laget.
|
||||
|
||||
## Kilder og videre læsning
|
||||
|
||||
- [nginx: ngx_http_sub_module (sub_filter)](https://nginx.org/en/docs/http/ngx_http_sub_module.html)
|
||||
- [OpenResty: set_by_lua_block / header_filter_by_lua_block](https://github.com/openresty/lua-nginx-module#set_by_lua_block)
|
||||
- [ZoeyVid/NPMplus på GitHub](https://github.com/ZoeyVid/NPMplus)
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: "Fra Nginx Proxy Manager til NPMplus: en migrering uden vej tilbage"
|
||||
description: "Hvad der gik godt, hvad der brød, og hvorfor backup ikke er til forhandling, når du skifter fra NPM til NPMplus."
|
||||
date: 2026-06-07
|
||||
tags: [npmplus, nginx-proxy-manager, reverse-proxy, selfhost]
|
||||
draft: false
|
||||
---
|
||||
|
||||
I årevis har min edge kørt på **Nginx Proxy Manager** (NPM). Det er en fremragende
|
||||
lille proxy med en pæn UI, men jeg ville have CrowdSec, moderne TLS-defaults og en
|
||||
del andre forbedringer. Det fører naturligt til **NPMplus**, en forbedret fork af
|
||||
NPM. Migreringen er for det meste smertefri, men der er nogle fælder du skal kende.
|
||||
|
||||
## Der er ingen vej tilbage
|
||||
|
||||
Det vigtigste først: **du kan ikke migrere tilbage** til den oprindelige NPM
|
||||
bagefter. Tag en fuld backup før du rører ved noget, så du har muligheden for at
|
||||
rulle tilbage hvis det går galt. Jeg snapshotter hele containeren først .. det er
|
||||
fem sekunders arbejde og redder din aften.
|
||||
|
||||
## Formularerne har ændret adfærd
|
||||
|
||||
NPMplus har ændret mange af formularerne, så efter migreringen skal du gennemgå
|
||||
**hver eneste proxy host** og tjekke at indstillingerne stadig er korrekte. Antag
|
||||
ikke at alt bare flytter med 1:1. Hos mig var de fleste hosts fine, men et par
|
||||
stykker skulle rettes i hånden.
|
||||
|
||||
## Let's Encrypt-mountet skal fjernes igen
|
||||
|
||||
Det her er den fælde folk hænger fast i. Under migreringen monterer du dit gamle
|
||||
`letsencrypt`-bibliotek ind, så NPMplus kan importere dine eksisterende
|
||||
certifikater. Men migreringen **fortsætter ikke** før du fjerner det mount igen.
|
||||
Du får en besked i stil med:
|
||||
|
||||
> All certbot certs have been copied, please remove the /etc/letsencrypt
|
||||
> mountpoint and redeploy to continue the migration!
|
||||
|
||||
Certifikaterne er flyttet til `/data`. Fjern `/etc/letsencrypt`-mountet, redeploy
|
||||
din compose-fil, og så kører den videre.
|
||||
|
||||
## Intet admin-password i loggen
|
||||
|
||||
Du leder forgæves efter et nyt admin-password i opstartsloggen. Det kommer ikke,
|
||||
fordi **dine eksisterende NPM-credentials følger med**. Log ind med det gamle
|
||||
brugernavn og kodeord.
|
||||
|
||||
## Proxy gennem proxy? Skift til https
|
||||
|
||||
Hvis du proxyer NPMplus gennem en anden NPM(plus)-instans, så husk at skifte
|
||||
schemet fra `http` til **`https`**. Det er en lille ting, men den giver en bøvlet
|
||||
fejl hvis du overser den.
|
||||
|
||||
## CrowdSec ser intet uden logs
|
||||
|
||||
Vil du have **CrowdSec** med, så er der to ting der ofte spænder ben:
|
||||
|
||||
- Sæt `LOGROTATE=true`. Uden den skrives access-logs aldrig til disk, og så har
|
||||
CrowdSec ingenting at analysere.
|
||||
- CrowdSec-konfigurationen peger på **container-stier** (fx
|
||||
`/opt/npmplus/nginx/logs`), ikke host-stier, selvom dine bind mounts mapper
|
||||
anderledes.
|
||||
|
||||
Sidder du bag **Cloudflare**, så sæt `TRUST_CLOUDFLARE=true`, ellers ser CrowdSec
|
||||
kun Cloudflares IP'er som kilde til al trafik, og så banlyser den i praksis hele
|
||||
internettet på én gang.
|
||||
|
||||
## HTTP/3 kræver UDP 443
|
||||
|
||||
NPMplus kan **HTTP/3**, men det kræver at du forwarder **UDP 443**, ikke kun TCP.
|
||||
Enten åbner du porten i din firewall, eller også slår du funktionen fra. Glemmer
|
||||
du det, virker siderne stadig (de falder bare tilbage til HTTP/2), så det er nemt
|
||||
at overse.
|
||||
|
||||
## Var det det værd?
|
||||
|
||||
Ja. NPMplus er en solid drop-in-erstatning .. den velkendte UI, alle proxy hosts
|
||||
og certifikater migrerede rent, og oveni fik jeg CrowdSec og bedre defaults. Bare
|
||||
husk backuppen, for der er som sagt ingen vej tilbage.
|
||||
|
||||
## Kilder og videre læsning
|
||||
|
||||
- [ZoeyVid/NPMplus på GitHub](https://github.com/ZoeyVid/NPMplus)
|
||||
- [Walkthrough: NPM til NPMplus med CrowdSec](https://trevorbotha.net/tech-related-blog/migrating-from-nginx-proxy-manager-to-npmplus-with-crowdsec-a-complete-walkthrough/)
|
||||
- [NPMplus migration discussion #964](https://github.com/ZoeyVid/NPMplus/discussions/964)
|
||||
- [Nginx Proxy Manager vs. NPMplus](https://www.virtualizationhowto.com/2025/09/nginx-proxy-manager-vs-npmplus-which-one-is-better-for-your-home-lab/)
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
title: "Mit Proxmox-hjemmelab"
|
||||
description: "Et kig på opbygningen: reverse proxy, SSO, CI/CD og en masse containere."
|
||||
date: 2026-06-09
|
||||
tags: [proxmox, selfhost, homelab]
|
||||
draft: false
|
||||
---
|
||||
|
||||
Mit hjemmelab kører på **Proxmox VE** med en blanding af LXC-containere og enkelte
|
||||
virtuelle maskiner. Et par af hjørnestenene:
|
||||
|
||||
## Edge & sikkerhed
|
||||
Al indgående trafik går gennem en **reverse proxy** med automatiske Let's Encrypt-certifikater,
|
||||
og brugere logger ind centralt via **SSO (OIDC)**.
|
||||
|
||||
## Kode & CI/CD
|
||||
Jeg hoster min egen **Gitea** med tilhørende Actions-runnere, så projekter bygges og
|
||||
deployes automatisk ved push — inklusive den her side.
|
||||
|
||||
## Resten
|
||||
Medieservere, databaser, dokument-redigering, overvågning og en lang række småtjenester.
|
||||
Mere om de enkelte dele i kommende indlæg.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: "Proxmox LXC i praksis: de fejl jeg selv lavede"
|
||||
description: "Unprivileged kontra privileged, uid-mapping til nobody, og hvorfor Docker i en unprivileged LXC driller på nye kerner."
|
||||
date: 2026-05-26
|
||||
tags: [proxmox, lxc, homelab, selfhost]
|
||||
draft: false
|
||||
---
|
||||
|
||||
Næsten alt i mit homelab kører i **LXC-containere** på **Proxmox VE** frem for
|
||||
fulde virtuelle maskiner. De er lette, starter på et øjeblik og deler kernen med
|
||||
værten. Men der er et par steder hvor jeg har lært det på den hårde måde.
|
||||
|
||||
## Unprivileged kontra privileged
|
||||
|
||||
Proxmox defaulter til **unprivileged** containere i GUI'en, og det er den rigtige
|
||||
default. Internetvendte tjenester som webservere og reverse proxies bør køre
|
||||
unprivileged af sikkerhedshensyn. DNS, proxies, overvågning og lette tjenester er
|
||||
perfekte LXC-kandidater.
|
||||
|
||||
**Privileged** containere bør du kun bruge til isolerede homelabs uden
|
||||
internetvendte tjenester, til NFS-servere hvor uid-mapping bliver for besværligt,
|
||||
eller til hurtige testmiljøer. Min tommelfingerregel: unprivileged medmindre noget
|
||||
tvinger mig til andet.
|
||||
|
||||
## Alt bliver til `nobody`
|
||||
|
||||
Den første væg man render ind i med unprivileged containere: hver fil og mappe
|
||||
mappes til **`nobody`** (uid 65534). Det giver bøvl så snart du har restriktive
|
||||
rettigheder, eller vil skrive filer med et bestemt uid/gid. Især når du monterer
|
||||
**netværksshares** ind, introducerer uid-shiftet en hel del kompleksitet du skal
|
||||
forstå før det spiller.
|
||||
|
||||
Løsningen er ikke at give op og køre privileged. Det er at lære `lxc.idmap` at
|
||||
kende og mappe de specifikke uid'er du har brug for. Det tager en eftermiddag at
|
||||
forstå, og så har du det som værktøj resten af tiden.
|
||||
|
||||
## Docker i en unprivileged LXC er en fælde lige nu
|
||||
|
||||
Det her bed mig for nylig. De fleste guides til **Docker i LXC** online er
|
||||
forældede .. de antager kerne 5.x og runc 1.1.x. På moderne Proxmox med
|
||||
**kerne 6.14+** og **runc 1.2+** fejler de opskrifter før du overhovedet når frem
|
||||
til de gamle workarounds. Unprivileged Docker er reelt brudt på nye kerner på
|
||||
grund af runc's per-namespace sysctl-skrivning og Dockers AppArmor-probe.
|
||||
|
||||
Næsten enhver fungerende opsætning i forummerne involverer enten
|
||||
**kerneændringer på værten**, at slå **AppArmor** helt fra, eller begge dele. Mit
|
||||
råd: vil du køre Docker, så kør det i en **VM** i stedet, eller i en bevidst
|
||||
**privileged** LXC du holder isoleret. Det er ikke kampen værd at presse det ind i
|
||||
en unprivileged container på en ny kerne.
|
||||
|
||||
## LXC kontra VM
|
||||
|
||||
Jeg vælger LXC til langt det meste: reverse proxy, databaser, statiske websites,
|
||||
små API'er. Jeg vælger en **VM** når jeg har brug for en anden kerne, fuld
|
||||
isolation, eller netop Docker-tunge workloads. Reglen er enkel: container når jeg
|
||||
kan, VM når jeg skal.
|
||||
|
||||
## Kilder og videre læsning
|
||||
|
||||
- [Proxmox wiki: Unprivileged LXC containers](https://pve.proxmox.com/wiki/Unprivileged_LXC_containers)
|
||||
- [Docker on Proxmox LXC: what actually works](https://dev.to/mattercoder/docker-on-proxmox-lxc-what-actually-works-and-why-unprivileged-doesnt-45km)
|
||||
- [Privileged vs. unprivileged LXC](https://proxmoxpulse.com/articles/privileged-vs-unprivileged-lxc-containers-proxmox/)
|
||||
- [Proxmox: LXC containers vs. VMs](https://homelabstarter.com/proxmox-lxc-vs-vm/)
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: "Velkommen til min nye side"
|
||||
description: "Hvad siden er, og hvad du kan forvente at finde her."
|
||||
date: 2026-06-10
|
||||
tags: [meta, selfhost]
|
||||
draft: false
|
||||
---
|
||||
|
||||
Velkommen! Det her er min nye personlige side — bygget med **Astro** på **Bun**,
|
||||
hostet på min egen Proxmox-infrastruktur, og deployet automatisk via Gitea Actions.
|
||||
|
||||
Her kommer jeg til at skrive om det jeg roder med:
|
||||
|
||||
- **Selvhosting & Proxmox** — hvordan jeg driver mit hjemmelab.
|
||||
- **AI & automatisering** — at bygge ting med Claude Code og LLM'er.
|
||||
- **Udvikling** — Bun, TypeScript, Django og alt derimellem.
|
||||
|
||||
Siden opdateres løbende. God fornøjelse.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
import { ClientRouter } from 'astro:transitions';
|
||||
import Head from '../components/Head.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import { SITE } from '../consts';
|
||||
import '../styles/global.css';
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
type?: 'website' | 'article';
|
||||
publishedTime?: string;
|
||||
modifiedTime?: string;
|
||||
tags?: string[];
|
||||
jsonLd?: unknown[];
|
||||
}
|
||||
const { title, description, image, type, publishedTime, modifiedTime, tags, jsonLd } = Astro.props;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang={SITE.lang}>
|
||||
<head>
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const apply = () => {
|
||||
const t = localStorage.getItem('theme') ||
|
||||
(window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||
document.documentElement.dataset.theme = t;
|
||||
};
|
||||
apply();
|
||||
document.addEventListener('astro:after-swap', apply);
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-theme-toggle]');
|
||||
if (!btn) return;
|
||||
const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
|
||||
document.documentElement.dataset.theme = next;
|
||||
localStorage.setItem('theme', next);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<Head {title} {description} {image} {type} {publishedTime} {modifiedTime} {tags} {jsonLd} />
|
||||
<ClientRouter />
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col antialiased">
|
||||
<Header />
|
||||
<main class="mx-auto w-full max-w-3xl flex-1 px-5 py-12">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
---
|
||||
<Base title="404">
|
||||
<div class="py-20 text-center">
|
||||
<h1 class="text-6xl font-bold tracking-tight">404</h1>
|
||||
<p class="mt-4 text-muted">Siden findes ikke.</p>
|
||||
<a href="/" class="mt-8 inline-block rounded-xl bg-accent px-5 py-2.5 font-medium text-bg">Til forsiden</a>
|
||||
</div>
|
||||
</Base>
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { SITE } from '../../consts';
|
||||
import { blogPosting } from '../../seo';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('blog', ({ data }) => !data.draft);
|
||||
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
|
||||
}
|
||||
const { post } = Astro.props;
|
||||
const { Content } = await render(post);
|
||||
const d = post.data.date.toLocaleDateString('da-DK', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
const url = new URL(`/blog/${post.id}`, SITE.url).href;
|
||||
const published = post.data.date.toISOString();
|
||||
const modified = (post.data.updated ?? post.data.date).toISOString();
|
||||
const ld = blogPosting({
|
||||
url, headline: post.data.title, description: post.data.description,
|
||||
datePublished: published, dateModified: modified,
|
||||
tags: post.data.tags, image: new URL('/og.png', SITE.url).href,
|
||||
});
|
||||
---
|
||||
<Base
|
||||
title={post.data.title}
|
||||
description={post.data.description}
|
||||
type="article"
|
||||
publishedTime={published}
|
||||
modifiedTime={modified}
|
||||
tags={post.data.tags}
|
||||
jsonLd={[ld]}
|
||||
>
|
||||
<a href="/blog" class="text-sm text-muted hover:text-accent">← Tilbage til blog</a>
|
||||
<article class="mt-4">
|
||||
<header class="border-b border-border pb-6">
|
||||
<div class="text-sm text-muted">{d}</div>
|
||||
<h1 class="mt-2 text-4xl font-bold tracking-tight">{post.data.title}</h1>
|
||||
{post.data.tags.length > 0 && (
|
||||
<div class="mt-4 flex flex-wrap gap-1.5">
|
||||
{post.data.tags.map((t) => (
|
||||
<a href={`/tags/${t}`} class="rounded-full border border-border px-2.5 py-0.5 text-xs text-muted hover:border-accent hover:text-accent">#{t}</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<div class="prose mt-8"><Content /></div>
|
||||
</article>
|
||||
|
||||
<section class="mt-16 border-t border-border pt-8">
|
||||
<h2 class="text-2xl font-bold tracking-tight">Kommentarer</h2>
|
||||
<comentario-comments class="mt-6 block" live-update="true"></comentario-comments>
|
||||
</section>
|
||||
<script is:inline>
|
||||
(function () {
|
||||
if (!window.__comentarioLoaded) {
|
||||
window.__comentarioLoaded = true;
|
||||
var s = document.createElement('script');
|
||||
s.src = 'https://comentario.weircon.dk/comentario.js';
|
||||
s.defer = true;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</Base>
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import PostCard from '../../components/PostCard.astro';
|
||||
|
||||
const posts = (await getCollection('blog', ({ data }) => !data.draft))
|
||||
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||
---
|
||||
<Base title="Blog" description="Indlæg om selvhosting, Proxmox, AI og udvikling.">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<h1 class="text-4xl font-bold tracking-tight">Blog</h1>
|
||||
<a href="/tags" class="text-sm text-accent hover:underline">Tags</a>
|
||||
</div>
|
||||
<div class="mt-8 grid gap-4">
|
||||
{posts.map((p) => (
|
||||
<PostCard href={`/blog/${p.id}`} title={p.data.title} date={p.data.date} description={p.data.description} tags={p.data.tags} />
|
||||
))}
|
||||
{posts.length === 0 && <p class="text-muted">Ingen indlæg endnu.</p>}
|
||||
</div>
|
||||
</Base>
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { SITE } from '../consts';
|
||||
|
||||
const erhverv = [
|
||||
{
|
||||
org: 'Digisense A/S', sted: 'Danmark', roller: [
|
||||
{ titel: 'Technical Solution Architect', naar: 'maj 2026 – nu', detaljer: ['Teknisk arkitektur og løsningsdesign', 'Bygger bro mellem kunder og udviklingsteams', 'Teknisk overblik og rådgivning'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
org: 'CGI', sted: 'Aalborg', roller: [
|
||||
{ titel: 'Teknisk Lead', naar: 'aug. 2023 – maj 2026', detaljer: ['Teknisk overblik og arkitektur', 'Tæt kundesamarbejde om teknisk leverance', 'Kontaktperson og rådgiver for kunden', 'Formidler kundens behov til udviklingsteamet'] },
|
||||
{ titel: 'Senior Konsulent', naar: 'apr. 2023 – maj 2026', detaljer: ['Buddy for ny udvikler', 'Afholdt installationsvejledning via webinar'] },
|
||||
{ titel: 'Konsulent', naar: 'aug. 2022 – apr. 2023', detaljer: ['OpenSource reference-implementering (Java, Peppol eDelivery)', 'Løsningsarkitekt'] },
|
||||
{ titel: 'Studentermedhjælper', naar: 'aug. 2020 – aug. 2022', detaljer: ['Kode-ansvarlig for en regelmotor (Java, Drools)', 'DevOps (Docker, docker-compose, Linux)'] },
|
||||
{ titel: 'Konsulent', naar: 'jun. 2019 – aug. 2020', detaljer: ['Frontend (Angular 8, TypeScript)', 'Backend (GoLang, Java, Python)'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
org: 'BilligVVS', sted: 'Egaa', roller: [
|
||||
{ titel: 'Fullstack IT-udvikler', naar: 'sep. 2018 – jun. 2019', detaljer: ['Hjemmeside (Python, TypeScript)', 'Backend (Python, Movex)', 'Webcrawler (Python)', 'BI (Python, chart.js)'] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const uddannelse = [
|
||||
{ hvad: 'HD 1. del — Erhvervsøkonomi', hvor: 'Aalborg Universitet', naar: '2025 – 2027 (i gang)', note: 'Forretningsforståelse, erhvervsøkonomi, regnskab og finansiering.' },
|
||||
{ hvad: 'Kandidat i Datalogi (cand.scient.)', hvor: 'Aalborg Universitet', naar: '2020 – 2022', note: 'Speciale: Thread-Fuzz — mutation-based behavioural blackbox fuzzing of Thread.' },
|
||||
{ hvad: 'Bachelor i Datalogi (B.Sc.)', hvor: 'Aalborg Universitet', naar: '2015 – 2018', note: '' },
|
||||
{ hvad: 'Almen studentereksamen (STX)', hvor: 'Espergærde Gymnasium og HF', naar: '2012 – 2015', note: '' },
|
||||
{ hvad: 'Udvekslingsår', hvor: 'Lincoln High School, Ypsilanti, Michigan (USA)', naar: '2011 – 2012', note: '' },
|
||||
];
|
||||
|
||||
const frivilligt = [
|
||||
{ hvad: 'LUNARK', naar: '2020', note: 'Notifikationssystem i C# og React.' },
|
||||
{ hvad: 'Kirkens Korshær', naar: '2020 – nu', note: 'Køkkenhjælper hver jul og nytår.' },
|
||||
{ hvad: 'AAU Campus', naar: '2016 – 2022', note: 'Tutor og F-Klub-frivillig.' },
|
||||
];
|
||||
|
||||
const primaere = ['Java', 'Python', 'GoLang', 'TypeScript', 'JavaScript', 'Angular', 'Docker', 'Linux', 'Proxmox', 'PostgreSQL', 'Jenkins', 'Git / GitLab', 'BI', 'Agile'];
|
||||
const sekundaere = ['C#', 'C++', 'C', 'Kubernetes', 'AWS', 'RabbitMQ', 'Datadog', 'XeLaTeX'];
|
||||
const sprog = ['Dansk — modersmål', 'Engelsk — flydende (tale & skrift)'];
|
||||
---
|
||||
<Base title="CV" description="Asger Weirsøe — datalog og Technical Solution Architect. Erfaring, uddannelse og publikationer.">
|
||||
<h1 class="text-4xl font-bold tracking-tight">CV</h1>
|
||||
<div class="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted">
|
||||
<span>{SITE.location}</span>
|
||||
<a class="text-accent hover:underline" href={`mailto:${SITE.email}`}>{SITE.email}</a>
|
||||
<a class="text-accent hover:underline" href="https://www.linkedin.com/in/ag-weirs%C3%B8e/">LinkedIn</a>
|
||||
</div>
|
||||
|
||||
<p class="mt-6 max-w-2xl text-muted">
|
||||
Passioneret datalog med solid baggrund fra Aalborg Universitet. Min karriere startede i
|
||||
detail og gik via fuld-stack udvikling hos BilligVVS til CGI — og i dag er jeg Technical
|
||||
Solution Architect hos Digisense A/S. Jeg forener teknisk dybde med ledelse og trives med
|
||||
at have det tekniske overblik og facilitere samarbejdet mellem kunder og udviklingsteams.
|
||||
</p>
|
||||
|
||||
<section class="mt-10">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Fagområder</h2>
|
||||
<p class="mt-4 max-w-2xl text-muted">
|
||||
Jeg arbejder med <strong class="text-text">elektronisk fakturering</strong> (e-faktura / electronic
|
||||
invoicing) og e-procurement. Hos CGI byggede jeg en open source reference-implementering af
|
||||
<strong class="text-text">Peppol eDelivery</strong> i Java, og i dag rådgiver jeg som Technical
|
||||
Solution Architect hos Digisense A/S om løsningsarkitektur på området — bl.a.
|
||||
<strong class="text-text">Peppol</strong> (BIS, Access Point og eDelivery/AS4, herunder den open
|
||||
source-baserede access point <strong class="text-text">Oxalis</strong>),
|
||||
<strong class="text-text">NemHandel</strong>, <strong class="text-text">OIOUBL/UBL</strong> og den
|
||||
europæiske e-faktura-standard EN 16931.
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
{['Peppol', 'Peppol Access Point', 'Oxalis', 'NemHandel', 'e-faktura', 'Electronic invoicing', 'OIOUBL / UBL', 'e-Delivery (AS4)', 'EN 16931', 'e-procurement'].map((k) => (
|
||||
<span class="rounded-full border border-border bg-surface/60 px-3 py-1 text-sm">{k}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-10">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Erhvervserfaring</h2>
|
||||
<div class="mt-4 space-y-4">
|
||||
{erhverv.map((e) => (
|
||||
<div class="rounded-2xl border border-border bg-surface/60 p-5">
|
||||
<h3 class="font-semibold">{e.org} <span class="font-normal text-muted">· {e.sted}</span></h3>
|
||||
<div class="mt-3 space-y-3">
|
||||
{e.roller.map((r) => (
|
||||
<div>
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<span class="font-medium text-text">{r.titel}</span>
|
||||
<span class="text-xs text-muted">{r.naar}</span>
|
||||
</div>
|
||||
<ul class="mt-1 list-disc pl-5 text-sm text-muted">
|
||||
{r.detaljer.map((d) => <li>{d}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-10">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Uddannelse</h2>
|
||||
<div class="mt-4 space-y-4">
|
||||
{uddannelse.map((u) => (
|
||||
<div class="rounded-2xl border border-border bg-surface/60 p-5">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 class="font-semibold">{u.hvad}</h3><span class="text-xs text-muted">{u.naar}</span>
|
||||
</div>
|
||||
<p class="text-sm text-muted">{u.hvor}</p>
|
||||
{u.note && <p class="mt-1 text-sm text-muted/80">{u.note}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-10">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Publikationer</h2>
|
||||
<div class="mt-4 rounded-2xl border border-border bg-surface/60 p-5">
|
||||
<a href="https://arxiv.org/abs/2310.00999" target="_blank" rel="noopener" class="font-medium hover:text-accent">
|
||||
CGAAL: Distributed On-The-Fly ATL Model Checker with Heuristics
|
||||
</a>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Carlsen, Frydenskov, Jensen, Rasmussen, Sørensen, <span class="text-text">Weirsøe</span>, Jensen & Larsen —
|
||||
<em>EPTCS</em>, vol. 390 (2023), s. 99–114. <span class="text-muted/80">arXiv:2310.00999</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-10">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Frivilligt arbejde</h2>
|
||||
<div class="mt-4 space-y-4">
|
||||
{frivilligt.map((f) => (
|
||||
<div class="rounded-2xl border border-border bg-surface/60 p-5">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 class="font-semibold">{f.hvad}</h3><span class="text-xs text-muted">{f.naar}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-muted">{f.note}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-10 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Primære kompetencer</h2>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
{primaere.map((k) => <span class="rounded-full border border-border bg-surface/60 px-3 py-1 text-sm">{k}</span>)}
|
||||
</div>
|
||||
<h2 class="mt-8 text-sm font-semibold uppercase tracking-widest text-accent">Sekundære</h2>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
{sekundaere.map((k) => <span class="rounded-full border border-border bg-surface/40 px-3 py-1 text-sm text-muted">{k}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Sprog</h2>
|
||||
<ul class="mt-4 space-y-2 text-sm">
|
||||
{sprog.map((s) => <li class="rounded-xl border border-border bg-surface/60 px-3 py-2">{s}</li>)}
|
||||
</ul>
|
||||
<h2 class="mt-8 text-sm font-semibold uppercase tracking-widest text-accent">Interesser</h2>
|
||||
<p class="mt-4 text-sm text-muted">
|
||||
Selvhosting og drift: jeg har gennem mange år bygget og vedligeholdt et homelab med
|
||||
<a class="text-accent hover:underline" href="https://www.proxmox.com/" target="_blank" rel="noopener"> Proxmox</a>,
|
||||
bag SSO via <a class="text-accent hover:underline" href="https://goauthentik.io/" target="_blank" rel="noopener">Authentik</a>.
|
||||
Det har ført til bidrag i open source-projekter som Qutebrowser og Stregsystemet (IT-studerendes betalingssystem på AAU).
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import { Image } from 'astro:assets';
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PostCard from '../components/PostCard.astro';
|
||||
import portrait from '../assets/asger.png';
|
||||
|
||||
const posts = (await getCollection('blog', ({ data }) => !data.draft))
|
||||
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime())
|
||||
.slice(0, 3);
|
||||
|
||||
const highlights = [
|
||||
{ title: 'Arkitektur & e-fakturering', body: 'Technical Solution Architect hos Digisense A/S — løsningsarkitektur inden for elektronisk fakturering, Peppol, Oxalis og NemHandel.' },
|
||||
{ title: 'Selvhostet infrastruktur', body: 'Et Proxmox-homelab med ~50 LXC/VM’er bag SSO (Authentik): reverse proxy, CI/CD, medier og meget mere.' },
|
||||
{ title: 'AI & fuld-stack', body: 'Fra Java, Go og Python til Bun og TypeScript — og LLM-drevne workflows med Claude Code.' },
|
||||
];
|
||||
---
|
||||
<Base>
|
||||
<section class="grid items-center gap-8 py-6 sm:grid-cols-[1fr_auto]">
|
||||
<div>
|
||||
<p class="font-medium text-accent">Hej, jeg er</p>
|
||||
<h1 class="mt-1 text-5xl font-bold tracking-tight sm:text-6xl">Asger Weirsøe</h1>
|
||||
<p class="mt-5 max-w-xl text-lg text-muted">
|
||||
Datalog (cand.scient.) fra Aalborg Universitet og Technical Solution Architect hos Digisense A/S.
|
||||
Jeg kombinerer teknisk dybde med ledelse — og i fritiden drifter jeg mit eget
|
||||
Proxmox-homelab og bygger projekter med AI.
|
||||
</p>
|
||||
<div class="mt-7 flex flex-wrap gap-3">
|
||||
<a href="/cv" class="rounded-xl bg-accent px-5 py-2.5 font-medium text-bg transition-transform hover:-translate-y-0.5">Se mit CV</a>
|
||||
<a href="/projekter" class="rounded-xl border border-border px-5 py-2.5 font-medium transition-colors hover:border-accent">Projekter</a>
|
||||
<a href="/blog" class="rounded-xl border border-border px-5 py-2.5 font-medium transition-colors hover:border-accent">Blog</a>
|
||||
</div>
|
||||
</div>
|
||||
<Image
|
||||
src={portrait}
|
||||
alt="Asger Weirsøe"
|
||||
width={260}
|
||||
densities={[1, 2]}
|
||||
class="mx-auto h-44 w-44 rounded-2xl border border-border object-cover shadow-xl sm:h-56 sm:w-56"
|
||||
loading="eager"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="mt-14 grid gap-4 sm:grid-cols-3">
|
||||
{highlights.map((h) => (
|
||||
<div class="rounded-2xl border border-border bg-surface/60 p-5">
|
||||
<h2 class="font-semibold tracking-tight">{h.title}</h2>
|
||||
<p class="mt-2 text-sm text-muted">{h.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{posts.length > 0 && (
|
||||
<section class="mt-16">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<h2 class="text-2xl font-bold tracking-tight">Seneste indlæg</h2>
|
||||
<a href="/blog" class="text-sm text-accent hover:underline">Alle indlæg →</a>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-4">
|
||||
{posts.map((p) => (
|
||||
<PostCard href={`/blog/${p.id}`} title={p.data.title} date={p.data.date} description={p.data.description} tags={p.data.tags} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</Base>
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { SITE, PROJECTS } from '../consts';
|
||||
|
||||
type Repo = { name: string; description: string | null; html_url: string; language: string | null; stars_count: number; updated_at: string; fork: boolean; archived: boolean; private: boolean; };
|
||||
let repos: Repo[] = [];
|
||||
let error = false;
|
||||
try {
|
||||
const res = await fetch(`${SITE.giteaBase}/api/v1/users/${SITE.giteaUser}/repos?limit=50&sort=updated`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
if (res.ok) {
|
||||
const hide = new Set(PROJECTS.hide.map((s) => s.toLowerCase()));
|
||||
const data = (await res.json()) as Repo[];
|
||||
repos = data
|
||||
.filter((r) => !r.fork && !r.archived && !r.private && !hide.has(r.name.toLowerCase()));
|
||||
// pinned først (i opsat rækkefølge), resten efter senest opdateret
|
||||
const order = (n: string) => {
|
||||
const i = PROJECTS.pinned.findIndex((p) => p.toLowerCase() === n.toLowerCase());
|
||||
return i === -1 ? Infinity : i;
|
||||
};
|
||||
repos.sort((a, b) => {
|
||||
const oa = order(a.name), ob = order(b.name);
|
||||
if (oa !== ob) return oa - ob;
|
||||
return +new Date(b.updated_at) - +new Date(a.updated_at);
|
||||
});
|
||||
} else { error = true; }
|
||||
} catch { error = true; }
|
||||
---
|
||||
<Base title="Projekter" description="Udvalgte offentlige projekter fra min Gitea.">
|
||||
<h1 class="text-4xl font-bold tracking-tight">Projekter</h1>
|
||||
<p class="mt-3 text-muted">Hentet live fra min <a class="text-accent hover:underline" href={`${SITE.giteaBase}/${SITE.giteaUser}`}>Gitea</a>.</p>
|
||||
|
||||
{error && <p class="mt-8 rounded-xl border border-border bg-surface/60 p-5 text-muted">Kunne ikke hente repositories lige nu.</p>}
|
||||
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
{repos.map((r) => (
|
||||
<a href={r.html_url} target="_blank" rel="noopener"
|
||||
class="group rounded-2xl border border-border bg-surface/60 p-5 transition-colors hover:border-accent/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold tracking-tight group-hover:text-accent">{r.name}</h3>
|
||||
{r.stars_count > 0 && <span class="text-xs text-muted">★ {r.stars_count}</span>}
|
||||
</div>
|
||||
<p class="mt-2 line-clamp-3 text-sm text-muted">{r.description || 'Ingen beskrivelse.'}</p>
|
||||
{r.language && <div class="mt-3 text-xs text-accent">{r.language}</div>}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Base>
|
||||
@@ -0,0 +1,20 @@
|
||||
import rss from '@astrojs/rss';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { SITE } from '../consts';
|
||||
|
||||
export async function GET(context) {
|
||||
const posts = (await getCollection('blog', ({ data }) => !data.draft))
|
||||
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||
return rss({
|
||||
title: SITE.title,
|
||||
description: SITE.description,
|
||||
site: context.site,
|
||||
items: posts.map((p) => ({
|
||||
title: p.data.title,
|
||||
description: p.data.description,
|
||||
pubDate: p.data.date,
|
||||
link: `/blog/${p.id}/`,
|
||||
})),
|
||||
customData: `<language>da-dk</language>`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import PostCard from '../../components/PostCard.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('blog', ({ data }) => !data.draft);
|
||||
const tags = [...new Set(posts.flatMap((p) => p.data.tags))];
|
||||
return tags.map((tag) => ({
|
||||
params: { tag },
|
||||
props: { posts: posts.filter((p) => p.data.tags.includes(tag)).sort((a, b) => b.data.date.getTime() - a.data.date.getTime()), tag },
|
||||
}));
|
||||
}
|
||||
const { posts, tag } = Astro.props;
|
||||
---
|
||||
<Base title={`#${tag}`} description={`Indlæg om ${tag}.`}>
|
||||
<a href="/tags" class="text-sm text-muted hover:text-accent">← Alle tags</a>
|
||||
<h1 class="mt-3 text-4xl font-bold tracking-tight">#{tag}</h1>
|
||||
<div class="mt-8 grid gap-4">
|
||||
{posts.map((p) => (
|
||||
<PostCard href={`/blog/${p.id}`} title={p.data.title} date={p.data.date} description={p.data.description} tags={p.data.tags} />
|
||||
))}
|
||||
</div>
|
||||
</Base>
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Base from '../../layouts/Base.astro';
|
||||
|
||||
const posts = await getCollection('blog', ({ data }) => !data.draft);
|
||||
const counts = new Map<string, number>();
|
||||
for (const p of posts) for (const t of p.data.tags) counts.set(t, (counts.get(t) ?? 0) + 1);
|
||||
const tags = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||||
---
|
||||
<Base title="Tags" description="Alle emner på bloggen.">
|
||||
<h1 class="text-4xl font-bold tracking-tight">Tags</h1>
|
||||
<div class="mt-8 flex flex-wrap gap-2">
|
||||
{tags.map(([t, n]) => (
|
||||
<a href={`/tags/${t}`} class="rounded-full border border-border bg-surface/60 px-3.5 py-1.5 text-sm transition-colors hover:border-accent hover:text-accent">
|
||||
#{t} <span class="text-muted">({n})</span>
|
||||
</a>
|
||||
))}
|
||||
{tags.length === 0 && <p class="text-muted">Ingen tags endnu.</p>}
|
||||
</div>
|
||||
</Base>
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { SITE } from './consts';
|
||||
|
||||
export const personId = `${SITE.url}/#person`;
|
||||
export const websiteId = `${SITE.url}/#website`;
|
||||
|
||||
// Canonical Person entity — referenced by @id from every other entity.
|
||||
export const person = {
|
||||
'@type': 'Person',
|
||||
'@id': personId,
|
||||
name: 'Asger Geel Weirsøe',
|
||||
alternateName: 'Asger Weirsøe',
|
||||
url: `${SITE.url}/`,
|
||||
image: `${SITE.url}/asger.png`,
|
||||
jobTitle: 'Technical Solution Architect',
|
||||
worksFor: { '@type': 'Organization', name: 'Digisense A/S' },
|
||||
alumniOf: { '@type': 'CollegeOrUniversity', name: 'Aalborg Universitet' },
|
||||
knowsAbout: [
|
||||
'Peppol', 'Peppol Access Point', 'Peppol BIS', 'Peppol eDelivery', 'Oxalis',
|
||||
'NemHandel', 'Elektronisk fakturering', 'E-faktura', 'Electronic invoicing',
|
||||
'OIOUBL', 'UBL', 'e-Delivery (AS4)', 'EN 16931', 'E-procurement', 'EDI',
|
||||
'Softwarearkitektur', 'Løsningsarkitektur', 'Proxmox', 'Selvhosting',
|
||||
'Kunstig intelligens', 'Linux', 'DevOps', 'OIDC', 'SSO', 'Fuld-stack udvikling', 'Docker',
|
||||
],
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/in/ag-weirs%C3%B8e/',
|
||||
'https://gitea.weircon.dk/agw',
|
||||
'https://arxiv.org/abs/2310.00999',
|
||||
],
|
||||
description:
|
||||
'Datalog (cand.scient.) fra Aalborg Universitet og Technical Solution Architect hos Digisense A/S. Arbejder med elektronisk fakturering og e-procurement — Peppol (Access Point, eDelivery/AS4, Oxalis), NemHandel og OIOUBL — samt selvhosting, Proxmox og AI.',
|
||||
};
|
||||
|
||||
export const website = {
|
||||
'@type': 'WebSite',
|
||||
'@id': websiteId,
|
||||
url: `${SITE.url}/`,
|
||||
name: SITE.name,
|
||||
description: SITE.description,
|
||||
inLanguage: 'da-DK',
|
||||
publisher: { '@id': personId },
|
||||
};
|
||||
|
||||
// Default graph present on every page (Google de-dupes by @id).
|
||||
export const baseGraph = { '@context': 'https://schema.org', '@graph': [website, person] };
|
||||
|
||||
export function blogPosting(opts: {
|
||||
url: string; headline: string; description: string;
|
||||
datePublished: string; dateModified: string; tags: string[]; image: string;
|
||||
}) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: opts.headline,
|
||||
description: opts.description,
|
||||
url: opts.url,
|
||||
mainEntityOfPage: opts.url,
|
||||
datePublished: opts.datePublished,
|
||||
dateModified: opts.dateModified,
|
||||
inLanguage: 'da-DK',
|
||||
image: opts.image,
|
||||
keywords: opts.tags.join(', '),
|
||||
author: { '@id': personId },
|
||||
publisher: { '@id': personId },
|
||||
isPartOf: { '@id': websiteId },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
@import "tailwindcss";
|
||||
@import "@fontsource-variable/inter";
|
||||
|
||||
@theme {
|
||||
--color-bg: #0b0f17;
|
||||
--color-surface: #121a27;
|
||||
--color-surface-2: #182232;
|
||||
--color-border: #243044;
|
||||
--color-text: #e6e9ef;
|
||||
--color-muted: #97a3b6;
|
||||
--color-accent: #38bdf8;
|
||||
--color-accent-2: #818cf8;
|
||||
--font-sans: "Inter Variable", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* ---------- light theme ---------- */
|
||||
html { color-scheme: dark; }
|
||||
html[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--color-bg: #f6f8fb;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-2: #eef2f8;
|
||||
--color-border: #dde4ee;
|
||||
--color-text: #0f1729;
|
||||
--color-muted: #54617a;
|
||||
--color-accent: #0284c7;
|
||||
--color-accent-2: #4f46e5;
|
||||
}
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
body { background-color: var(--color-bg); color: var(--color-text); }
|
||||
|
||||
/* smooth cross-fade when toggling theme (no flash on load: theme is set pre-paint) */
|
||||
body, header, a, span, div, p, h1, h2, h3, li, ul, input, button, svg, section, article {
|
||||
transition: background-color .35s ease, border-color .35s ease, color .35s ease;
|
||||
}
|
||||
|
||||
::selection { background: color-mix(in srgb, var(--color-accent) 35%, transparent); }
|
||||
|
||||
a { color: inherit; }
|
||||
|
||||
/* subtle top-glow background */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed; inset: 0; z-index: -1; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(60rem 30rem at 70% -10%, color-mix(in srgb, var(--color-accent) 12%, transparent), transparent 70%),
|
||||
radial-gradient(50rem 30rem at 10% 0%, color-mix(in srgb, var(--color-accent-2) 10%, transparent), transparent 70%);
|
||||
}
|
||||
|
||||
/* ---------- animated theme toggle (sun ⇄ moon) ---------- */
|
||||
.theme-toggle {
|
||||
display: inline-grid; place-items: center;
|
||||
width: 2.25rem; height: 2.25rem; border-radius: 0.75rem;
|
||||
color: var(--color-muted); background: transparent; cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.theme-toggle:hover { color: var(--color-text); background: var(--color-surface-2); }
|
||||
.theme-toggle:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
||||
.sun-and-moon > :is(.moon, .sun, .sun-beams) { transform-origin: center; }
|
||||
.sun-and-moon > :is(.moon, .sun) { fill: currentColor; }
|
||||
.sun-and-moon > .sun-beams { stroke: currentColor; stroke-width: 2px; }
|
||||
.sun-and-moon > .sun { transition: transform .5s cubic-bezier(.5, 1.25, .75, 1.25); }
|
||||
.sun-and-moon > .sun-beams { transition: transform .5s cubic-bezier(.5, 1.5, .75, 1.25), opacity .5s cubic-bezier(.25, 0, .3, 1); }
|
||||
.sun-and-moon .moon > circle { transition: transform .25s cubic-bezier(0, 0, 0, 1); }
|
||||
@supports (cx: 1) { .sun-and-moon .moon > circle { transition: cx .25s cubic-bezier(0, 0, 0, 1); } }
|
||||
[data-theme="dark"] .sun-and-moon > .sun { transform: scale(1.75); transition-timing-function: cubic-bezier(.25, 0, .3, 1); transition-duration: .25s; }
|
||||
[data-theme="dark"] .sun-and-moon > .sun-beams { opacity: 0; transform: rotateZ(-25deg); transition-duration: .15s; }
|
||||
[data-theme="dark"] .sun-and-moon > .moon > circle { transform: translateX(-7px); transition-delay: .25s; transition-duration: .5s; }
|
||||
@supports (cx: 1) { [data-theme="dark"] .sun-and-moon > .moon > circle { cx: 17; transform: translateX(0); } }
|
||||
@media (prefers-reduced-motion: reduce) { .sun-and-moon * { transition: none !important; } }
|
||||
|
||||
/* ---------- markdown prose ---------- */
|
||||
.prose { color: var(--color-text); line-height: 1.75; font-size: 1.05rem; }
|
||||
.prose > * + * { margin-top: 1.1em; }
|
||||
.prose h2 { font-size: 1.6rem; font-weight: 700; margin-top: 2em; letter-spacing: -0.01em; }
|
||||
.prose h3 { font-size: 1.25rem; font-weight: 700; margin-top: 1.6em; }
|
||||
.prose a { color: var(--color-accent); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.prose code { background: var(--color-surface-2); padding: 0.15em 0.4em; border-radius: 0.35rem; font-size: 0.9em; }
|
||||
.prose pre { background: var(--color-surface-2); border: 1px solid var(--color-border); border-radius: 0.75rem; padding: 1rem 1.1rem; overflow-x: auto; }
|
||||
.prose pre code { background: none; padding: 0; }
|
||||
.prose blockquote { border-left: 3px solid var(--color-accent); padding-left: 1rem; color: var(--color-muted); font-style: italic; }
|
||||
.prose ul { list-style: disc; padding-left: 1.4em; }
|
||||
.prose ol { list-style: decimal; padding-left: 1.4em; }
|
||||
.prose img { border-radius: 0.75rem; border: 1px solid var(--color-border); }
|
||||
.prose hr { border-color: var(--color-border); }
|
||||
.prose table { width: 100%; border-collapse: collapse; }
|
||||
.prose th, .prose td { border: 1px solid var(--color-border); padding: 0.5rem 0.7rem; text-align: left; }
|
||||
Reference in New Issue
Block a user