Add optional API key auth to the server
build / build (push) Successful in 3m4s

Deployments reachable outside a trusted LAN need some form of access
control. Set FUELISH_API_KEY and the server requires a matching
X-API-Key header on /jobs; unset, behavior is unchanged (no auth).
The Android app gets a matching Settings field. This only checks the
key - TLS termination is left to a reverse proxy in front of the
server, not handled here.
This commit is contained in:
ml
2026-10-04 11:41:55 +02:00
parent ca85530b5a
commit 7a91a37049
13 changed files with 111 additions and 25 deletions
+7
View File
@@ -0,0 +1,7 @@
# Copy to .env (gitignored, never commit the real value) to require an
# API key on the server's /jobs endpoint - see docker-compose.yml and
# tooling-container/src/server.py. Leave FUELISH_API_KEY unset/empty for
# the original LAN-only/no-auth setup.
#
# Generate one with: openssl rand -hex 32
FUELISH_API_KEY=
+8 -1
View File
@@ -66,8 +66,15 @@ jobs:
set -euo pipefail set -euo pipefail
APK="$(ls dist/fuelish-*.apk)" APK="$(ls dist/fuelish-*.apk)"
mkdir -p ~/.ssh mkdir -p ~/.ssh
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key printf '%s\n' '${{ secrets.DL_SFTP_KEY }}' > ~/.ssh/dl_sftp_key
chmod 600 ~/.ssh/dl_sftp_key chmod 600 ~/.ssh/dl_sftp_key
# Sanity-check the key is actually loadable before attempting the
# SFTP connection - fails fast with a clear message instead of
# SFTP's much less obvious "Permission denied" when the key
# material itself is malformed (e.g. mangled by how the secret
# was entered into Gitea - see DL_SFTP_KEY in README.md).
ssh-keygen -y -f ~/.ssh/dl_sftp_key > /dev/null \
|| { echo "FAIL: ~/.ssh/dl_sftp_key is not a valid private key - re-check the DL_SFTP_KEY secret" >&2; exit 1; }
sftp -i ~/.ssh/dl_sftp_key -P 2223 \ sftp -i ~/.ssh/dl_sftp_key -P 2223 \
-o StrictHostKeyChecking=accept-new \ -o StrictHostKeyChecking=accept-new \
uploader@dl.ladkau.de <<EOF uploader@dl.ladkau.de <<EOF
+3
View File
@@ -4,6 +4,9 @@
# Local registry credentials (see registry.env.example) # Local registry credentials (see registry.env.example)
/registry.env /registry.env
# Local API key for docker-compose.yml's api service (see .env.example)
/.env
# Local Claude Code settings # Local Claude Code settings
/.claude/settings.local.json /.claude/settings.local.json
+14 -5
View File
@@ -157,12 +157,21 @@ is found next to the photo):
docker compose up -d docker compose up -d
``` ```
By default this has no auth (fine for a LAN-only setup - see the app's
`network_security_config.xml` for the HTTP-only/private-IP-range
restriction this implies). For a deployment reachable outside a trusted
LAN, copy `.env.example` to `.env`, set `FUELISH_API_KEY` (e.g.
`openssl rand -hex 32`), and enter the same key in the Android app's
Settings tab alongside the server URL - every request then needs a
matching `X-API-Key` header (see `tooling-container/src/server.py`).
This only checks the key; it doesn't add TLS - put a reverse proxy in
front of the server for anything public.
Then, in the Android app's Settings tab, point it at Then, in the Android app's Settings tab, point it at
`http://<this-host>:8080` (both HTTP-only - see the app's `http://<this-host>:8080`. Take a photo, watch the progress (upload →
`network_security_config.xml` - and LAN-only by design, no auth/TLS). preprocess+OCR → extract), correct anything the model got wrong, and
Take a photo, watch the 4-stage progress (upload → preprocess → OCR → save. The Receipts tab lists everything saved so far and exports to CSV
extract), correct anything the model got wrong, and save. The Receipts or `.ods`.
tab lists everything saved so far and exports to CSV or `.ods`.
## License ## License
@@ -32,13 +32,14 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
.readTimeout(3, TimeUnit.SECONDS) .readTimeout(3, TimeUnit.SECONDS)
.build() .build()
suspend fun submitImage(baseUrl: String, imageFile: File): String = withContext(Dispatchers.IO) { suspend fun submitImage(baseUrl: String, apiKey: String, imageFile: File): String = withContext(Dispatchers.IO) {
try { try {
val body = MultipartBody.Builder() val body = MultipartBody.Builder()
.setType(MultipartBody.FORM) .setType(MultipartBody.FORM)
.addFormDataPart("image", imageFile.name, imageFile.asRequestBody("image/jpeg".toMediaType())) .addFormDataPart("image", imageFile.name, imageFile.asRequestBody("image/jpeg".toMediaType()))
.build() .build()
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs").post(body).build() val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs").post(body)
.withApiKey(apiKey).build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}") if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}")
val text = response.body?.string().orEmpty() val text = response.body?.string().orEmpty()
@@ -50,8 +51,9 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
} }
} }
suspend fun getJobStatus(baseUrl: String, jobId: String): JobStatusResponse = withContext(Dispatchers.IO) { suspend fun getJobStatus(baseUrl: String, apiKey: String, jobId: String): JobStatusResponse = withContext(Dispatchers.IO) {
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs/$jobId").get().build() val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs/$jobId").get()
.withApiKey(apiKey).build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("status check failed: HTTP ${response.code}") if (!response.isSuccessful) throw IOException("status check failed: HTTP ${response.code}")
val text = response.body?.string().orEmpty() val text = response.body?.string().orEmpty()
@@ -84,3 +86,9 @@ sealed interface HealthCheckResult {
data object Ok : HealthCheckResult data object Ok : HealthCheckResult
data class Failed(val message: String) : HealthCheckResult data class Failed(val message: String) : HealthCheckResult
} }
/** Matches server.py's require_api_key(): omits the header entirely for
* a blank key rather than sending an empty one, so a server with no
* FUELISH_API_KEY configured behaves identically either way. */
private fun Request.Builder.withApiKey(apiKey: String): Request.Builder =
if (apiKey.isBlank()) this else header("X-API-Key", apiKey)
@@ -1,10 +1,14 @@
package de.ladkau.fuelish.data package de.ladkau.fuelish.data
/** /**
* Everything the app needs that isn't a saved receipt: just the fuelish * Everything the app needs that isn't a saved receipt: the fuelish
* server's base URL (e.g. "http://192.168.1.10:8080") right now. * server's base URL (e.g. "http://192.168.1.10:8080") and, if the server
* has FUELISH_API_KEY set (see tooling-container/src/server.py), the
* matching key to send as X-API-Key. Blank apiKey is fine against a
* server with no key configured - that's the original no-auth setup.
* Persisted via Preferences DataStore, see [ConfigRepository]. * Persisted via Preferences DataStore, see [ConfigRepository].
*/ */
data class AppConfig( data class AppConfig(
val serverUrl: String = "", val serverUrl: String = "",
val apiKey: String = "",
) )
@@ -15,15 +15,20 @@ private val Context.configDataStore: DataStore<Preferences> by preferencesDataSt
class ConfigRepository(private val context: Context) { class ConfigRepository(private val context: Context) {
private object Keys { private object Keys {
val SERVER_URL = stringPreferencesKey("server_url") val SERVER_URL = stringPreferencesKey("server_url")
val API_KEY = stringPreferencesKey("api_key")
} }
val config: Flow<AppConfig> = context.configDataStore.data.map { prefs -> val config: Flow<AppConfig> = context.configDataStore.data.map { prefs ->
AppConfig(serverUrl = prefs[Keys.SERVER_URL] ?: "") AppConfig(
serverUrl = prefs[Keys.SERVER_URL] ?: "",
apiKey = prefs[Keys.API_KEY] ?: "",
)
} }
suspend fun save(config: AppConfig) { suspend fun save(config: AppConfig) {
context.configDataStore.edit { prefs -> context.configDataStore.edit { prefs ->
prefs[Keys.SERVER_URL] = config.serverUrl prefs[Keys.SERVER_URL] = config.serverUrl
prefs[Keys.API_KEY] = config.apiKey
} }
} }
} }
@@ -15,9 +15,11 @@ class ReceiptRepository(context: Context) {
suspend fun getById(id: Long): Receipt? = dao.getById(id) suspend fun getById(id: Long): Receipt? = dao.getById(id)
suspend fun submitImage(baseUrl: String, imageFile: File): String = api.submitImage(baseUrl, imageFile) suspend fun submitImage(baseUrl: String, apiKey: String, imageFile: File): String =
api.submitImage(baseUrl, apiKey, imageFile)
suspend fun pollJob(baseUrl: String, jobId: String): JobStatusResponse = api.getJobStatus(baseUrl, jobId) suspend fun pollJob(baseUrl: String, apiKey: String, jobId: String): JobStatusResponse =
api.getJobStatus(baseUrl, apiKey, jobId)
suspend fun save(receipt: Receipt): Long = dao.insert(receipt) suspend fun save(receipt: Receipt): Long = dao.insert(receipt)
@@ -36,23 +36,24 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch { viewModelScope.launch {
_uiState.value = CaptureUiState.Uploading _uiState.value = CaptureUiState.Uploading
try { try {
val baseUrl = configRepository.config.first().serverUrl val config = configRepository.config.first()
val baseUrl = config.serverUrl
if (baseUrl.isBlank()) { if (baseUrl.isBlank()) {
_uiState.value = CaptureUiState.Error("Set the server URL in Settings first") _uiState.value = CaptureUiState.Error("Set the server URL in Settings first")
return@launch return@launch
} }
val jobId = repository.submitImage(baseUrl, imageFile) val jobId = repository.submitImage(baseUrl, config.apiKey, imageFile)
pollUntilDone(baseUrl, jobId) pollUntilDone(baseUrl, config.apiKey, jobId)
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = CaptureUiState.Error(e.message ?: "unknown error") _uiState.value = CaptureUiState.Error(e.message ?: "unknown error")
} }
} }
} }
private suspend fun pollUntilDone(baseUrl: String, jobId: String) { private suspend fun pollUntilDone(baseUrl: String, apiKey: String, jobId: String) {
var lastStage: String? = null var lastStage: String? = null
while (true) { while (true) {
val status = repository.pollJob(baseUrl, jobId) val status = repository.pollJob(baseUrl, apiKey, jobId)
when (status.status) { when (status.status) {
JobStatusResponse.STATUS_DONE -> { JobStatusResponse.STATUS_DONE -> {
AppLog.log("Job $jobId done") AppLog.log("Job $jobId done")
@@ -15,6 +15,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.fuelish.R import de.ladkau.fuelish.R
@@ -38,6 +39,14 @@ fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField(
value = config.apiKey,
onValueChange = { viewModel.update { c -> c.copy(apiKey = it) } },
label = { Text(stringResource(R.string.settings_api_key)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { viewModel.save() }, enabled = isDirty) { Button(onClick = { viewModel.save() }, enabled = isDirty) {
Text(stringResource(R.string.capture_save)) Text(stringResource(R.string.capture_save))
@@ -44,6 +44,7 @@
<string name="settings_server_url">Server URL</string> <string name="settings_server_url">Server URL</string>
<string name="settings_server_url_example">http://192.168.1.10:8080</string> <string name="settings_server_url_example">http://192.168.1.10:8080</string>
<string name="settings_api_key">API key (leave blank if the server has none set)</string>
<string name="settings_test_connection">Test connection</string> <string name="settings_test_connection">Test connection</string>
<string name="settings_test_testing">Testing…</string> <string name="settings_test_testing">Testing…</string>
<string name="settings_test_success">Connected successfully</string> <string name="settings_test_success">Connected successfully</string>
+8
View File
@@ -9,6 +9,13 @@
# #
# Usage: docker compose up -d # Usage: docker compose up -d
# API reachable at http://<this-host>:8080 from any device on the LAN. # API reachable at http://<this-host>:8080 from any device on the LAN.
#
# For a deployment reachable outside a trusted LAN, copy .env.example to
# .env and set FUELISH_API_KEY - every /jobs request then needs a
# matching X-API-Key header (see tooling-container/src/server.py).
# Unset (the default), the server has no auth, same as before. This
# server does not terminate TLS itself - put a reverse proxy in front of
# it for anything public.
services: services:
ollama: ollama:
image: ollama/ollama image: ollama/ollama
@@ -26,6 +33,7 @@ services:
OLLAMA_URL: http://ollama:11434/api/chat OLLAMA_URL: http://ollama:11434/api/chat
OLLAMA_BASE_URL: http://ollama:11434 OLLAMA_BASE_URL: http://ollama:11434
OLLAMA_MODEL: qwen2.5:3b OLLAMA_MODEL: qwen2.5:3b
FUELISH_API_KEY: ${FUELISH_API_KEY:-}
command: ["uvicorn", "server:app", "--app-dir", "/opt/app/src", "--host", "0.0.0.0", "--port", "8000"] command: ["uvicorn", "server:app", "--app-dir", "/opt/app/src", "--host", "0.0.0.0", "--port", "8000"]
ports: ports:
- "8080:8000" - "8080:8000"
+27 -5
View File
@@ -10,8 +10,13 @@ Jobs are processed asynchronously so the app can show real per-stage
progress: POST /jobs returns immediately with a job id; GET /jobs/{id} progress: POST /jobs returns immediately with a job id; GET /jobs/{id}
reports which of preprocessing/ocr/extracting is currently running, and reports which of preprocessing/ocr/extracting is currently running, and
the final result once done. Job state lives in memory only - this is a the final result once done. Job state lives in memory only - this is a
personal, LAN-only, single-instance server with no need for persistence personal, single-instance server with no need for persistence here (the
here (the Android app is the system of record for confirmed receipts). Android app is the system of record for confirmed receipts).
Auth: set FUELISH_API_KEY to require a matching X-API-Key header on
/jobs (GET /health stays open for reverse-proxy health checks). Leave it
unset for the original LAN-only/no-auth setup. This server does not
terminate TLS itself - see require_api_key()'s docstring.
""" """
import os import os
import shutil import shutil
@@ -21,17 +26,34 @@ import time
import uuid import uuid
import requests import requests
from fastapi import FastAPI, HTTPException, UploadFile from fastapi import Depends, FastAPI, HTTPException, Security, UploadFile
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security.api_key import APIKeyHeader
from adaptive import preprocess_and_ocr_adaptive from adaptive import preprocess_and_ocr_adaptive
from extract import structure from extract import structure
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434") OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b") OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b")
API_KEY = os.environ.get("FUELISH_API_KEY", "")
app = FastAPI(title="fuelish") app = FastAPI(title="fuelish")
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
def require_api_key(provided: str | None = Security(_api_key_header)) -> None:
"""No-op when FUELISH_API_KEY isn't set - the unchanged default for a
LAN-only deployment with no key configured. When it is set, every
request to a protected route must carry a matching X-API-Key header.
This only checks the key; it doesn't terminate TLS - a reverse proxy
in front of this server (e.g. Traefik) is expected to do that for any
deployment reachable outside a trusted LAN."""
if not API_KEY:
return
if provided != API_KEY:
raise HTTPException(status_code=401, detail="invalid or missing API key")
_jobs: dict[str, dict] = {} _jobs: dict[str, dict] = {}
_jobs_lock = threading.Lock() _jobs_lock = threading.Lock()
@@ -96,7 +118,7 @@ def _process_job(job_id: str, image_path: str, scratch_dir: str) -> None:
shutil.rmtree(scratch_dir, ignore_errors=True) shutil.rmtree(scratch_dir, ignore_errors=True)
@app.post("/jobs", status_code=201) @app.post("/jobs", status_code=201, dependencies=[Depends(require_api_key)])
async def create_job(image: UploadFile) -> JSONResponse: async def create_job(image: UploadFile) -> JSONResponse:
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
scratch_dir = tempfile.mkdtemp(prefix=f"fuelish-{job_id}-") scratch_dir = tempfile.mkdtemp(prefix=f"fuelish-{job_id}-")
@@ -113,7 +135,7 @@ async def create_job(image: UploadFile) -> JSONResponse:
return JSONResponse({"job_id": job_id}, status_code=201) return JSONResponse({"job_id": job_id}, status_code=201)
@app.get("/jobs/{job_id}") @app.get("/jobs/{job_id}", dependencies=[Depends(require_api_key)])
def get_job(job_id: str) -> dict: def get_job(job_id: str) -> dict:
with _jobs_lock: with _jobs_lock:
job = _jobs.get(job_id) job = _jobs.get(job_id)