Vai al contenuto
Learn & Publish
Indietro

Hardening di Ollama con systemd su un portatile: da 9.4 a 2.2 con la GPU attiva

Su un portatile con GPU integrata Intel, un servizio Ollama utente è sceso da un’esposizione di 9.4 a 2.2 senza perdere l’accelerazione. Il risultato vale per questa macchina, ma il metodo è riusabile. Il guasto più insidioso della prova, infatti, non ha prodotto né errori né warning.

Il punto di partenza

ollama.service gira come servizio utente su CachyOS, con systemd 262.1 e Ollama 0.40.9. Ascolta su 127.0.0.1:11434, usa la GPU via Vulkan attraverso /dev/dri e tiene modelli e chiave privata id_ed25519 in ~/.ollama. La unit contiene ExecStart=, Restart=on-failure, After=network-online.target, i limiti MemoryMax=24G e MemorySwapMax=2G e alcune variabili d’ambiente. Non ha direttive di hardening.

systemd-analyze --user security ollama.service assegna 9.4 UNSAFE. Il manuale chiarisce come leggere il dato: un’esposizione alta indica poco sandboxing applicato da systemd, non che il servizio sia vulnerabile.

Il manuale avverte anche che alcune impostazioni di namespace non funzionano da sole nei servizi utente, e che la maggior parte funziona insieme a PrivateUsers=true. Il test quindi non poteva fermarsi al punteggio: andava controllato anche il processo reale.

Il metodo

Le direttive sono state aggiunte a gruppi cumulativi in un drop-in separato, con un riavvio a ogni passo. Se un gruppo falliva, le sue direttive venivano riprovate una per una. Così una rottura si può ricondurre al gruppo responsabile, senza cercarla tra tutte le direttive.

A ogni passo sono stati eseguiti quattro controlli:

  1. una risposta di chat;
  2. il modello caricato al 100% in VRAM;
  3. un embedding;
  4. una scrittura in ~/.ollama, copiando ed eliminando un modello.

Il secondo controllo è quello decisivo. Un servizio attivo che risponde non dimostra di usare ancora la GPU.

Alla fine il drop-in è stato rimosso e la unit è tornata com’era: la configurazione è stata provata, non adottata in modo permanente.

Cosa ha funzionato

Il punteggio è sceso da 9.4 a 2.2 con la GPU sempre al 100%. I passi che pesano di più sono Capability (-2.0), Kernel e System call (-1.5 ciascuno) e Namespace (-0.8). Filesystem e Home nascosta valgono solo -0.2 e -0.1, anche se sono tra le protezioni più concrete: il peso nel punteggio non coincide con l’utilità pratica.

Passo cumulativoEsposizioneGPUEsito
Nessuna direttiva9.4 UNSAFE100%ok
Privilegi8.9 EXPOSED100%ok
Kernel7.4 MEDIUM100%ok
/tmp privata7.2 MEDIUM100%ok
Filesystem di sola lettura7.0 MEDIUM100%ok
Home nascosta6.9 MEDIUM100%ok
Famiglie di indirizzi6.5 MEDIUM100%ok
Namespace5.7 MEDIUM100%ok
Capability3.7 OK100%ok
System call2.2 OK100%ok
MemoryDenyWriteExecute=2.2 OK100%ok
PrivateDevices= (scartata)1.9 OK0%fallito

MemoryDenyWriteExecute=yes era il candidato più sospetto, perché il manuale lo segnala come incompatibile con il codice generato a runtime. Qui non ha causato problemi nei quattro controlli né nelle prove di caricamento e generazione, ma i carichi prolungati non sono stati provati.

Il punteggio legge la configurazione, non il processo

systemd-analyze valuta le impostazioni della unit, quindi 2.2 non dimostra da solo che i limiti siano attivi. Per questo lo stato del processo è stato letto da /proc, con e senza hardening.

Proprietà del processoUnit originaleCon hardening
NoNewPrivs01
Seccomp02, con 28 filtri
CapBndcompletovuoto
Namespace utente e di mountquelli dell’hostpropri
Root del filesystemscrivibilesola lettura
~/.ssh e ~/.configvisibiliassenti
~/.ollamascrivibilescrivibile

Su questa macchina le direttive sono applicate davvero, nonostante l’avvertenza del manuale sui servizi utente: il processo vede solo l’UID 1000, in un namespace utente proprio. Su altri sistemi o kernel il risultato può cambiare, quindi questa lettura va ripetuta. Il namespace di rete invece resta quello dell’host: RestrictAddressFamilies= limita le famiglie di indirizzi, ma non isola il servizio dalla rete.

