odo is an early MVP skeleton for a Go-based, self-hostable, API-first FOSS library access middleware/proxy application.
The important architectural rule is that management and configuration happen through versioned JSON APIs. The admin UI at /admin uses those same APIs with fetch; it is not a separate control plane.
- A single compiled Go application.
- Local HTTP service on port 8080 by default.
- Versioned JSON APIs under
/api/v1. - Embedded SQLite persistence using
modernc.org/sqlite. - Resource registry import from dropped JSON config files.
- URL/domain rule testing.
- A minimal safe outbound
GET/HEADproxy for configured and allowed targets. - Partial HTML/CSS asset URL rewriting for basic proxied page rendering.
- Privacy-conscious first-pass request logging that avoids logging full query strings.
- A full JavaScript-aware browser compatibility proxy.
- A full admin login/session system. Management APIs use bearer API keys with hashed database storage and bootstrap/dev fallback support.
- A SAML/Shibboleth Service Provider.
- A production HA deployment.
- A complete audit implementation.
Odo is built with human direction and review, with some assistance from AI tools for drafting code, tests, documentation, examples, and design notes.
Because Odo concerns privacy, authentication, access control, and library infrastructure, AI-generated output is not treated as authoritative. All AI-assisted changes should be reviewed for correctness, security, privacy impact, maintainability, and alignment with the project’s goals.
The project maintainer and contributors remain responsible for what is committed to the repository.
- Local development: run
go run ./cmd/odoand openhttp://127.0.0.1:8080/admin. - Linux VM install: build a binary, install the systemd unit, keep config in
/etc/odo, data in/var/lib/odo, and logs in/var/log/odo. See Installing Odo on a Linux VM. - Container install: build the image, mount persistent data/config volumes, and put a reverse proxy in front. See Deploying Odo with a Container.
Run without a bootstrap API key for local development. Protected API endpoints still require an authenticated user session or a stored API key:
go run ./cmd/odoRun with an API key:
APP_ADMIN_API_KEY=devsecret go run ./cmd/odoFor stored API keys, set a hash secret:
APP_ADMIN_API_KEY=devsecret APP_KEY_HASH_SECRET='change-me-long-random-secret' go run ./cmd/odoOpen:
http://127.0.0.1:8080/admin
For local development, use go run ./cmd/odo. For a Linux VM or container behind a real FQDN, set APP_PUBLIC_URL, use a persistent data volume for SQLite, and keep config under a persistent config directory. Production deployments need real secrets for APP_ADMIN_API_KEY and APP_KEY_HASH_SECRET; do not expose a dev instance or devsecret publicly.
See Installing Odo on a Linux VM for binary/systemd installs, or Deploying Odo with a Container for Podman, Quadlet, persistent volume, and reverse proxy examples.
/api/v1 is the versioned API namespace and a safe, public JSON discovery endpoint. Its links expand to the routes available to a valid user session or API key; discovery does not grant access, and specific resources under /api/v1/{resource} retain their authentication and scope requirements.
The current OpenAPI 3.1 schema is served by the app at /openapi.yaml:
http://127.0.0.1:8080/openapi.yaml
Open http://127.0.0.1:8080/admin. The admin UI is organized into sections for Dashboard, Resources, Config, Diagnostics / Logs, API Keys, Users, Auth / SAML, and Settings / System.
The built-in admin UI is intentionally minimal: plain, readable, and focused on common sysadmin and library-staff tasks. All key operations use the documented /api/v1 JSON endpoints, so sites can build their own custom UI, scripts, or automation against the same API instead of customizing the bundled page.
Enter an APP_ADMIN_API_KEY bootstrap token or stored API key in the global Admin API Key field for protected actions. The key is kept only in the page runtime and is not stored in browser storage. Resources can still be created, edited, and deleted using a raw JSON editor.
API key management is available in the API Keys section. Newly created or rotated tokens are shown once with a copy warning and are not persisted by the UI. The UI uses the same documented /api/v1 endpoints available to scripts and integrations; it is not a separate control plane.
User management is available in the Users section. Admins can create local users, update roles/status, set passwords, lock/disable users, and revoke sessions through the same /api/v1/users endpoints used by scripts. Password hashes are never displayed.
The Resources section includes search, sort, filters, readable resource list rows, resource details, raw JSON editing, the Resource Config Builder, export controls, and integrated Proxy Test controls. It can generate JSON, validate it through /api/v1/resources/validate, save it through the normal resource API, export a resource-<id>.json file, or export the currently filtered resources as JSON.
API keys are still supported for scripts, automation, and control-plane integrations. Human admins can also sign in at /login with a local account and use /admin through their browser session without pasting an API key. The Admin API Key field remains available as an optional override/testing mode and is not stored in browser storage.
Local user roles determine which admin sections are available. Backend scopes enforce permissions even if a UI section is hidden. Regular user accounts use /resources, not /admin.
Initial role mapping:
super_adminor legacyadmin:adminsystems_admin: resources, config, diagnostics, logs, and system read accessresource_admin: resources/config write and diagnostics readsupport_staff: resources, diagnostics, and logs readsecurity_admin: user management plus logs and diagnostics readviewer: resources/config/diagnostics/system readuser: no admin scopes
When the admin UI uses a browser session for unsafe API calls, it sends X-Odo-CSRF. Bearer API-key requests do not require CSRF.
Use the admin Resource Config Builder for most additions. Start with a title, entry URL, and main domain, then generate and validate the JSON before saving. Use raw JSON for advanced cases such as header rules, anonymous URL rules, content rewrite rules, tags, or carefully reviewed compatibility settings. After saving, use the integrated Proxy Test in the Resources tab and Diagnostics to confirm the entry URL, search pages, detail pages, downloads, and any blocked or missed hosts. Use search, status/type/complexity filters, tags, and sorting to manage large collections and find complex resources.
See Adding Resources in Odo for a plain-language workflow and examples.
Environment variables:
APP_BIND_ADDR, preferred bind address for service installs, default:8080APP_ADDR, older name for the bind address; still supported whenAPP_BIND_ADDRis unsetAPP_ENV, defaultdevelopment; useproductionon serversAPP_DATA_DIR, default./datain development and/var/lib/odoin productionAPP_DB_PATH, default$APP_DATA_DIR/odo.db; production preference is/var/lib/odo/odo.dbAPP_CONFIG_DIR, default./configin development and/etc/odoin productionAPP_PUBLIC_URL, optional public base URL used for generated SAML SP metadata defaultsAPP_ADMIN_API_KEY, optional bootstrap/dev fallback for creating and managing stored API keysAPP_BOOTSTRAP_ADMIN_USERNAME, optional username used to create the first local admin user when no users existAPP_BOOTSTRAP_ADMIN_PASSWORD, optional password used to create the first local admin user when no users existAPP_BOOTSTRAP_ADMIN_EMAIL, optional email for the first local admin userAPP_PROXY_REQUIRE_LOGIN, default is enabled once local users exist; setfalsefor development-only anonymous proxy accessAPP_SESSION_PERSIST_ON_RESTART, defaulttruein production andfalsein development; whenfalse, existing browser sessions are rejected after Odo restartsAPP_SESSION_TTL_MINUTES, default480; absolute browser session lifetimeAPP_SESSION_IDLE_TIMEOUT_MINUTES, default60; idle browser session timeout based on throttledlast_seen_atupdatesAPP_KEY_HASH_SECRET, recommended secret used to HMAC stored API key tokens; if unset, local dev uses SHA-256 with a startup warningAPP_ACCESS_LOG_FORMAT, defaultprivacyAPP_ACCESS_LOG_PATH, optional path to append access logsAPP_PROXY_DEBUG, defaultfalse; whentrue,/odoadds safe cookie/session diagnostic count headers without exposing cookie valuesAPP_PROXY_URL_MODE, defaultpath; usequeryfor/odo?url=...compatibility links ordualto build path links while accepting both path and query formsAPP_PROXY_ALLOW_LOCAL_HTTP, defaultfalse; development-only loopback HTTP allowance forloadtest/fake vendor runs, never for productionAPP_VIRTUAL_HOST_BASE_DOMAIN, reserved for future virtual-host proxy mode and not active yetAPP_VIRTUAL_HOST_ENCODING, reserved for future virtual-host proxy mode and not active yetAPP_PROXY_MAX_BODY_BYTES, default10485760; maximum proxied POST request body sizeAPP_PROXY_INJECT_JS_SHIM, defaulttrue; injects a small same-originfetch()/XHR rewrite shim into proxied HTMLAPP_PROXY_REFERER_RECOVERY, defaulttrue; recovers missed local asset/script paths when a proxied Referer identifies the upstream hostAPP_TRUST_PROXY_HEADERS, defaultfalse; settrueonly behind a trusted reverse proxy that controlsX-Forwarded-*headers
Health:
curl -s http://127.0.0.1:8080/api/v1/healthValidate resource config files without writing to the database:
curl -X POST http://127.0.0.1:8080/api/v1/config/validate \
-H 'Authorization: Bearer devsecret' | jqImport resource config files:
curl -s -X POST http://127.0.0.1:8080/api/v1/config/import \
-H 'Authorization: Bearer devsecret' | jqList config revisions:
curl http://127.0.0.1:8080/api/v1/config/revisions \
-H 'Authorization: Bearer devsecret' | jqGet a config revision:
curl http://127.0.0.1:8080/api/v1/config/revisions/1 \
-H 'Authorization: Bearer devsecret' | jqList resources:
curl -s http://127.0.0.1:8080/api/v1/resourcesGet one resource:
curl http://127.0.0.1:8080/api/v1/resources/jstor | jqDelete a resource:
curl -X DELETE http://127.0.0.1:8080/api/v1/resources/jstor \
-H 'Authorization: Bearer devsecret' | jqTest a URL:
curl -s -X POST http://127.0.0.1:8080/api/v1/rules/test-url \
-H 'Content-Type: application/json' \
-d '{"url":"https://www.jstor.org/stable/example"}'Create or update a resource:
curl -s -X POST http://127.0.0.1:8080/api/v1/resources \
-H 'Authorization: Bearer devsecret' \
-H 'Content-Type: application/json' \
-d @config/resources/jstor.jsonMinimal proxy fetch:
curl -s 'http://127.0.0.1:8080/odo/https/www.jstor.org/stable/example'Admin proxy test fetch:
curl -X POST http://127.0.0.1:8080/api/v1/proxy/test-fetch \
-H 'Authorization: Bearer devsecret' \
-H 'Content-Type: application/json' \
-d '{"url":"https://www.jstor.org/"}' | jqRecent access logs:
curl http://127.0.0.1:8080/api/v1/logs/access/recent \
-H 'Authorization: Bearer devsecret' | jqAPP_ADMIN_API_KEY remains a bootstrap/dev fallback. For ongoing use, create database-backed API keys and send them as Authorization: Bearer <token>. Odo stores only a hash and short prefix, never the full token after creation. The full token is shown only when a key is created or rotated.
Create a stored key:
curl -X POST http://127.0.0.1:8080/api/v1/api-keys \
-H 'Authorization: Bearer devsecret' \
-H 'Content-Type: application/json' \
-d '{"name":"Local admin","scopes":["admin"]}' | jqUse the returned token:
curl http://127.0.0.1:8080/api/v1/config/revisions \
-H 'Authorization: Bearer odo_live_...' | jqRotate or revoke a key:
curl -X POST http://127.0.0.1:8080/api/v1/api-keys/key_abc123/rotate \
-H 'Authorization: Bearer odo_live_...' | jq
curl -X POST http://127.0.0.1:8080/api/v1/api-keys/key_abc123/revoke \
-H 'Authorization: Bearer odo_live_...' | jqInitial API key scopes are admin, api_keys:read, api_keys:write, resources:read, resources:write, config:read, config:write, diagnostics:read, logs:read, auth:read, auth:write, system:read, users:read, and users:write. The admin scope can access all management endpoints. Set APP_KEY_HASH_SECRET in persistent deployments so stored token hashes use HMAC-SHA256 instead of local-dev SHA-256.
Odo now has local user accounts for browser access to proxied resources. To create the first admin user on an empty database:
APP_BOOTSTRAP_ADMIN_USERNAME=admin \
APP_BOOTSTRAP_ADMIN_PASSWORD='change-me-long-random-password' \
APP_ADMIN_API_KEY=devsecret \
go run ./cmd/odoOpen http://127.0.0.1:8080/login to sign in. Signed-in users can open http://127.0.0.1:8080/resources for a simple patron resource portal. Browser sessions use an HttpOnly odo_session cookie and proxy browsing also keeps upstream/vendor cookies in a separate server-side jar.
In production, browser sessions can persist across Odo restarts because session rows live in SQLite until they expire or are revoked. In development, APP_SESSION_PERSIST_ON_RESTART defaults to false, so an existing browser cookie is rejected after a restart without deleting the session row. Set APP_SESSION_PERSIST_ON_RESTART=true when you want restart-persistent sessions during local testing. Use APP_SESSION_TTL_MINUTES and APP_SESSION_IDLE_TIMEOUT_MINUTES to control absolute and idle session limits. last_seen_at updates are throttled so normal browsing does not write the session row on every proxied asset request.
Once local users exist, /odo proxy access requires login by default. When APP_PROXY_REQUIRE_LOGIN=true, every /odo proxy request requires a valid browser session unless the target matches an explicit anonymous_url_rule. Resource homepages, entry URLs, active resource domains, and direct deep links are not anonymous by default. For local development only, disable that gate with:
APP_PROXY_REQUIRE_LOGIN=false go run ./cmd/odoUsers can click Odo resource links directly, such as /odo/https/www.jstor.org/stable/123456 or a resource homepage like /odo/https/www.jstor.org/. If they are not logged in, Odo sends browser navigation requests to /login with a safe local next path. After a successful login, the user returns to the original proxied URL, including the original path and query string.
Unsafe next values are rejected to prevent open redirects. Odo accepts local paths such as /resources, /admin, /odo/https/www.jstor.org/, and /odo?url=https%3A%2F%2Fwww.jstor.org%2F. Absolute URLs, scheme-relative URLs, malformed slash tricks, backslashes, and control characters fall back to /resources. A next=/admin redirect is honored only for users with admin-like scopes.
Fetch/API-style proxy requests receive a JSON login_required response instead of an HTML redirect. This keeps scripts and app data calls from accidentally receiving a login page as data.
Use APP_PROXY_REQUIRE_LOGIN=false only for local proxy testing. anonymous_url_rules remain narrow exceptions for public vendor assets and still pass the normal URL safety and resource checks.
Create a user through the management API:
curl -X POST http://127.0.0.1:8080/api/v1/users \
-H 'Authorization: Bearer devsecret' \
-H 'Content-Type: application/json' \
-d '{"username":"patron1","password":"change-me-too","roles":["user"],"status":"active"}' | jqList users or revoke a user's sessions:
curl http://127.0.0.1:8080/api/v1/users \
-H 'Authorization: Bearer devsecret' | jq
curl -X POST http://127.0.0.1:8080/api/v1/users/user_abc123/revoke-sessions \
-H 'Authorization: Bearer devsecret' | jqOdo is designed to act as a SAML Service Provider for campus/Shibboleth-style identity infrastructure. Full SAML login initiation and assertion validation are future work, but the MVP includes SAML provider configuration APIs, admin UI controls, and a Service Provider metadata endpoint.
Manage provider config through:
curl http://127.0.0.1:8080/api/v1/auth/saml/providers \
-H 'Authorization: Bearer devsecret' | jqThe public SP metadata endpoint is:
http://127.0.0.1:8080/auth/saml/metadata
Placeholder routes are also present for future integration:
GET /auth/saml/loginPOST /auth/saml/acs
A sample provider config lives at config/auth/saml/campus-shibboleth.json. The current scaffold omits signing certificates and does not validate SAML assertions yet.
The Resources tab includes an integrated Proxy Test panel that can test rule matching, open a target through /odo, or fetch a bounded body preview through the protected /api/v1/proxy/test-fetch endpoint. Selecting a resource prefills the test URL from its first entry URL.
Recent access logs are privacy-filtered and available through the admin UI. Proxy diagnostics are also exposed from the UI for checking blocked hosts, rewrite counts, and upstream status as those diagnostics grow. These tools are intended to make the access layer easier to understand and troubleshoot without exposing full target URLs, cookies, or authorization headers.
Modern sites may generate paths dynamically in JavaScript. These can appear as local paths outside /odo, such as /assets/app.js or /mfe-copper-roof/.../remoteEntry.js, when they should have been proxied.
By default, Odo can infer the upstream host from a proxied Referer and recover the request through the normal proxy path. Recovery is still subject to URL safety checks, DNS/IP safety validation, resource allowlists, and domain rule actions.
Missed asset, script, CSS, image, and API-like URLs may be silently recovered from the proxied Referer. Missed top-level document navigations, such as a clicked link that lands on /action/doAdvancedSearch, redirect to the canonical /odo/https/{host}/{path} URL instead. This keeps the browser address bar and future relative URL resolution inside the proxy. Query strings are preserved in the redirect, but privacy logs and missed-rewrite diagnostics avoid recording full query strings.
Disable referer recovery with:
APP_PROXY_REFERER_RECOVERY=falseUse browser DevTools plus Load Missed Rewrites in the admin UI to inspect recovered, redirected, denied, and unrecovered missed rewrite events.
Section, search, and landing pages may depend on JavaScript chunks, route manifests, JSON data routes, and API calls before the full header or navigation appears. Article pages may work earlier because they are often more server-rendered.
Odo classifies and recovers common app/data paths from a proxied Referer, including _next data routes, manifests, mfe- module chunks, remoteEntry.js, /static/, /assets/, /api/, and /graphql requests. These requests are silently proxied when they are safe and proxyable; document navigations still redirect to canonical /odo/https/{host}/{path} URLs. Use browser DevTools Network together with Load Missed Rewrites and Load Proxy Diagnostics to inspect section-page failures.
Access logs default to privacy-filtered output on stdout. Privacy mode logs request metadata and safe proxy decisions without full query strings, target URLs, article URLs, search terms, or reading-history-like paths.
Available formats:
APP_ACCESS_LOG_FORMAT=privacy
APP_ACCESS_LOG_FORMAT=common
APP_ACCESS_LOG_FORMAT=combined
APP_ACCESS_LOG_FORMAT=jsonWrite access logs to a file by setting:
APP_ACCESS_LOG_PATH=/path/to/access.logExample:
APP_ACCESS_LOG_FORMAT=json go run ./cmd/odoOdo is default-deny for proxy/access decisions. Proxy targets must be HTTPS URLs that match configured resource domains. Raw IP hosts, localhost, private networks, link-local addresses, non-global addresses, suspicious internal hostnames such as .local and .internal, URL userinfo, fragments, wildcards, and non-default ports are blocked before the proxy fetch is allowed.
/odo/https/{host}/{path} is the preferred local/MVP public proxy route. /odo?url=https://... is still accepted for compatibility and manual testing. Clicked links, asset references, GET form actions, and validated upstream redirects are generated through one proxy URL builder and rewritten back through /odo so the patron's browser continues to talk to Odo and vendors continue to see Odo's outbound IP rather than the patron's IP.
Path mode is the default:
APP_PROXY_URL_MODE=pathQuery compatibility mode can be selected with:
APP_PROXY_URL_MODE=queryDual mode builds path-mode URLs while continuing to accept older query-mode URLs:
APP_PROXY_URL_MODE=dualPath mode is the default and recommended MVP mode. Query mode is available for compatibility and development. Virtual-host mode is planned for better compatibility with difficult sites that need vendor-like browser origins, but it is not implemented yet. Do not deploy virtual-host mode until wildcard DNS and wildcard TLS are configured and Odo has host-based proxy routing support.
See Future Virtual-Host Proxying for the design notes and migration plan.
Unknown local paths now return 404 instead of redirecting to /admin unless referer-based recovery applies, which makes missed rewrites easier to spot during testing. Missed document navigations redirect to canonical /odo/https/{host}/{path} URLs, while missed assets can be silently proxied. Virtual-host mode may be added later for institution-specific host-based access URLs such as www-economist-com.access.library.edu.
/odo performs a minimal safe outbound GET/HEAD/POST proxy. HTML href, src, action, and common asset attributes are rewritten when they point to safe, allowlisted proxy targets. srcset is partially supported. CSS url(...) references are partially rewritten for text/css responses and inline style attributes.
Odo keeps a server-side per-session cookie jar for proxied browsing. The browser receives only an odo_proxy_sid cookie; upstream/vendor cookies are stored server-side and are not exposed directly to the browser. This improves continuity across proxied requests and POST form submissions, but it is not user authentication. In HA deployments, the in-memory session store would need Redis or another shared session store.
Local browser login is separate from vendor cookie handling. Once local users exist, /odo requires a valid odo_session by default so patrons use Odo as the access path instead of reaching vendors directly. Narrow anonymous URL rules can still allow explicitly configured public proxy URLs.
POST form submissions are forwarded upstream when the target is safe and proxyable. Request bodies are size-limited by APP_PROXY_MAX_BODY_BYTES, and request bodies/form values are not logged. Full JavaScript rewriting, WebSockets, and full SPA compatibility are future work. Only a small set of safe request and response headers are copied. Redirects are validated before returning a local proxied redirect, which defaults to /odo/https/{host}/{path}. Content-Security-Policy is not copied yet, and integrity attributes are removed when URLs are rewritten, because upstream CSP and SRI often reject proxied/transformed assets before fuller policy rewriting exists.
Odo injects a small JavaScript shim into proxied HTML pages by default. The shim rewrites same-origin fetch() and XMLHttpRequest calls back through /odo, which helps modern sites that render headers, search boxes, menus, and consent UI through JavaScript. This is not full JavaScript rewriting, and server-side URL validation remains authoritative. Disable it with:
APP_PROXY_INJECT_JS_SHIM=falseSee Local Load Testing for the fake vendor server, sample resource config, k6 smoke/idle/active/spike/soak scripts, runtime metrics endpoint, and SQLite/session notes. The load-test setup is local-only and must not be used against real vendor sites.
Resource domain rules support roles and actions. Existing configs without action still work: blocked defaults to block, external defaults to allow, and all other roles default to proxy.
Common combinations:
content/proxyasset/proxyapi/proxyauth/proxyredirect/allowexternal/allowblocked/block
Broad subdomain proxy rules are useful for library resources, but explicit block rules should be added for analytics, tracking, ads, or unrelated third-party domains discovered during diagnostics. More specific exact rules take precedence over broader subdomain rules, and explicit blocks win when specificity is equal or greater.
Build:
podman build -t odo:dev .Run with mounted data and config directories. The :Z suffix lets Podman relabel the mounts for SELinux on Fedora:
mkdir -p data config/resources
podman run --rm -p 8080:8080 \
-e APP_ADMIN_API_KEY=dev-secret \
-v "$PWD/data:/data:Z" \
-v "$PWD/config:/config:Z" \
odo:dev- Hardened API-key storage and rotation.
- SAML SP support as a first-class module.
- Signed proxy links.
- Deeper JavaScript-aware proxy rewriting.
- HA with PostgreSQL and Redis.