Files
a13labs.infra/.opencode/plans/tls-oauth2-sso-dev-02.md
T
alexandre.pires 8724360fb2 Enhance infrastructure and application configurations
- Updated AGENTS.md to include new directories for execution plans and implemented feature reports.
- Modified Ansible host variables for dev-02 and gpu-01 to change API endpoints and add new configurations for Scaleway.
- Adjusted firewall rules in gpu-01 to allow HTTPS traffic instead of the previous Ollama port.
- Added OAuth2 proxy configurations to gpu-01 for enhanced security.
- Removed deprecated open-webui module from Terraform app configurations.
- Updated Terraform configurations to reflect changes in local variables and removed unused secrets.
- Created a new sectool.json file for CloudNS integration with Bitwarden.
- Enhanced SELinux configurations for nginx to allow connections to any port and added oauth2-proxy port.
2026-07-12 16:28:45 -04:00

25 KiB

TLS + OAuth2 SSO for dev-02

Goal

Add HTTPS, TLS termination, and OAuth2/OIDC SSO to dev-02 services (code-server + opencode), exposed directly on the internal network for VPN/office access.

Architecture

                        ┌──────────────────────────────────────────┐
                        │          dev-02.lab.alexpires.me         │
                        │  (Fedora bare metal)                     │
                        │                                          │
                        │  ┌──────┐  auth_request   ┌───────────┐ │
                        │  │nginx │─────────────────►│oauth2-    │ │
                        │  │:443 │◄──────────────────│proxy:4180 │ │
                        │  │TLS  │                   │(session)  │ │
                        │  │SSO  │                   │           │ │
                        │  └──┬───┘                   └─────┬─────┘ │
                        │     │ proxy_pass (trusts auth)      │      │
                        │     │                               │OIDC  │
                        │  ┌──▼──────┐                  ┌──────▼────┐ │
                        │  │ide:3000 │                  │Gitea IdP  │ │
                        │  │(127.0.0.1)                 │code.      │ │
                        │  │code-   │                  │alexpires. │ │
                        │  │server  │                  │me (pub)   │ │
                        │  └────────┘                  └───────────┘ │
                        │  ┌────────┐                                │
                        │  │agent:4096│                               │
                        │  │(127.0.0.1)                               │
                        │  │opencode │                                │
                        │  └────────┘                                │
                        └──────────────────────────────────────────┘
                               │              │
                    HTTPS 443  │              │ OIDC
                    (internal) │              │
                                ▼              ▼
                        ┌──────────────────────────────────────────┐
                        │  Browser (VPN/office)          │  code.alexpires.me (public)
                        │  ide.lab.alexpires.me:443 ──────────►  Gitea IdP
                        │  agent.lab.alexpires.me:443 ──────────►
                        └──────────────────────────────────────────┘

SSO Flow

  1. User visits https://ide.lab.alexpires.me:443 (direct, requires VPN/office)
  2. nginx detects no valid session cookie → auth_request to oauth2-proxy
  3. oauth2-proxy returns 401 → nginx redirects browser to Gitea login
  4. Browser → code.alexpires.me (Gitea, public, always reachable)
  5. User logs in → Gitea redirects back to https://ide.lab.alexpires.me/oauth2/callback
  6. oauth2-proxy exchanges code → access_token → creates session cookie
  7. Cookie scoped to *.lab.alexpires.me → SSO across lab subdomains
  8. Subsequent requests: nginx trusts cookie → proxies to backend (no re-login)

Access constraint: SSO only works when the user can reach lab.alexpires.me (VPN or office network). This is inherent to the architecture — the OAuth2 callback URL (ide.lab.alexpires.me/oauth2/callback) must be reachable by the browser after Gitea redirects back. All lab.alexpires.me services follow this same constraint.

Certificate Strategy

  • SAN cert for ide.lab.alexpires.me and agent.lab.alexpires.me
  • Obtained via certbot DNS-01 challenge (cloudns plugin)
  • Auto-renewal via cron with --deploy-hook "systemctl reload nginx"
  • No port 80/443 exposure needed (DNS-01 challenge doesn't need HTTP)
  • Certbot stores certs in /etc/letsencrypt/live/ (symlinked from /etc/letsencrypt/archive/)
  • nginx references:
    • ssl_certificate /etc/letsencrypt/live/ide.lab.alexpires.me/fullchain.pem;
    • ssl_certificate_key /etc/letsencrypt/live/ide.lab.alexpires.me/privkey.pem;

Service Binding

Service Before After
code-server 0.0.0.0:3000 127.0.0.1:3000
opencode 0.0.0.0:4096 127.0.0.1:4096
oauth2-proxy N/A 127.0.0.1:4180
nginx N/A 0.0.0.0:443 (only public port)

Pre-requisites

Before applying, register an OAuth2 app in Gitea:

  • Name: lab-services
  • Redirect URI: https://ide.lab.alexpires.me/oauth2/callback
  • Scopes: openid, profile, email
  • Result: client_id + client_secret → stored in sectool / env vars

Generate a cookie secret:

openssl rand -base64 32

Store via sectool as OAUTH2_PROXY_COOKIE_SECRET, OAUTH2_PROXY_CLIENT_ID, OAUTH2_PROXY_CLIENT_SECRET.

Changes Required

1. New Role: nginx_proxy

ansible/roles/nginx_proxy/
├── defaults/main.yml           # certbot, nginx, oauth2 config, upstreams
├── tasks/
│   ├── main.yml                # Entry point (includes certbot, nginx, oauth2_proxy)
│   ├── certbot.yml             # Install certbot, DNS credentials, obtain cert, cron renewal
│   ├── nginx.yml               # Install nginx, deploy config, systemd, SELinux context
│   └── oauth2_proxy.yml        # Download binary, config, systemd
├── templates/
│   ├── nginx.conf.j2           # Main nginx config (worker processes, logging, etc.)
│   ├── nginx-server.conf.j2    # Server blocks (SAN domains, TLS, auth_request, proxy)
│   └── oauth2-proxy.cfg.j2     # OAuth2/OIDC config for Gitea
└── files/
    └── .gitkeep

defaults/main.yml

nginx_proxy_certbot_enabled: true
nginx_proxy_certbot_email: "c.alexandre.pires@alexpires.me"
nginx_proxy_certbot_domains:
  - "ide.lab.alexpires.me"
  - "agent.lab.alexpires.me"
nginx_proxy_certbot_cron_minute: "0"
nginx_proxy_certbot_cron_hour: "3"
nginx_proxy_certbot_credential_file: "/etc/letsencrypt/cloudns.ini"
nginx_proxy_certbot_credential_mode: "0600"
nginx_proxy_certbot_nginx_reload: true

nginx_proxy_nginx_enabled: true
nginx_proxy_nginx_bind: "0.0.0.0"
nginx_proxy_nginx_port: 443
nginx_proxy_nginx_worker_connections: 1024
nginx_proxy_nginx_log_format: "main"
nginx_proxy_nginx_access_log: "/var/log/nginx/access.log"
nginx_proxy_nginx_error_log: "/var/log/nginx/error.log warn"
nginx_proxy_nginx_ssl_protocols: "TLSv1.2 TLSv1.3"
nginx_proxy_nginx_ssl_ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384"
nginx_proxy_nginx_ssl_prefer_server_ciphers: "on"
nginx_proxy_nginx_hsts_max_age: "31536000"
nginx_proxy_nginx_hsts_include_subdomains: true

nginx_proxy_oauth2_proxy_enabled: true
nginx_proxy_oauth2_proxy_bind: "127.0.0.1"
nginx_proxy_oauth2_proxy_port: 4180
nginx_proxy_oauth2_proxy_version: "2.13.0"
nginx_proxy_oauth2_proxy_cookie_domain: ".lab.alexpires.me"
nginx_proxy_oauth2_proxy_cookie_name: "_alexpires_oauth2_proxy"
nginx_proxy_oauth2_proxy_cookie_secret: "{{ lookup('env', 'OAUTH2_PROXY_COOKIE_SECRET', default='') }}"
nginx_proxy_oauth2_proxy_client_id: "{{ lookup('env', 'OAUTH2_PROXY_CLIENT_ID', default='') }}"
nginx_proxy_oauth2_proxy_client_secret: "{{ lookup('env', 'OAUTH2_PROXY_CLIENT_SECRET', default='') }}"
nginx_proxy_oauth2_proxy_redirect_url: "https://ide.lab.alexpires.me/oauth2/callback"
nginx_proxy_oauth2_proxy_provider: "gitea"
nginx_proxy_oauth2_proxy_provider_url: "https://code.alexpires.me"
nginx_proxy_oauth2_proxy_scopes: "openid profile email"
nginx_proxy_oauth2_proxy_email_domains: []
nginx_proxy_oauth2_proxy_upstream: "static://200"
nginx_proxy_oauth2_proxy_skip_jwt: false
nginx_proxy_oauth2_proxy_set_basic_auth: false
nginx_proxy_oauth2_proxy_set_xauthrequest: true
nginx_proxy_oauth2_proxy_ssl_insecure_skip_verify: false
nginx_proxy_oauth2_proxy_login_button: "Sign in with Gitea"
nginx_proxy_oauth2_proxy_proxy_prefix: "/oauth2"
nginx_proxy_oauth2_proxy_silent_login: false
nginx_proxy_oauth2_proxy_skip_provider_whoami: false
nginx_proxy_oauth2_proxy_cookie_refresh: "0s"
nginx_proxy_oauth2_proxy_cookie_expire: "168h"
nginx_proxy_oauth2_proxy_session_storage_type: "memory"
nginx_proxy_oauth2_proxy_request_logging: true
nginx_proxy_oauth2_proxy_log_file: "/var/log/oauth2-proxy.log"
nginx_proxy_oauth2_proxy_log_level: "info"

nginx_proxy_upstreams:
  ide:
    host: "127.0.0.1"
    port: 3000
  agent:
    host: "127.0.0.1"
    port: 4096

nginx_proxy_cloudns_auth_id: "{{ lookup('env', 'CLOUDNS_AUTH_ID') }}"
nginx_proxy_cloudns_auth_password: "{{ lookup('env', 'CLOUDNS_PASSWORD') }}"
nginx_proxy_cloudns_nameserver: "109.201.133.111"

tasks/certbot.yml

  • Install certbot + certbot-dns-cloudns via pip
  • Create CloudDNS credentials file at /etc/letsencrypt/cloudns.ini:
    dns_cloudns_auth_id = {{ nginx_proxy_cloudns_auth_id }}
    dns_cloudns_auth_password = {{ nginx_proxy_cloudns_auth_password }}
    
  • Permissions: 0600, owner: root:root
  • Obtain SAN cert:
    certbot certonly --dns-cloudns \
      -d ide.lab.alexpires.me \
      -d agent.lab.alexpires.me \
      --non-interactive \
      --agree-tos \
      -m {{ nginx_proxy_certbot_email }}
    
  • Create cron job for renewal (daily at 3:00 AM):
    certbot renew --deploy-hook "systemctl reload nginx"
    
  • Certs stored in /etc/letsencrypt/live/ide.lab.alexpires.me/

tasks/nginx.yml

  • Install nginx package
  • Render nginx.conf (global):
    user nginx;
    worker_processes auto;
    error_log {{ nginx_proxy_nginx_error_log }};
    pid /run/nginx.pid;
    
    events {
        worker_connections {{ nginx_proxy_nginx_worker_connections }};
    }
    
    http {
        log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                        '$status $body_bytes_sent "$http_referer" '
                        '"$http_user_agent" "$http_x_forwarded_for"';
    
        access_log {{ nginx_proxy_nginx_access_log }} main;
    
        sendfile on;
        tcp_nopush on;
        tcp_nodelay on;
        keepalive_timeout 65;
        types_hash_max_size 2048;
    
        include /etc/nginx/mime.types;
        default_type application/octet-stream;
    
        # WebSocket upgrade mapping
        map $http_upgrade $connection_upgrade {
            default upgrade;
            ''      close;
        }
    
        include /etc/nginx/conf.d/*.conf;
    }
    
  • Render nginx-server.conf (server blocks):
    # IDE (code-server)
    server {
        listen 443 ssl http2;
        server_name ide.lab.alexpires.me;
    
        ssl_certificate /etc/letsencrypt/live/ide.lab.alexpires.me/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/ide.lab.alexpires.me/privkey.pem;
        ssl_protocols {{ nginx_proxy_nginx_ssl_protocols }};
        ssl_ciphers {{ nginx_proxy_nginx_ssl_ciphers }};
        ssl_prefer_server_ciphers {{ nginx_proxy_nginx_ssl_prefer_server_ciphers }};
    
        add_header Strict-Transport-Security "max-age={{ nginx_proxy_nginx_hsts_max_age }}; {{ nginx_proxy_nginx_hsts_include_subdomains ? 'includeSubDomains' : '' }}" always;
    
        # OAuth2 auth_request
        location = /oauth2/ {
            proxy_pass http://127.0.0.1:{{ nginx_proxy_oauth2_proxy_port }};
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Scheme $scheme;
            proxy_set_header X-Auth-Request-Redirect $request_uri;
        }
    
        location = /oauth2/callback {
            proxy_pass http://127.0.0.1:{{ nginx_proxy_oauth2_proxy_port }}/oauth2/callback;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Scheme $scheme;
            proxy_set_header X-Auth-Request-Redirect $request_uri;
        }
    
        location /oauth2/ {
            proxy_pass http://127.0.0.1:{{ nginx_proxy_oauth2_proxy_port }};
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Scheme $scheme;
        }
    
        location / {
            auth_request /oauth2/;
            auth_request_set $auth_status $upstream_status;
            auth_request_set $auth_cookie $upstream_http_set_cookie;
    
            add_header Set-Cookie $auth_cookie;
    
            proxy_pass http://127.0.0.1:{{ nginx_proxy_upstreams.ide.port }};
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Host $host;
            proxy_set_header X-Forwarded-Port $server_port;
    
            # WebSocket support
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
    
            # Timeouts for code-server
            proxy_read_timeout 3600s;
            proxy_send_timeout 3600s;
        }
    }
    
    # Agent (opencode)
    server {
        listen 443 ssl http2;
        server_name agent.lab.alexpires.me;
    
        ssl_certificate /etc/letsencrypt/live/ide.lab.alexpires.me/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/ide.lab.alexpires.me/privkey.pem;
        ssl_protocols {{ nginx_proxy_nginx_ssl_protocols }};
        ssl_ciphers {{ nginx_proxy_nginx_ssl_ciphers }};
        ssl_prefer_server_ciphers {{ nginx_proxy_nginx_ssl_prefer_server_ciphers }};
    
        add_header Strict-Transport-Security "max-age={{ nginx_proxy_nginx_hsts_max_age }}; {{ nginx_proxy_nginx_hsts_include_subdomains ? 'includeSubDomains' : '' }}" always;
    
        # OAuth2 auth_request
        location = /oauth2/ {
            proxy_pass http://127.0.0.1:{{ nginx_proxy_oauth2_proxy_port }};
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Scheme $scheme;
            proxy_set_header X-Auth-Request-Redirect $request_uri;
        }
    
        location = /oauth2/callback {
            proxy_pass http://127.0.0.1:{{ nginx_proxy_oauth2_proxy_port }}/oauth2/callback;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Scheme $scheme;
            proxy_set_header X-Auth-Request-Redirect $request_uri;
        }
    
        location /oauth2/ {
            proxy_pass http://127.0.0.1:{{ nginx_proxy_oauth2_proxy_port }};
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Scheme $scheme;
        }
    
        location / {
            auth_request /oauth2/;
            auth_request_set $auth_status $upstream_status;
            auth_request_set $auth_cookie $upstream_http_set_cookie;
    
            add_header Set-Cookie $auth_cookie;
    
            proxy_pass http://127.0.0.1:{{ nginx_proxy_upstreams.agent.port }};
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Host $host;
            proxy_set_header X-Forwarded-Port $server_port;
    
            # WebSocket support
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
    
            # Timeouts for opencode
            proxy_read_timeout 3600s;
            proxy_send_timeout 3600s;
        }
    }
    
  • Enable + start nginx service
  • Set SELinux context: semanage fcontext -a -t httpd_cert_t "/etc/letsencrypt(/.*)?" + restorecon -Rv /etc/letsencrypt

tasks/oauth2_proxy.yml

  • Download latest release from GitHub (Go binary for linux/amd64):
    wget -qO /tmp/oauth2-proxy.tar.gz "https://github.com/oauth2-proxy/oauth2-proxy/releases/download/v{{ nginx_proxy_oauth2_proxy_version }}/oauth2-proxy-v{{ nginx_proxy_oauth2_proxy_version }}.linux-amd64.tar.gz"
    tar xzf /tmp/oauth2-proxy.tar.gz -C /tmp/
    mv /tmp/oauth2-proxy-v{{ nginx_proxy_oauth2_proxy_version }}.linux-amd64/oauth2-proxy /usr/local/bin/
    
  • Render config file at /etc/oauth2-proxy/oauth2-proxy.cfg:
    provider = "{{ nginx_proxy_oauth2_proxy_provider }}"
    provider_url = "{{ nginx_proxy_oauth2_proxy_provider_url }}"
    client_id = "{{ nginx_proxy_oauth2_proxy_client_id }}"
    client_secret = "{{ nginx_proxy_oauth2_proxy_client_secret }}"
    cookie_domain = "{{ nginx_proxy_oauth2_proxy_cookie_domain }}"
    cookie_name = "{{ nginx_proxy_oauth2_proxy_cookie_name }}"
    cookie_secret = "{{ nginx_proxy_oauth2_proxy_cookie_secret }}"
    cookie_expire = "{{ nginx_proxy_oauth2_proxy_cookie_expire | default('168h') }}"
    cookie_refresh = "{{ nginx_proxy_oauth2_proxy_cookie_refresh | default('0s') }}"
    redirect_url = "{{ nginx_proxy_oauth2_proxy_redirect_url }}"
    login_url = "{{ nginx_proxy_oauth2_proxy_provider_url }}/login/oauth/authorize"
    redeem_url = "{{ nginx_proxy_oauth2_proxy_provider_url }}/login/oauth/access_token"
    openid_connectissuerurl = "{{ nginx_proxy_oauth2_proxy_provider_url }}"
    scopes = "{{ nginx_proxy_oauth2_proxy_scopes }}"
    email_domains = {{ nginx_proxy_oauth2_proxy_email_domains | to_json }}
    upstream = "{{ nginx_proxy_oauth2_proxy_upstream }}"
    skip_jwt_bearer_tokens = {{ nginx_proxy_oauth2_proxy_skip_jwt | lower }}
    set_basic_auth = {{ nginx_proxy_oauth2_proxy_set_basic_auth | lower }}
    set_xauthrequest = {{ nginx_proxy_oauth2_proxy_set_xauthrequest | lower }}
    ssl_insecure_skip_verify = {{ nginx_proxy_oauth2_proxy_ssl_insecure_skip_verify | lower }}
    proxy_prefix = "{{ nginx_proxy_oauth2_proxy_proxy_prefix }}"
    silent_login = {{ nginx_proxy_oauth2_proxy_silent_login | lower }}
    skip_provider_whoami = {{ nginx_proxy_oauth2_proxy_skip_provider_whoami | lower }}
    listen = "{{ nginx_proxy_oauth2_proxy_bind }}:{{ nginx_proxy_oauth2_proxy_port }}"
    {% if nginx_proxy_oauth2_proxy_request_logging | default(true) %}
    logfile_filename = "{{ nginx_proxy_oauth2_proxy_log_file | default('/var/log/oauth2-proxy.log') }}"
    {% endif %}
    {% if nginx_proxy_oauth2_proxy_log_level | default('info') %}
    log_level = "{{ nginx_proxy_oauth2_proxy_log_level | default('info') }}"
    {% endif %}
    cookie_secure = false
    {% if nginx_proxy_oauth2_proxy_login_button | default('Sign in with Gitea') %}
    custom_login_message = "Sign in with your Gitea account"
    {% endif %}
    
  • Create systemd service at /etc/systemd/system/oauth2-proxy.service:
    [Unit]
    Description=oauth2-proxy for {{ nginx_proxy_oauth2_proxy_cookie_domain }}
    After=network.target
    
    [Service]
    Type=simple
    User=nginx
    ExecStart=/usr/local/bin/oauth2-proxy --config /etc/oauth2-proxy/oauth2-proxy.cfg
    Restart=always
    RestartSec=5
    
    [Install]
    WantedBy=multi-user.target
    
  • Enable + start service
  • Set config file permissions: 0640, owner root:nginx (contains client_secret)
  • Create log directory with proper permissions: mkdir -p /var/log && chown nginx:nginx /var/log/oauth2-proxy.log

Notes

  • opencode without credentials — Verify opencode starts and functions correctly when OPENCODE_SERVER_USERNAME/OPENCODE_SERVER_PASSWORD are not set. If opencode requires them to start, we may need to keep them but they will be ignored since nginx/oauth2-proxy handles auth.

2. Update linux_dev_station Role

defaults/main.yml

  • Change linux_dev_station_code_server_host default to 127.0.0.1
  • Change linux_dev_station_opencode_server_host default to 127.0.0.1

templates/code-server.service.j2

  • Change --bind-addr to 127.0.0.1:3000
  • Remove Environment="PASSWORD={{ linux_dev_station_code_server_password }}" — SSO handles auth, no password needed

templates/opencode.service.j2

  • Change --hostname to 127.0.0.1
  • Remove Environment="OPENCODE_SERVER_USERNAME={{ linux_dev_station_opencode_server_username }}" — SSO handles auth
  • Remove Environment="OPENCODE_SERVER_PASSWORD={{ linux_dev_station_opencode_server_password }}" — SSO handles auth

3. Update dev-01 DNS & Remove Proxy Config

terraform/apps/dev-01/config.tf

Add DNS records for ide and agent pointing to dev-02 (10.19.4.210):

{
  name    = "ide"
  type    = "A"
  content = "10.19.4.210"
},
{
  name    = "agent"
  type    = "A"
  content = "10.19.4.210"
},

Remove the opencode and code-server entries from proxy_services (no longer needed, dev-02 handles its own TLS).

Remove the opencode and vscode CNAME records from the zones list:

// Remove these:
{
  name    = "opencode"
  type    = "CNAME"
  content = "dev-01.${local.lab_domain}."
},
{
  name    = "vscode"
  type    = "CNAME"
  content = "dev-01.${local.lab_domain}."
},

4. Update Host Vars & Inventory

ansible/host_vars/dev-02.lab.alexpires.me.yml

Add:

# Certbot
nginx_proxy_certbot_enabled: true
nginx_proxy_certbot_email: "c.alexandre.pires@alexpires.me"
nginx_proxy_certbot_domains:
  - "ide.lab.alexpires.me"
  - "agent.lab.alexpires.me"
nginx_proxy_certbot_credential_file: "/etc/letsencrypt/cloudns.ini"
nginx_proxy_cloudns_auth_id: "{{ lookup('env', 'CLOUDNS_AUTH_ID') }}"
nginx_proxy_cloudns_auth_password: "{{ lookup('env', 'CLOUDNS_PASSWORD') }}"
nginx_proxy_cloudns_nameserver: "109.201.133.111"

# OAuth2 / Gitea
nginx_proxy_oauth2_proxy_cookie_secret: "{{ lookup('env', 'OAUTH2_PROXY_COOKIE_SECRET') }}"
nginx_proxy_oauth2_proxy_client_id: "{{ lookup('env', 'OAUTH2_PROXY_CLIENT_ID') }}"
nginx_proxy_oauth2_proxy_client_secret: "{{ lookup('env', 'OAUTH2_PROXY_CLIENT_SECRET') }}"

inventory/hosts

Add dev-02.lab.alexpires.me to [certbot] group:

[certbot]
dev-02.lab.alexpires.me

5. Gitea OAuth2 Application

Register one OAuth2 app in Gitea:

  • Name: lab-services
  • Redirect URI: https://ide.lab.alexpires.me/oauth2/callback
  • Scopes: openid, profile, email
  • Result: client_id + client_secret → stored in sectool / env vars

6. Firewall Changes (handled by existing firewall role)

Port Before After
22 10.19.4.0/24 + VPN Same
3000 10.19.4.0/24 CLOSED
4096 10.19.4.0/24 CLOSED
443 10.19.4.0/24 + VPN

The linux_dev_station role already manages firewall rules. Update fw_allowed_ports in host_vars:

fw_allowed_ports:
  - { rule: "allow", port: "22", proto: "tcp", from: "10.19.4.0/24" }
  - { rule: "allow", port: "22", proto: "tcp", from: "10.5.5.5/32" }
  - { rule: "allow", port: "443", proto: "tcp", from: "10.19.4.0/24" }
  - { rule: "allow", port: "443", proto: "tcp", from: "10.5.5.5/32" }

SELinux Considerations

  • Fedora ships with SELinux enforcing by default
  • nginx needs permission to read certs: semanage fcontext -a -t httpd_cert_t "/etc/letsencrypt(/.*)?" + restorecon -Rv /etc/letsencrypt
  • oauth2-proxy runs as nginx user — ensure systemd service file sets User=nginx
  • nginx needs outbound HTTP/HTTPS to Gitea and CloudDNS API — these are allowed by default for the httpd_t domain

Playbook Structure

# playbook_linux_dev_station.yml
- hosts: linux_dev
  roles:
    - linux_dev_station

# playbook_nginx_proxy.yml
- hosts: nginx
  roles:
    - nginx_proxy

The nginx_proxy role is a standalone, generic role — not tied to linux_dev_station.

Apply Order

  1. Register Gitea OAuth2 app → store creds in sectool
  2. Run playbook_nginx_proxy.yml on dev-02 (Ansible)
  3. Update fw_allowed_ports in dev-02 host_vars (close 3000/4096, add 443)
  4. Update dev-01 DNS + remove proxy config (Terraform)
  5. Verify: https://ide.lab.alexpires.me → SSO via Gitea
  6. Verify: https://agent.lab.alexpires.me → SSO via Gitea

Files to Create

File Action
ansible/roles/nginx_proxy/ NEW role
ansible/playbook_nginx_proxy.yml NEW standalone playbook (hosts: certbot)
ansible/roles/linux_dev_station/defaults/main.yml Update bind to 127.0.0.1
ansible/roles/linux_dev_station/templates/code-server.service.j2 --bind-addr 127.0.0.1:3000
ansible/roles/linux_dev_station/templates/opencode.service.j2 --hostname 127.0.0.1
ansible/host_vars/dev-02.lab.alexpires.me.yml Add certbot + oauth2 vars
inventory/hosts Add dev-02 to [certbot]
terraform/apps/dev-01/config.tf Add DNS records for ide/agent, remove proxy config, remove CNAMEs
implemented/linux_dev_station.md Update report