Cosa si rompe

PrivateDevices=yes è il caso da ricordare. Il manuale spiega che crea un nuovo /dev con i soli pseudo-dispositivi e senza dispositivi fisici. Il punteggio migliora a 1.9, il servizio resta attivo e risponde, ma il modello finisce allo 0% di GPU. La velocità del test scende da 18,4 a 9,9 token al secondo.

Nel journal di quell’avvio non ci sono né warning né errori. L’unico indizio è una riga di livello INFO, inference compute id=cpu library=cpu. Chi controlla solo lo stato del servizio o gli errori non se ne accorge. Il manuale non nomina /dev/dri, quindi il legame con quel percorso è una deduzione coerente con la misura.

Il secondo problema è più sottile. ProtectHome=tmpfs monta un tmpfs di sola lettura sulle home, e Mesa non può più creare la cache degli shader:

Failed to create ~/.cache for shader cache (Read-only file system)---disabling.

La GPU funziona lo stesso, ma nella prova i caricamenti a freddo si sono allungati. Rendendo disponibile solo ~/.cache/mesa_shader_cache con BindPaths= e ReadWritePaths=, l’avviso è sparito e i tempi sono tornati in linea con l’originale.

ConfigurazioneEsposizioneCaricamento 35bCaricamento 4bGenerazione 35b
Unit originale9.411,7 s4,3 s15,4 tok/s
Con hardening2.215,6 s6,3 s14,7 tok/s
Con hardening e bind della cache2.210,7 s4,0 s15,0 tok/s

I valori sono medie di due run. Le differenze di velocità rientrano nel rumore di misura. Per i caricamenti due run bastano a mostrare la tendenza, non a isolarne la causa: il legame con la cache degli shader è l’ipotesi più coerente.

Cosa il punteggio non dice

Il passo Capability vale 2 punti, ma è in gran parte nominale: il processo aveva già 0 capability effettive, e il bounding set svuotato non cambia ciò che faceva. Il punteggio finale quindi va letto come misura delle protezioni attive, non del rischio residuo.

Restano anche limiti concreti. La chiave id_ed25519 è ancora leggibile dal servizio, perché ~/.ollama deve restare accessibile. Il namespace di rete è quello dell’host: ascoltare su 127.0.0.1 riguarda le connessioni in entrata, quindi non limita di per sé ciò che il processo può contattare in uscita.

Non sono stati provati ollama pull, IPAddressDeny=, UMask=, DeviceAllow= e i carichi prolungati. I dati vengono da un solo portatile e da due run per configurazione. Se i modelli stanno fuori da ~/.ollama, i percorsi vanno adattati.

La configurazione provata

Le direttive sono raggruppate per passo. ReadWritePaths= e BindPaths= compaiono due volte, come nella prova: le righe si sommano. Con ProtectHome=tmpfs il bind è necessario, perché senza di esso quei percorsi non esisterebbero dentro il servizio. ~/.ollama e ~/.cache/mesa_shader_cache devono già esistere prima del riavvio: un percorso mancante può impedire l’avvio della unit.

[Service]
# Privilegi
NoNewPrivileges=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
# Kernel
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectHostname=yes
# /tmp privata
PrivateTmp=yes
# Filesystem di sola lettura
ProtectSystem=strict
ReadWritePaths=%h/.ollama
# Home nascosta
ProtectHome=tmpfs
BindPaths=%h/.ollama
# Famiglie di indirizzi
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
# Namespace
RestrictNamespaces=yes
# Capability
CapabilityBoundingSet=
AmbientCapabilities=
# System call
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
# Memoria
MemoryDenyWriteExecute=yes
# Cache degli shader
ReadWritePaths=%h/.cache/mesa_shader_cache
BindPaths=%h/.cache/mesa_shader_cache

Il blocco va in un drop-in, per esempio con systemctl --user edit ollama.service, seguito da un riavvio del servizio. Rimuovendo il drop-in e riavviando si torna alla unit originale. Per verificarla bastano tre comandi, dopo aver caricato un modello:

systemd-analyze --user security ollama.service
ollama ps
grep -E 'NoNewPrivs|Seccomp|CapBnd' /proc/$(systemctl --user show ollama -p MainPID --value)/status

La colonna PROCESSOR di ollama ps deve indicare 100% GPU.

Fonti


Condividi questo articolo: