diff --git a/ansible/host_vars/dev-02.lab.alexpires.me.yml b/ansible/host_vars/dev-02.lab.alexpires.me.yml index 725fbc4..224e87f 100644 --- a/ansible/host_vars/dev-02.lab.alexpires.me.yml +++ b/ansible/host_vars/dev-02.lab.alexpires.me.yml @@ -1,8 +1,10 @@ +--- hostname: dev-02.lab.alexpires.me ansible_user: "{{ lookup('env', 'ANSIBLE_USER') }}" ansible_host: "10.19.4.210" # Users +login_disable_history: false login_users: - username: "{{ ansible_user }}" comment: "Managed by Ansible" @@ -26,11 +28,9 @@ linux_dev_station_workspace_dir: "/home/devstation/projects" linux_dev_station_scripts_dir: "/home/devstation/scripts" linux_dev_station_logs_dir: "/home/devstation/.local/state/logs" -linux_dev_station_code_server_host: "0.0.0.0" linux_dev_station_code_server_port: 3000 -linux_dev_station_code_server_auth: "password" +linux_dev_station_code_server_auth: "none" -linux_dev_station_opencode_server_host: "0.0.0.0" linux_dev_station_opencode_server_port: 4096 linux_dev_station_manage_systemd: true linux_dev_station_oh_my_zsh_enabled: true @@ -88,28 +88,10 @@ linux_dev_station_opencode_config: timeout: 3000000 chunkTimeout: 3000000 models: - gemma-4-12b-q8-0: - name: "gemma-4-12b-q8-0" - gemma-4-26b-a4b-ud-iq4-xs: - name: "gemma-4-26b-a4b-ud-iq4-xs" - gemma-4-31b-iq4-xs: - name: "gemma-4-31b-iq4-xs" - gpt-oss-20b-q4-0: - name: "gpt-oss-20b-q4-0" - gpt-oss-20b-q8-0: - name: "gpt-oss-20b-q8-0" - qwen3-5-35b-a3b-q4-k-m: - name: "qwen3-5-35b-a3b-q4-k-m" - qwen3-5-9b-q8-0: - name: "qwen3-5-9b-q8-0" qwen3-6-27b-mtp-q4-0: name: "qwen3-6-27b-mtp-q4-0" - qwen3-6-27b-q4-k-m: - name: "qwen3-6-27b-q4-k-m" qwen3-6-35b-a3b-mtp-ud-q4-m: name: "qwen3-6-35b-a3b-mtp-ud-q4-m" - qwen3-6-35b-a3b-ud-q4-k-m: - name: "qwen3-6-35b-a3b-ud-q4-k-m" mcp: deepwiki: type: "remote" @@ -174,14 +156,46 @@ linux_dev_station_opencode_config: linux_dev_station_service_user_ssh_enabled: true linux_dev_station_service_user_ssh_authorized_keys: [] +# Bind services to localhost (nginx handles TLS+auth) +linux_dev_station_code_server_bind_to_localhost: true +linux_dev_station_opencode_bind_to_localhost: true + +# nginx_proxy: TLS + OAuth2/OIDC SSO +nginx_proxy_certbot_email: "admin@alexpires.me" +nginx_proxy_oauth2_proxy_enabled: true +nginx_proxy_oauth2_proxy_provider: "oidc" +nginx_proxy_oauth2_proxy_provider_url: "https://code.alexpires.me" +nginx_proxy_oauth2_proxy_cookie_domain: ".lab.alexpires.me" +nginx_proxy_oauth2_proxy_redirect_urls: + - "https://ide.lab.alexpires.me/oauth2/callback" + - "https://agent.lab.alexpires.me/oauth2/callback" +nginx_proxy_oauth2_proxy_email_domains: + - "*" +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_sites: + - name: ide + server_name: "ide.lab.alexpires.me" + upstream: + host: "127.0.0.1" + port: "{{ linux_dev_station_code_server_port }}" + websocket: true + - name: agent + server_name: "agent.lab.alexpires.me" + upstream: + host: "127.0.0.1" + port: "{{ linux_dev_station_opencode_server_port }}" + websocket: true + # Firewall ports ufw_enable: true fw_allowed_ports: - - { rule: "allow", port: "22", proto: "tcp", from: "10.5.5.5/32" } # SSH Access (VPN device) - - { rule: "allow", port: "22", proto: "tcp", from: "10.19.4.0/24" } # SSH Access (local network) - - { rule: "allow", port: "3000", proto: "tcp", from: "10.19.4.0/24" } # code-server (local network only) - - { rule: "allow", port: "4096", proto: "tcp", from: "10.19.4.0/24" } # opencode (local network only) - - { rule: "allow", port: "9090", proto: "tcp", from: "10.19.4.0/24" } # opencode (local network only) + - { rule: "allow", port: "22", proto: "tcp", from: "10.5.5.5/32" } # SSH (VPN device) + - { rule: "allow", port: "22", proto: "tcp", from: "10.19.4.0/24" } # SSH (local network) + - { rule: "allow", port: "443", proto: "tcp", from: "10.19.4.0/24" } # HTTPS (nginx reverse proxy) + - { rule: "allow", port: "443", proto: "tcp", from: "10.5.5.5/32" } # HTTPS (VPN device) + - { rule: "allow", port: "9090", proto: "tcp", from: "10.19.4.0/24" } # Prometheus node-exporter linux_dev_station_oh_my_zsh_theme: "agnoster" linux_dev_station_oh_my_zsh_plugins: diff --git a/ansible/host_vars/gpu-01.lab.alexpires.me.yml b/ansible/host_vars/gpu-01.lab.alexpires.me.yml index 1ad4673..30ce3cd 100644 --- a/ansible/host_vars/gpu-01.lab.alexpires.me.yml +++ b/ansible/host_vars/gpu-01.lab.alexpires.me.yml @@ -207,9 +207,9 @@ llama_server_argv_extra: "-np", -1, "-b", - 2048, - "-ub", 1024, + "-ub", + 512, "-fa", "on", "-ctk", diff --git a/ansible/playbook_nginx_proxy.yml b/ansible/playbook_nginx_proxy.yml new file mode 100644 index 0000000..1faff2e --- /dev/null +++ b/ansible/playbook_nginx_proxy.yml @@ -0,0 +1,14 @@ +--- +- name: Configure nginx reverse proxy with TLS and OAuth2 + hosts: nginx + gather_facts: true + + pre_tasks: + - name: Ensure target is Fedora + ansible.builtin.assert: + that: + - ansible_facts['distribution'] == "Fedora" + fail_msg: "This playbook supports Fedora hosts only" + + roles: + - nginx_proxy diff --git a/ansible/roles/linux_dev_station/defaults/main.yml b/ansible/roles/linux_dev_station/defaults/main.yml index 97895ab..8565aa9 100644 --- a/ansible/roles/linux_dev_station/defaults/main.yml +++ b/ansible/roles/linux_dev_station/defaults/main.yml @@ -27,7 +27,9 @@ linux_dev_station_dnf_packages: - fzf - sqlite3 - opentofu - - kubectl + - kubernetes1.36-client + - ansible + - python3-ansible-lint linux_dev_station_tool_packages: - name: uv @@ -46,7 +48,10 @@ linux_dev_station_tool_packages: version: "1.1.2" sha256: "" -linux_dev_station_code_server_host: "0.0.0.0" +linux_dev_station_code_server_bind_to_localhost: false + +linux_dev_station_code_server_host: >- + {{ (linux_dev_station_code_server_bind_to_localhost | bool) | ternary('127.0.0.1', '0.0.0.0') }} linux_dev_station_code_server_port: 3000 linux_dev_station_code_server_auth: "password" linux_dev_station_code_server_password: "{{ lookup('env', 'CODE_SERVER_PASSWORD', default='') }}" @@ -82,7 +87,6 @@ linux_dev_station_oh_my_zsh_plugins: - kubectl - microk8s - docker - - docker-compose - ansible - aws - golang @@ -100,7 +104,10 @@ linux_dev_station_ssh_key_comment: "dev-02" linux_dev_station_code_server_systemd_enabled: true linux_dev_station_opencode_systemd_enabled: true -linux_dev_station_opencode_server_host: "0.0.0.0" +linux_dev_station_opencode_bind_to_localhost: false + +linux_dev_station_opencode_server_host: >- + {{ (linux_dev_station_opencode_bind_to_localhost | bool) | ternary('127.0.0.1', '0.0.0.0') }} linux_dev_station_opencode_server_port: 4096 linux_dev_station_opencode_server_username: "{{ lookup('env', 'OPENCODE_SERVER_USERNAME', default='') }}" linux_dev_station_opencode_server_password: "{{ lookup('env', 'OPENCODE_SERVER_PASSWORD', default='') }}" diff --git a/ansible/roles/linux_dev_station/tasks/main.yml b/ansible/roles/linux_dev_station/tasks/main.yml index 969f803..61b5174 100644 --- a/ansible/roles/linux_dev_station/tasks/main.yml +++ b/ansible/roles/linux_dev_station/tasks/main.yml @@ -5,28 +5,6 @@ - ansible_facts['distribution'] == "Fedora" fail_msg: "Role linux_dev_station supports Fedora hosts only" -- name: Ensure OpenCode server credentials are provided - ansible.builtin.assert: - that: - - linux_dev_station_opencode_server_username | length > 0 - - linux_dev_station_opencode_server_password | length > 0 - fail_msg: >- - Set OPENCODE_SERVER_USERNAME and OPENCODE_SERVER_PASSWORD - in environment (recommended via sectool) - before running the playbook. - when: linux_dev_station_opencode_systemd_enabled | bool - -- name: Ensure code-server password is provided - ansible.builtin.assert: - that: - - linux_dev_station_code_server_password | length > 0 - fail_msg: >- - Set CODE_SERVER_PASSWORD in environment - (recommended via sectool) before running the playbook. - when: - - linux_dev_station_code_server_systemd_enabled | bool - - linux_dev_station_code_server_auth == "password" - - name: Install dnf packages become: true ansible.builtin.dnf: diff --git a/ansible/roles/linux_dev_station/templates/code-server.service.j2 b/ansible/roles/linux_dev_station/templates/code-server.service.j2 index b534049..37dc7be 100644 --- a/ansible/roles/linux_dev_station/templates/code-server.service.j2 +++ b/ansible/roles/linux_dev_station/templates/code-server.service.j2 @@ -16,7 +16,6 @@ StandardError=append:{{ linux_dev_station_logs_dir }}/code-server.err.log Environment="HOME={{ linux_dev_station_service_home }}" Environment="PATH={{ linux_dev_station_path }}" Environment="TMPDIR={{ linux_dev_station_service_home }}/.cache/tmp" -Environment="PASSWORD={{ linux_dev_station_code_server_password }}" {% if linux_dev_station_ssh_agent_enabled | bool %} Environment="SSH_AUTH_SOCK={{ linux_dev_station_ssh_agent_socket }}" {% endif %} diff --git a/ansible/roles/linux_dev_station/templates/opencode.service.j2 b/ansible/roles/linux_dev_station/templates/opencode.service.j2 index 1d6750e..4b6253a 100644 --- a/ansible/roles/linux_dev_station/templates/opencode.service.j2 +++ b/ansible/roles/linux_dev_station/templates/opencode.service.j2 @@ -16,8 +16,6 @@ StandardError=append:{{ linux_dev_station_logs_dir }}/opencode.err.log Environment="HOME={{ linux_dev_station_service_home }}" Environment="PATH={{ linux_dev_station_path }}" Environment="TMPDIR={{ linux_dev_station_service_home }}/.cache/tmp" -Environment="OPENCODE_SERVER_USERNAME={{ linux_dev_station_opencode_server_username }}" -Environment="OPENCODE_SERVER_PASSWORD={{ linux_dev_station_opencode_server_password }}" {% for env_key, env_value in linux_dev_station_opencode_extra_environment.items() %} Environment="{{ env_key }}={{ env_value }}" {% endfor %} diff --git a/ansible/roles/login/defaults/main.yml b/ansible/roles/login/defaults/main.yml index bc3a5f5..8010464 100644 --- a/ansible/roles/login/defaults/main.yml +++ b/ansible/roles/login/defaults/main.yml @@ -1,2 +1,3 @@ --- login_users: [] +login_disable_history: true \ No newline at end of file diff --git a/ansible/roles/login/tasks/main.yml b/ansible/roles/login/tasks/main.yml index 001e7af..01fdb87 100644 --- a/ansible/roles/login/tasks/main.yml +++ b/ansible/roles/login/tasks/main.yml @@ -61,3 +61,4 @@ owner: root group: root mode: "0755" + when: login_disable_history diff --git a/ansible/roles/nginx_proxy/defaults/main.yml b/ansible/roles/nginx_proxy/defaults/main.yml new file mode 100644 index 0000000..2cac2a3 --- /dev/null +++ b/ansible/roles/nginx_proxy/defaults/main.yml @@ -0,0 +1,54 @@ +--- +nginx_proxy_certbot_enabled: true +nginx_proxy_certbot_email: "" +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_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_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: "7.15.3" +nginx_proxy_oauth2_proxy_cookie_domain: "" +nginx_proxy_oauth2_proxy_cookie_name: "_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_urls: [] +nginx_proxy_oauth2_proxy_provider: "" +nginx_proxy_oauth2_proxy_provider_url: "" +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_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_sites: [] + +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" diff --git a/ansible/roles/nginx_proxy/files/.gitkeep b/ansible/roles/nginx_proxy/files/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ansible/roles/nginx_proxy/handlers/main.yml b/ansible/roles/nginx_proxy/handlers/main.yml new file mode 100644 index 0000000..5904a76 --- /dev/null +++ b/ansible/roles/nginx_proxy/handlers/main.yml @@ -0,0 +1,7 @@ +--- +- name: Reload nginx + become: true + ansible.builtin.systemd: + name: nginx + state: reloaded + listen: Reload nginx diff --git a/ansible/roles/nginx_proxy/tasks/certbot.yml b/ansible/roles/nginx_proxy/tasks/certbot.yml new file mode 100644 index 0000000..0bdd761 --- /dev/null +++ b/ansible/roles/nginx_proxy/tasks/certbot.yml @@ -0,0 +1,60 @@ +--- +- name: Remove certbot installed via package manager + become: true + ansible.builtin.dnf: + name: certbot + state: absent + +- name: Install pip + become: true + ansible.builtin.dnf: + name: python3-pip + state: present + +- name: Install certbot and DNS plugin via pip + become: true + ansible.builtin.pip: + name: + - certbot + - certbot-dns-cloudns + state: present + +- name: Ensure CloudDNS credentials directory exists + become: true + ansible.builtin.file: + path: /etc/letsencrypt + state: directory + mode: '0700' + +- name: Create CloudDNS credentials file + become: true + ansible.builtin.copy: + content: | + dns_cloudns_auth_id={{ nginx_proxy_cloudns_auth_id }} + dns_cloudns_auth_password={{ nginx_proxy_cloudns_auth_password }} + dest: "{{ nginx_proxy_certbot_credential_file }}" + mode: "{{ nginx_proxy_certbot_credential_mode }}" + owner: root + group: root + +- name: Obtain TLS certificate via DNS-01 challenge + become: true + ansible.builtin.command: >- + certbot certonly + --authenticator dns-cloudns + --dns-cloudns-credentials {{ nginx_proxy_certbot_credential_file }} + --dns-cloudns-nameserver {{ nginx_proxy_cloudns_nameserver }} + -d {{ __nginx_proxy_certbot_domains | join(' -d ') }} + --non-interactive + --agree-tos + -m {{ nginx_proxy_certbot_email }} + args: + creates: "/etc/letsencrypt/live/{{ __nginx_proxy_certbot_domains[0] }}/fullchain.pem" + +- name: Create certbot renewal cron job + become: true + ansible.builtin.cron: + name: "certbot renewal" + minute: "{{ nginx_proxy_certbot_cron_minute }}" + hour: "{{ nginx_proxy_certbot_cron_hour }}" + job: "certbot renew --deploy-hook 'systemctl reload nginx'" diff --git a/ansible/roles/nginx_proxy/tasks/main.yml b/ansible/roles/nginx_proxy/tasks/main.yml new file mode 100644 index 0000000..d1ffb10 --- /dev/null +++ b/ansible/roles/nginx_proxy/tasks/main.yml @@ -0,0 +1,39 @@ +--- +- name: Validate nginx_proxy_sites is defined + ansible.builtin.assert: + that: + - nginx_proxy_sites is defined + - nginx_proxy_sites | length > 0 + fail_msg: "nginx_proxy_sites must be defined with at least one site" + +- name: Validate each site has required fields + ansible.builtin.assert: + that: + - item.server_name is defined + - item.upstream is defined + - item.upstream.host is defined + - item.upstream.port is defined + fail_msg: "Each site in nginx_proxy_sites must have server_name, upstream.host, and upstream.port" + loop: "{{ nginx_proxy_sites }}" + +- name: Calculate certbot domains from sites + ansible.builtin.set_fact: + __nginx_proxy_certbot_domains: "{{ nginx_proxy_sites | map(attribute='server_name') | list }}" + when: nginx_proxy_certbot_domains is not defined + +- name: Use explicit certbot domains when defined + ansible.builtin.set_fact: + __nginx_proxy_certbot_domains: "{{ nginx_proxy_certbot_domains }}" + when: nginx_proxy_certbot_domains is defined + +- name: Install certbot + ansible.builtin.include_tasks: certbot.yml + when: nginx_proxy_certbot_enabled | bool + +- name: Install nginx + ansible.builtin.include_tasks: nginx.yml + when: nginx_proxy_nginx_enabled | bool + +- name: Install oauth2-proxy + ansible.builtin.include_tasks: oauth2_proxy.yml + when: nginx_proxy_oauth2_proxy_enabled | bool diff --git a/ansible/roles/nginx_proxy/tasks/nginx.yml b/ansible/roles/nginx_proxy/tasks/nginx.yml new file mode 100644 index 0000000..c97d00a --- /dev/null +++ b/ansible/roles/nginx_proxy/tasks/nginx.yml @@ -0,0 +1,46 @@ +--- +- name: Install nginx + become: true + ansible.builtin.dnf: + name: nginx + state: present + +- name: Deploy nginx.conf + become: true + ansible.builtin.template: + src: nginx.conf.j2 + dest: /etc/nginx/nginx.conf + mode: "0644" + owner: root + group: root + notify: Reload nginx + +- name: Deploy server blocks configuration + become: true + ansible.builtin.template: + src: nginx-server.conf.j2 + dest: /etc/nginx/conf.d/nginx-proxy.conf + mode: "0644" + owner: root + group: root + notify: Reload nginx + +- name: Enable and start nginx + become: true + ansible.builtin.systemd: + name: nginx + state: started + enabled: true + daemon_reload: true + +- name: Set SELinux context for Let's Encrypt certificates + become: true + ansible.builtin.command: >- + semanage fcontext -a -t httpd_cert_t "/etc/letsencrypt(/.*)?" + failed_when: false + changed_when: false + +- name: Restore SELinux context for Let's Encrypt directory + become: true + ansible.builtin.command: restorecon -Rv /etc/letsencrypt + changed_when: false diff --git a/ansible/roles/nginx_proxy/tasks/oauth2_proxy.yml b/ansible/roles/nginx_proxy/tasks/oauth2_proxy.yml new file mode 100644 index 0000000..461557c --- /dev/null +++ b/ansible/roles/nginx_proxy/tasks/oauth2_proxy.yml @@ -0,0 +1,68 @@ +--- +- name: Download oauth2-proxy + become: true + ansible.builtin.get_url: + url: "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" + dest: /tmp/oauth2-proxy.tar.gz + mode: "0644" + +- name: Extract oauth2-proxy binary + become: true + ansible.builtin.unarchive: + src: /tmp/oauth2-proxy.tar.gz + dest: /tmp/ + remote_src: true + +- name: Install oauth2-proxy binary + become: true + ansible.builtin.copy: + src: >- + /tmp/oauth2-proxy-v{{ nginx_proxy_oauth2_proxy_version }}.linux-amd64/oauth2-proxy + dest: /usr/local/bin/oauth2-proxy + mode: "0755" + owner: root + group: root + +- name: Create oauth2-proxy config directory + become: true + ansible.builtin.file: + path: /etc/oauth2-proxy + state: directory + mode: "0755" + owner: root + group: root + +- name: Deploy oauth2-proxy configuration + become: true + ansible.builtin.template: + src: oauth2-proxy.cfg.j2 + dest: /etc/oauth2-proxy/oauth2-proxy.cfg + mode: "0640" + owner: root + group: nginx + +- name: Create oauth2-proxy log file + become: true + ansible.builtin.file: + path: "{{ nginx_proxy_oauth2_proxy_log_file }}" + state: touch + mode: "0644" + owner: nginx + group: nginx + +- name: Deploy oauth2-proxy systemd service + become: true + ansible.builtin.template: + src: oauth2-proxy.service.j2 + dest: /etc/systemd/system/oauth2-proxy.service + mode: "0644" + owner: root + group: root + +- name: Enable and start oauth2-proxy + become: true + ansible.builtin.systemd: + name: oauth2-proxy + state: started + enabled: true + daemon_reload: true diff --git a/ansible/roles/nginx_proxy/templates/nginx-server.conf.j2 b/ansible/roles/nginx_proxy/templates/nginx-server.conf.j2 new file mode 100644 index 0000000..5d7cedf --- /dev/null +++ b/ansible/roles/nginx_proxy/templates/nginx-server.conf.j2 @@ -0,0 +1,96 @@ +{% set cert_prefix = nginx_proxy_sites[0].server_name %} +{% for item in nginx_proxy_sites %} +server { + listen {{ nginx_proxy_nginx_port }} ssl; + http2 on; + server_name {{ item.server_name }}; + + ssl_certificate {{ item.ssl_certificate | default('/etc/letsencrypt/live/' + cert_prefix + '/fullchain.pem') }}; + ssl_certificate_key {{ item.ssl_certificate_key | default('/etc/letsencrypt/live/' + cert_prefix + '/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 | ternary('includeSubDomains', '') }}" always; + + # Redirect unauthenticated requests to OAuth2 sign-in + error_page 401 = /oauth2/sign_in; + + # OAuth2 auth_request + location = /oauth2/auth { + proxy_pass http://{{ nginx_proxy_oauth2_proxy_bind }}:{{ nginx_proxy_oauth2_proxy_port }}/oauth2/auth; + 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; + proxy_set_header Content-Length ""; + proxy_pass_request_body off; + proxy_method GET; + } + + location = /oauth2/callback { + proxy_pass http://{{ nginx_proxy_oauth2_proxy_bind }}:{{ 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; + proxy_buffer_size 16k; + proxy_buffers 4 32k; + proxy_busy_buffers_size 64k; + } + + location = /oauth2/sign_in { + proxy_pass http://{{ nginx_proxy_oauth2_proxy_bind }}:{{ nginx_proxy_oauth2_proxy_port }}/oauth2/sign_in; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + } + + location /oauth2/ { + proxy_pass http://{{ nginx_proxy_oauth2_proxy_bind }}:{{ 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; + auth_request_set $auth_status $upstream_status; + auth_request_set $auth_cookie $upstream_http_set_cookie; + + add_header Set-Cookie $auth_cookie; + add_header Strict-Transport-Security "max-age={{ nginx_proxy_nginx_hsts_max_age }}; {{ nginx_proxy_nginx_hsts_include_subdomains | ternary('includeSubDomains', '') }}" always; + + proxy_pass http://{{ item.upstream.host }}:{{ item.upstream.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 + {% if item.websocket | default(true) | bool %} + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + {% endif %} + + # Timeouts + proxy_read_timeout {{ item.proxy_read_timeout | default('3600s') }}; + proxy_send_timeout {{ item.proxy_send_timeout | default('3600s') }}; + } + + # Extra location blocks +{% if item.extra_locations | default([]) %} +{% for location in item.extra_locations %} + location {{ location.path }} { +{% for key, value in location.config.items() %} + {{ key }} {{ value }}; +{% endfor %} + } +{% endfor %} +{% endif %} +} + +{% endfor %} diff --git a/ansible/roles/nginx_proxy/templates/nginx.conf.j2 b/ansible/roles/nginx_proxy/templates/nginx.conf.j2 new file mode 100644 index 0000000..0e50033 --- /dev/null +++ b/ansible/roles/nginx_proxy/templates/nginx.conf.j2 @@ -0,0 +1,33 @@ +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_bucket_size 64; + types_hash_max_size 2048; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + include /etc/nginx/conf.d/*.conf; +} diff --git a/ansible/roles/nginx_proxy/templates/oauth2-proxy.cfg.j2 b/ansible/roles/nginx_proxy/templates/oauth2-proxy.cfg.j2 new file mode 100644 index 0000000..be5db8c --- /dev/null +++ b/ansible/roles/nginx_proxy/templates/oauth2-proxy.cfg.j2 @@ -0,0 +1,26 @@ +provider = "{{ nginx_proxy_oauth2_proxy_provider }}" +oidc_issuer_url = "{{ nginx_proxy_oauth2_proxy_provider_url }}" +client_id = "{{ nginx_proxy_oauth2_proxy_client_id }}" +client_secret = "{{ nginx_proxy_oauth2_proxy_client_secret }}" +cookie_domains = [ "{{ 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 }}" +cookie_refresh = "{{ nginx_proxy_oauth2_proxy_cookie_refresh }}" +redirect_url = "{{ nginx_proxy_oauth2_proxy_redirect_urls[0] }}" +scope = "{{ nginx_proxy_oauth2_proxy_scopes }}" +email_domains = {{ nginx_proxy_oauth2_proxy_email_domains | to_json }} +upstreams = [ "static://200" ] +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 }}" +http_address = "{{ nginx_proxy_oauth2_proxy_bind }}:{{ nginx_proxy_oauth2_proxy_port }}" +{% if nginx_proxy_oauth2_proxy_request_logging | default(true) %} +logging_filename = "{{ nginx_proxy_oauth2_proxy_log_file | default('/var/log/oauth2-proxy.log') }}" +{% endif %} +cookie_secure = false +reverse_proxy = true +trusted_proxy_ips = [ "127.0.0.1" ] +proxy_websockets = true diff --git a/ansible/roles/nginx_proxy/templates/oauth2-proxy.service.j2 b/ansible/roles/nginx_proxy/templates/oauth2-proxy.service.j2 new file mode 100644 index 0000000..f486774 --- /dev/null +++ b/ansible/roles/nginx_proxy/templates/oauth2-proxy.service.j2 @@ -0,0 +1,13 @@ +[Unit] +Description=oauth2-proxy +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 diff --git a/ansible/sectool.env b/ansible/sectool.env index 5d21237..3824541 100644 --- a/ansible/sectool.env +++ b/ansible/sectool.env @@ -21,3 +21,6 @@ OPENCODE_SERVER_USERNAME=$OPENCODE_SERVER_USERNAME OPENCODE_SERVER_PASSWORD=$OPENCODE_SERVER_PASSWORD CODE_SERVER_PASSWORD=$CODE_SERVER_PASSWORD OPEN_WEBUI_API_KEY=$OPEN_WEBUI_API_KEY +OAUTH2_PROXY_COOKIE_SECRET=$OAUTH2_PROXY_COOKIE_SECRET +OAUTH2_PROXY_CLIENT_ID=$OAUTH2_PROXY_CLIENT_ID +OAUTH2_PROXY_CLIENT_SECRET=$OAUTH2_PROXY_CLIENT_SECRET diff --git a/implemented/linux_dev_station.md b/implemented/linux_dev_station.md new file mode 100644 index 0000000..4bf504a --- /dev/null +++ b/implemented/linux_dev_station.md @@ -0,0 +1,102 @@ +# Linux Dev Station - dev-02.lab.alexpires.me + +## Overview +Provisioned `dev-02.lab.alexpires.me` (Fedora, `10.19.4.210`) as a remote dev station mirroring the macOS setup. Includes code-server, opencode, and dev toolchain management. Exposed via nginx reverse proxy with TLS termination and OAuth2/OIDC SSO (Gitea as IdP). + +## Role: `linux_dev_station` +- **Location:** `ansible/roles/linux_dev_station/` +- **Scope:** Fedora only (`ansible_facts['distribution'] == "Fedora"`) +- **Package manager:** dnf for system packages; tool-managed (uv, rustup, fnm) for dev toolchains +- **Excludes:** podman/container runtime management + +### Components +- **SSH hardening:** Public key auth, password auth disabled, root login disabled +- **Users:** `devstation` user with sudo, zsh shell, and required groups +- **Zsh:** Oh My Zsh with agnoster theme, plugins, custom env vars, and zshrc management +- **Tooling:** + - `uv` - Python package manager + - `rustup` - Rust toolchain + - `fnm` + `node` - Node.js version manager + - `code-server` - VS Code remote on `127.0.0.1:3000` + - `opencode` - AI coding assistant (HTTP server on `127.0.0.1:4096`) +- **Firewall:** firewalld with `fw_allowed_ports` (22, 443) +- **Systemd services:** code-server, opencode + +### Opencode Details +- **Repo:** `anomalyco/opencode` (not `opencode-ai`) +- **Version:** `1.17.18` +- **Binary:** `opencode-linux-x64.tar.gz` +- **Mode:** HTTP server (`serve`), binds to `127.0.0.1` +- **Install strategy:** Versioned directories (`~/.local/lib/tool-vX.Y.Z`) + symlinks for idempotent upgrades + +### Oh My Zsh Plugins (Linux) +git, fzf, common-aliases, sudo, tmux, extract, colored-man-pages, history-substring-search, kubectl, microk8s, docker, docker-compose, ansible, aws, golang, dnf, bgnotify + +## macOS Role Updates +- **Location:** `ansible/roles/macos_dev_station/` +- Synced Oh My Zsh fixes: sourcing, theme, plugins, extra env vars +- **Oh My Zsh Plugins (macOS):** git, fzf, common-aliases, sudo, tmux, extract, colored-man-pages, history-substring-search, kubectl, docker, docker-compose, ansible, aws, golang, brew, bgnotify + +## TLS + OAuth2/OIDC SSO (`nginx_proxy` role) +- **Location:** `ansible/roles/nginx_proxy/` — standalone, generic role not tied to `linux_dev_station` +- **Playbook:** `ansible/playbook_nginx_proxy.yml` targets `[nginx]` group +- **Architecture:** nginx (:443, TLS) → oauth2-proxy (:4180, session mgmt) → backends (127.0.0.1 only) +- **Certificate:** SAN cert for `ide.lab.alexpires.me` + `agent.lab.alexpires.me` via certbot DNS-01 (cloudns plugin), auto-renewal cron +- **SSO cookie:** Scoped to `.lab.alexpires.me` +- **IdP:** Gitea (`code.alexpires.me`), OAuth2 app `lab-services` (confidential client, scopes: `openid`, `profile`, `email`) +- **oauth2-proxy:** v7.15.3, config format: `oidc_issuer_url`, `http_address`, `upstreams` array, `cookie_domains` array, `proxy_websockets = true`, `reverse_proxy = true` +- **Services exposed:** + - `https://ide.lab.alexpires.me` → code-server (127.0.0.1:3000) + - `https://agent.lab.alexpires.me` → opencode (127.0.0.1:4096) +- **Nginx fixes applied:** `types_hash_bucket_size 64;`, ssl_ciphers single line, http2 separate directive, SAN cert shared via first site directory, POST auth_request body fix (`proxy_set_header Content-Length "";`), proxy_buffer_size for oauth2 callback + +### Defaults +- `nginx_proxy_oauth2_proxy_version: "7.15.3"` +- `nginx_proxy_cloudns_nameserver: "109.201.133.111"` +- `nginx_proxy_sites` — list of site configs with server_name, upstream, websocket toggle + +### Secrets +- `OAUTH2_PROXY_COOKIE_SECRET` — 32-byte random, stored in vault +- `OAUTH2_PROXY_CLIENT_ID` / `OAUTH2_PROXY_CLIENT_SECRET` — from Gitea OAuth2 app +- `CLOUDNS_AUTH_ID` / `CLOUDNS_PASSWORD` — DNS credentials for certbot + +## Infrastructure Changes +- **DNS:** + - Added A record for `dev-02.lab.alexpires.me` (`10.19.4.210`) + - Added A records for `ide` and `agent` → `10.19.4.210` (in `terraform/apps/dev-01/config.tf`) +- **Inventory:** Added `[nginx]` group with `dev-02.lab.alexpires.me`; dev-02 removed from `[certbot]` group +- **Host vars:** `ansible/host_vars/dev-02.lab.alexpires.me.yml` with `nginx_proxy_sites`, `bind_to_localhost: true`, firewall rules (22, 443), OAuth2 credentials via `lookup('env', ...)` +- **dev-01 cleanup:** Removed `opencode`/`vscode` CNAME records and `proxy_services` entries (no longer proxying to dev-02) + +## Service Bindings +| Service | Binding | +|---------|---------| +| code-server | `127.0.0.1:3000` | +| opencode | `127.0.0.1:4096` | +| oauth2-proxy | `127.0.0.1:4180` | +| nginx | `0.0.0.0:443` | + +## Firewall +| Port | Access | +|------|--------| +| 22 | `10.19.4.0/24` + VPN (`10.5.5.5/32`) | +| 443 | `10.19.4.0/24` + VPN (`10.5.5.5/32`) | +| 3000/4096 | Closed (nginx handles all external access) | + +## SELinux +- http_port_t extended to include 4180 (oauth2-proxy) +- httpd_can_network_connect enabled +- `/etc/letsencrypt` context set to `httpd_cert_t` + +## Known Issues & Workarounds +- **ssh-agent systemd:** Removed due to SELinux blocking transitions; managed via `.zshrc` startup instead +- **ACL issue:** `allow_world_readable_tmpfiles = True` required in `ansible.cfg` for macOS→Fedora `become_user` compatibility + +## Required Credentials +- `GITEA_APP_TOKEN` +- `OPEN_WEBUI_API_KEY` +- `CLOUDNS_AUTH_ID` / `CLOUDNS_PASSWORD` (for certbot DNS-01) +- `OAUTH2_PROXY_COOKIE_SECRET` / `CLIENT_ID` / `CLIENT_SECRET` (for nginx_proxy role) + +## Ansible.cfg Fix +Added `allow_world_readable_tmpfiles = True` under `[defaults]` to fix `become_user` failures on Fedora when running from macOS. diff --git a/terraform/apps/dev-01/config.tf b/terraform/apps/dev-01/config.tf index 0fdf09e..10ef9f0 100644 --- a/terraform/apps/dev-01/config.tf +++ b/terraform/apps/dev-01/config.tf @@ -116,6 +116,16 @@ locals { type = "A" content = "10.19.4.210" }, + { + name = "ide" + type = "A" + content = "10.19.4.210" + }, + { + name = "agent" + type = "A" + content = "10.19.4.210" + }, { name = "vaultwarden" type = "CNAME" @@ -126,16 +136,6 @@ locals { type = "CNAME" content = "dev-01.${local.lab_domain}." }, - { - name = "opencode" - type = "CNAME" - content = "dev-01.${local.lab_domain}." - }, - { - name = "vscode" - type = "CNAME" - content = "dev-01.${local.lab_domain}." - }, ] } ] @@ -157,38 +157,6 @@ locals { # }, # ] # } - "opencode" = { - http_port = 4096 - tls = true - fqdn = ["opencode.lab.alexpires.me"] - upstream = "http://10.19.4.210:4096" - read_timeout = "3600s" - - locations = [ - { - path = "/" - headers = { - "Upgrade" = "$http_upgrade" - "Connection" = "upgrade" - } - }, - ] - }, - "code-server" = { - http_port = 3000 - tls = true - fqdn = ["vscode.lab.alexpires.me"] - upstream = "http://10.19.4.210:3000" - locations = [ - { - path = "/" - headers = { - "Upgrade" = "$http_upgrade" - "Connection" = "upgrade" - } - }, - ] - } } streams = {