From 34b45853e26997eafed0bdfcb2c3c87e82a43182 Mon Sep 17 00:00:00 2001 From: Bastian de Byl Date: Tue, 13 Jan 2026 16:08:38 -0500 Subject: [PATCH] graylog updates, test.debyl.io, scripts for reference --- ansible/deploy_home.yml | 2 + .../roles/graylog-config/defaults/main.yml | 170 ++++++++ .../graylog-config/tasks/lookup_tables.yml | 187 ++++++++ ansible/roles/graylog-config/tasks/main.yml | 15 + .../roles/graylog-config/tasks/pipelines.yml | 188 ++++++++ .../roles/graylog-config/tasks/streams.yml | 127 ++++++ .../templates/pipeline_source.j2 | 8 + .../podman/tasks/containers/base/caddy.yml | 1 + .../tasks/containers/base/conf-caddy.yml | 11 + .../roles/podman/templates/caddy/Caddyfile.j2 | 29 +- ansible/vars/vault.yml | Bin 16512 -> 16641 bytes scripts/steam-workshop-query.py | 401 ++++++++++++++++++ 12 files changed, 1136 insertions(+), 3 deletions(-) create mode 100644 ansible/roles/graylog-config/defaults/main.yml create mode 100644 ansible/roles/graylog-config/tasks/lookup_tables.yml create mode 100644 ansible/roles/graylog-config/tasks/main.yml create mode 100644 ansible/roles/graylog-config/tasks/pipelines.yml create mode 100644 ansible/roles/graylog-config/tasks/streams.yml create mode 100644 ansible/roles/graylog-config/templates/pipeline_source.j2 create mode 100755 scripts/steam-workshop-query.py diff --git a/ansible/deploy_home.yml b/ansible/deploy_home.yml index 76185cb..a40b77e 100644 --- a/ansible/deploy_home.yml +++ b/ansible/deploy_home.yml @@ -9,3 +9,5 @@ # SSL certificates are now handled automatically by Caddy # - role: ssl # REMOVED - Caddy handles all certificate management - role: github-actions + - role: graylog-config + tags: graylog-config diff --git a/ansible/roles/graylog-config/defaults/main.yml b/ansible/roles/graylog-config/defaults/main.yml new file mode 100644 index 0000000..2895e1a --- /dev/null +++ b/ansible/roles/graylog-config/defaults/main.yml @@ -0,0 +1,170 @@ +--- +# Graylog API Configuration +graylog_api_url: "https://logs.debyl.io/api" +# graylog_api_token: defined in vault + +# Default index set for new streams (Default Stream index set) +graylog_default_index_set: "6955a9d3cc3f442e78805871" + +# Stream definitions +graylog_streams: + - title: "debyltech-api" + description: "Lambda API events from debyltech-api service" + rules: + - field: "service" + value: "debyltech-api" + type: 1 # EXACT match + inverted: false + + - title: "caddy-access" + description: "Web traffic access logs from Caddy" + rules: + - field: "source" + value: "caddy" + type: 1 + inverted: false + - field: "log_type" + value: "access" + type: 1 + inverted: false + + - title: "caddy-fulfillr" + description: "Fulfillr-specific web traffic" + rules: + - field: "source" + value: "caddy" + type: 1 + inverted: false + - field: "tag" + value: "caddy.fulfillr" + type: 1 + inverted: false + + - title: "ssh-security" + description: "SSH access and security logs" + rules: + - field: "source" + value: "sshd" + type: 1 + inverted: false + + - title: "container-logs" + description: "Container stdout/stderr from Podman" + rules: + - field: "source" + value: "podman" + type: 1 + inverted: false + +# Pipeline definitions +graylog_pipelines: + - title: "GeoIP Enrichment" + description: "Add geographic information to access logs" + stages: + - stage: 0 + match: "EITHER" + rules: + - "geoip_caddy_access" + + - title: "Debyltech Event Classification" + description: "Categorize debyltech-api events" + stages: + - stage: 0 + match: "EITHER" + rules: + - "classify_order_events" + - "classify_review_events" + - "classify_backinstock_events" + - "classify_shipping_events" + - "classify_product_events" + - stage: 1 + match: "EITHER" + rules: + - "classify_default_events" + +# Pipeline rule definitions +graylog_pipeline_rules: + - title: "geoip_caddy_access" + description: "GeoIP lookup for Caddy access logs" + source: | + rule "GeoIP for Caddy Access" + when + has_field("request_remote_ip") + then + let ip = to_string($message.request_remote_ip); + let geo = lookup("geoip-lookup", ip); + set_field("geo_country", geo["country"].iso_code); + set_field("geo_city", geo["city"].names.en); + set_field("geo_coordinates", geo["coordinates"]); + end + + - title: "classify_order_events" + description: "Classify order events" + source: | + rule "Classify order events" + when + has_field("event") AND contains(to_string($message.event), "order") + then + set_field("event_category", "order"); + end + + - title: "classify_review_events" + description: "Classify review events" + source: | + rule "Classify review events" + when + has_field("event") AND contains(to_string($message.event), "review") + then + set_field("event_category", "review"); + end + + - title: "classify_backinstock_events" + description: "Classify back-in-stock events" + source: | + rule "Classify back-in-stock events" + when + has_field("event") AND contains(to_string($message.event), "backinstock") + then + set_field("event_category", "backinstock"); + end + + - title: "classify_shipping_events" + description: "Classify shipping events" + source: | + rule "Classify shipping events" + when + has_field("event") AND contains(to_string($message.event), "shipping") + then + set_field("event_category", "shipping"); + end + + - title: "classify_product_events" + description: "Classify product events" + source: | + rule "Classify product events" + when + has_field("event") AND contains(to_string($message.event), "product") + then + set_field("event_category", "product"); + end + + - title: "classify_default_events" + description: "Default category for unclassified events" + source: | + rule "Classify default events" + when + has_field("event") AND NOT has_field("event_category") + then + set_field("event_category", "other"); + end + +# Pipeline to stream connections +graylog_pipeline_connections: + - pipeline: "GeoIP Enrichment" + streams: + - "caddy-access" + - "caddy-fulfillr" + + - pipeline: "Debyltech Event Classification" + streams: + - "debyltech-api" diff --git a/ansible/roles/graylog-config/tasks/lookup_tables.yml b/ansible/roles/graylog-config/tasks/lookup_tables.yml new file mode 100644 index 0000000..af8430e --- /dev/null +++ b/ansible/roles/graylog-config/tasks/lookup_tables.yml @@ -0,0 +1,187 @@ +--- +# Graylog Lookup Table Management via REST API +# Creates Data Adapters, Caches, and Lookup Tables for GeoIP + +# ============================================================================= +# Data Adapters +# ============================================================================= + +- name: get existing data adapters + ansible.builtin.uri: + url: "{{ graylog_api_url }}/system/lookup/adapters" + method: GET + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Accept: application/json + status_code: 200 + register: existing_adapters + tags: graylog-config, lookup-tables + +- name: build list of existing adapter names + ansible.builtin.set_fact: + existing_adapter_names: "{{ existing_adapters.json.data_adapters | default([]) | map(attribute='name') | list }}" + tags: graylog-config, lookup-tables + +- name: create GeoIP data adapter + ansible.builtin.uri: + url: "{{ graylog_api_url }}/system/lookup/adapters" + method: POST + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Content-Type: application/json + body_format: json + body: + name: "geoip-adapter" + title: "GeoIP MaxMind Adapter" + description: "MaxMind GeoLite2-City database adapter" + config: + type: "maxmind_geoip" + path: "/usr/share/graylog/geoip/GeoLite2-City.mmdb" + database_type: "MAXMIND_CITY" + check_interval: 86400 + check_interval_unit: "SECONDS" + status_code: [200, 201] + when: "'geoip-adapter' not in existing_adapter_names" + register: created_adapter + tags: graylog-config, lookup-tables + +# ============================================================================= +# Caches +# ============================================================================= + +- name: get existing caches + ansible.builtin.uri: + url: "{{ graylog_api_url }}/system/lookup/caches" + method: GET + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Accept: application/json + status_code: 200 + register: existing_caches + tags: graylog-config, lookup-tables + +- name: build list of existing cache names + ansible.builtin.set_fact: + existing_cache_names: "{{ existing_caches.json.caches | default([]) | map(attribute='name') | list }}" + tags: graylog-config, lookup-tables + +- name: create GeoIP cache + ansible.builtin.uri: + url: "{{ graylog_api_url }}/system/lookup/caches" + method: POST + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Content-Type: application/json + body_format: json + body: + name: "geoip-cache" + title: "GeoIP Cache" + description: "Cache for GeoIP lookups" + config: + type: "guava_cache" + max_size: 10000 + expire_after_access: 3600 + expire_after_access_unit: "SECONDS" + expire_after_write: 0 + expire_after_write_unit: "SECONDS" + status_code: [200, 201] + when: "'geoip-cache' not in existing_cache_names" + register: created_cache + tags: graylog-config, lookup-tables + +# ============================================================================= +# Lookup Tables +# ============================================================================= + +- name: refresh adapters list + ansible.builtin.uri: + url: "{{ graylog_api_url }}/system/lookup/adapters" + method: GET + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Accept: application/json + status_code: 200 + register: all_adapters + tags: graylog-config, lookup-tables + +- name: refresh caches list + ansible.builtin.uri: + url: "{{ graylog_api_url }}/system/lookup/caches" + method: GET + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Accept: application/json + status_code: 200 + register: all_caches + tags: graylog-config, lookup-tables + +- name: build adapter and cache ID maps + ansible.builtin.set_fact: + adapter_id_map: "{{ all_adapters.json.data_adapters | default([]) | items2dict(key_name='name', value_name='id') }}" + cache_id_map: "{{ all_caches.json.caches | default([]) | items2dict(key_name='name', value_name='id') }}" + tags: graylog-config, lookup-tables + +- name: get existing lookup tables + ansible.builtin.uri: + url: "{{ graylog_api_url }}/system/lookup/tables" + method: GET + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Accept: application/json + status_code: 200 + register: existing_tables + tags: graylog-config, lookup-tables + +- name: build list of existing table names + ansible.builtin.set_fact: + existing_table_names: "{{ existing_tables.json.lookup_tables | default([]) | map(attribute='name') | list }}" + tags: graylog-config, lookup-tables + +- name: create GeoIP lookup table + ansible.builtin.uri: + url: "{{ graylog_api_url }}/system/lookup/tables" + method: POST + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Content-Type: application/json + body_format: json + body: + name: "geoip-lookup" + title: "GeoIP Lookup Table" + description: "Lookup table for GeoIP resolution" + cache_id: "{{ cache_id_map['geoip-cache'] }}" + data_adapter_id: "{{ adapter_id_map['geoip-adapter'] }}" + default_single_value: "" + default_single_value_type: "NULL" + default_multi_value: "" + default_multi_value_type: "NULL" + status_code: [200, 201] + when: + - "'geoip-lookup' not in existing_table_names" + - "'geoip-adapter' in adapter_id_map" + - "'geoip-cache' in cache_id_map" + tags: graylog-config, lookup-tables diff --git a/ansible/roles/graylog-config/tasks/main.yml b/ansible/roles/graylog-config/tasks/main.yml new file mode 100644 index 0000000..319a622 --- /dev/null +++ b/ansible/roles/graylog-config/tasks/main.yml @@ -0,0 +1,15 @@ +--- +# Graylog Configuration via REST API +# Configures lookup tables, streams, pipelines, and pipeline rules + +- name: include lookup table configuration + ansible.builtin.include_tasks: lookup_tables.yml + tags: graylog-config, lookup-tables + +- name: include stream configuration + ansible.builtin.include_tasks: streams.yml + tags: graylog-config, streams + +- name: include pipeline configuration + ansible.builtin.include_tasks: pipelines.yml + tags: graylog-config, pipelines diff --git a/ansible/roles/graylog-config/tasks/pipelines.yml b/ansible/roles/graylog-config/tasks/pipelines.yml new file mode 100644 index 0000000..a76ecd9 --- /dev/null +++ b/ansible/roles/graylog-config/tasks/pipelines.yml @@ -0,0 +1,188 @@ +--- +# Graylog Pipeline Management via REST API +# Idempotent: checks for existing pipelines/rules before creating + +# ============================================================================= +# Pipeline Rules +# ============================================================================= + +- name: get existing pipeline rules + ansible.builtin.uri: + url: "{{ graylog_api_url }}/system/pipelines/rule" + method: GET + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Accept: application/json + status_code: 200 + register: existing_rules + tags: graylog-config, pipelines + +- name: build list of existing rule titles + ansible.builtin.set_fact: + existing_rule_titles: "{{ existing_rules.json | map(attribute='title') | list }}" + existing_rule_map: "{{ existing_rules.json | items2dict(key_name='title', value_name='id') }}" + tags: graylog-config, pipelines + +- name: create pipeline rules + ansible.builtin.uri: + url: "{{ graylog_api_url }}/system/pipelines/rule" + method: POST + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Content-Type: application/json + body_format: json + body: + title: "{{ item.title }}" + description: "{{ item.description | default('') }}" + source: "{{ item.source }}" + status_code: [200, 201] + loop: "{{ graylog_pipeline_rules }}" + loop_control: + label: "{{ item.title }}" + when: item.title not in existing_rule_titles + register: created_rules + tags: graylog-config, pipelines + +- name: refresh rule list after creation + ansible.builtin.uri: + url: "{{ graylog_api_url }}/system/pipelines/rule" + method: GET + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Accept: application/json + status_code: 200 + register: all_rules + tags: graylog-config, pipelines + +- name: build rule ID lookup + ansible.builtin.set_fact: + rule_id_map: "{{ all_rules.json | items2dict(key_name='title', value_name='id') }}" + tags: graylog-config, pipelines + +# ============================================================================= +# Pipelines +# ============================================================================= + +- name: get existing pipelines + ansible.builtin.uri: + url: "{{ graylog_api_url }}/system/pipelines/pipeline" + method: GET + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Accept: application/json + status_code: 200 + register: existing_pipelines + tags: graylog-config, pipelines + +- name: build list of existing pipeline titles + ansible.builtin.set_fact: + existing_pipeline_titles: "{{ existing_pipelines.json | map(attribute='title') | list }}" + existing_pipeline_map: "{{ existing_pipelines.json | items2dict(key_name='title', value_name='id') }}" + tags: graylog-config, pipelines + +- name: build pipeline source for each pipeline + ansible.builtin.set_fact: + pipeline_sources: "{{ pipeline_sources | default({}) | combine({item.title: lookup('template', 'pipeline_source.j2')}) }}" + loop: "{{ graylog_pipelines }}" + loop_control: + label: "{{ item.title }}" + vars: + pipeline: "{{ item }}" + tags: graylog-config, pipelines + +- name: create pipelines + ansible.builtin.uri: + url: "{{ graylog_api_url }}/system/pipelines/pipeline" + method: POST + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Content-Type: application/json + body_format: json + body: + title: "{{ item.title }}" + description: "{{ item.description | default('') }}" + source: "{{ pipeline_sources[item.title] }}" + status_code: [200, 201] + loop: "{{ graylog_pipelines }}" + loop_control: + label: "{{ item.title }}" + when: item.title not in existing_pipeline_titles + register: created_pipelines + tags: graylog-config, pipelines + +- name: refresh pipeline list after creation + ansible.builtin.uri: + url: "{{ graylog_api_url }}/system/pipelines/pipeline" + method: GET + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Accept: application/json + status_code: 200 + register: all_pipelines + tags: graylog-config, pipelines + +- name: build pipeline ID lookup + ansible.builtin.set_fact: + pipeline_id_map: "{{ all_pipelines.json | items2dict(key_name='title', value_name='id') }}" + tags: graylog-config, pipelines + +# ============================================================================= +# Pipeline to Stream Connections +# ============================================================================= + +- name: get current pipeline connections + ansible.builtin.uri: + url: "{{ graylog_api_url }}/system/pipelines/connections" + method: GET + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Accept: application/json + status_code: 200 + register: current_connections + tags: graylog-config, pipelines + +- name: connect pipelines to streams + ansible.builtin.uri: + url: "{{ graylog_api_url }}/system/pipelines/connections/to_stream" + method: POST + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Content-Type: application/json + body_format: json + body: + stream_id: "{{ stream_id_map[item.1] }}" + pipeline_ids: + - "{{ pipeline_id_map[item.0.pipeline] }}" + status_code: [200, 201] + loop: "{{ graylog_pipeline_connections | subelements('streams') }}" + loop_control: + label: "{{ item.0.pipeline }} -> {{ item.1 }}" + when: + - item.0.pipeline in pipeline_id_map + - item.1 in stream_id_map + ignore_errors: true + tags: graylog-config, pipelines diff --git a/ansible/roles/graylog-config/tasks/streams.yml b/ansible/roles/graylog-config/tasks/streams.yml new file mode 100644 index 0000000..4ac974e --- /dev/null +++ b/ansible/roles/graylog-config/tasks/streams.yml @@ -0,0 +1,127 @@ +--- +# Graylog Stream Management via REST API +# Idempotent: checks for existing streams before creating + +- name: get existing streams + ansible.builtin.uri: + url: "{{ graylog_api_url }}/streams" + method: GET + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Accept: application/json + status_code: 200 + register: existing_streams + tags: graylog-config, streams + +- name: build list of existing stream titles + ansible.builtin.set_fact: + existing_stream_titles: "{{ existing_streams.json.streams | map(attribute='title') | list }}" + existing_stream_map: "{{ existing_streams.json.streams | items2dict(key_name='title', value_name='id') }}" + tags: graylog-config, streams + +- name: create streams + ansible.builtin.uri: + url: "{{ graylog_api_url }}/streams" + method: POST + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Content-Type: application/json + body_format: json + body: + title: "{{ item.title }}" + description: "{{ item.description | default('') }}" + index_set_id: "{{ item.index_set_id | default(graylog_default_index_set) }}" + remove_matches_from_default_stream: "{{ item.remove_from_default | default(true) }}" + status_code: [200, 201] + loop: "{{ graylog_streams }}" + loop_control: + label: "{{ item.title }}" + when: item.title not in existing_stream_titles + register: created_streams + tags: graylog-config, streams + +- name: refresh stream list after creation + ansible.builtin.uri: + url: "{{ graylog_api_url }}/streams" + method: GET + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Accept: application/json + status_code: 200 + register: all_streams + tags: graylog-config, streams + +- name: build stream ID lookup + ansible.builtin.set_fact: + stream_id_map: "{{ all_streams.json.streams | items2dict(key_name='title', value_name='id') }}" + tags: graylog-config, streams + +- name: get existing rules for each stream + ansible.builtin.uri: + url: "{{ graylog_api_url }}/streams/{{ stream_id_map[item.title] }}/rules" + method: GET + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Accept: application/json + status_code: 200 + loop: "{{ graylog_streams }}" + loop_control: + label: "{{ item.title }}" + when: item.title in stream_id_map + register: stream_rules + tags: graylog-config, streams + +- name: create stream rules + ansible.builtin.uri: + url: "{{ graylog_api_url }}/streams/{{ stream_id_map[item.0.title] }}/rules" + method: POST + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + Content-Type: application/json + body_format: json + body: + field: "{{ item.1.field }}" + value: "{{ item.1.value }}" + type: "{{ item.1.type | default(1) }}" + inverted: "{{ item.1.inverted | default(false) }}" + description: "{{ item.1.description | default('') }}" + status_code: [200, 201] + loop: "{{ graylog_streams | subelements('rules', skip_missing=True) }}" + loop_control: + label: "{{ item.0.title }} - {{ item.1.field }}:{{ item.1.value }}" + when: + - item.0.title in stream_id_map + - stream_rules.results | selectattr('item.title', 'equalto', item.0.title) | map(attribute='json.stream_rules') | first | default([]) | selectattr('field', 'equalto', item.1.field) | selectattr('value', 'equalto', item.1.value) | list | length == 0 + tags: graylog-config, streams + +- name: start streams + ansible.builtin.uri: + url: "{{ graylog_api_url }}/streams/{{ stream_id_map[item.title] }}/resume" + method: POST + user: "{{ graylog_api_token }}" + password: token + force_basic_auth: true + headers: + X-Requested-By: ansible + status_code: [200, 204] + loop: "{{ graylog_streams }}" + loop_control: + label: "{{ item.title }}" + when: item.title in stream_id_map + ignore_errors: true + tags: graylog-config, streams diff --git a/ansible/roles/graylog-config/templates/pipeline_source.j2 b/ansible/roles/graylog-config/templates/pipeline_source.j2 new file mode 100644 index 0000000..bc68b2a --- /dev/null +++ b/ansible/roles/graylog-config/templates/pipeline_source.j2 @@ -0,0 +1,8 @@ +pipeline "{{ pipeline.title }}" +{% for stage in pipeline.stages %} +stage {{ stage.stage }} match {{ stage.match | default('EITHER') }} +{% for rule in stage.rules %} +rule "{{ rule }}" +{% endfor %} +{% endfor %} +end \ No newline at end of file diff --git a/ansible/roles/podman/tasks/containers/base/caddy.yml b/ansible/roles/podman/tasks/containers/base/caddy.yml index c4668a4..2b7981f 100644 --- a/ansible/roles/podman/tasks/containers/base/caddy.yml +++ b/ansible/roles/podman/tasks/containers/base/caddy.yml @@ -25,6 +25,7 @@ # Legacy volume mounts removed - Caddy manages certificates automatically # Mount static site directories - "/usr/local/share/fulfillr-site:/usr/local/share/fulfillr-site:ro" + - "/usr/local/share/test-site:/srv/test-site:ro" env: CADDY_ADMIN: "0.0.0.0:2019" restart_policy: always diff --git a/ansible/roles/podman/tasks/containers/base/conf-caddy.yml b/ansible/roles/podman/tasks/containers/base/conf-caddy.yml index f8932fb..a603459 100644 --- a/ansible/roles/podman/tasks/containers/base/conf-caddy.yml +++ b/ansible/roles/podman/tasks/containers/base/conf-caddy.yml @@ -27,6 +27,17 @@ - caddy - ssl +- name: create test-site directory + become: true + ansible.builtin.file: + path: /usr/local/share/test-site + state: directory + owner: "{{ ansible_user }}" + group: "{{ ansible_user }}" + mode: '0755' + tags: + - caddy + - name: deploy caddyfile become: true ansible.builtin.template: diff --git a/ansible/roles/podman/templates/caddy/Caddyfile.j2 b/ansible/roles/podman/templates/caddy/Caddyfile.j2 index d096741..b71d83a 100644 --- a/ansible/roles/podman/templates/caddy/Caddyfile.j2 +++ b/ansible/roles/podman/templates/caddy/Caddyfile.j2 @@ -327,22 +327,45 @@ reverse_proxy localhost:9054 } - + # Serve static files with SPA fallback handle { root * /usr/local/share/fulfillr-site try_files {path} {path}/ /index.html file_server } - + header { Strict-Transport-Security "max-age=31536000; includeSubDomains" X-Content-Type-Options "nosniff" Referrer-Policy "same-origin" } - + log { output file /var/log/caddy/fulfillr.log format json } +} + +# ============================================================================ +# TEST/STAGING SITES +# ============================================================================ + +# Test Site - test.debyl.io (Public static site hosting, no caching) +test.debyl.io { + import common_headers + + root * /srv/test-site + try_files {path} {path}/ /index.html + file_server + + # Disable all caching for test sites + header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" + header Pragma "no-cache" + + log { + output file /var/log/caddy/test.log + format json + level {{ caddy_log_level }} + } } \ No newline at end of file diff --git a/ansible/vars/vault.yml b/ansible/vars/vault.yml index 61330837874f45326eb3cd0ea0af174c23ed085b..d99bc71b76dd29cce258f766c3531bd7e70c79b9 100644 GIT binary patch literal 16641 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 literal 16512 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 diff --git a/scripts/steam-workshop-query.py b/scripts/steam-workshop-query.py new file mode 100755 index 0000000..a044468 --- /dev/null +++ b/scripts/steam-workshop-query.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +""" +Steam Workshop Query Tool for Project Zomboid Mods + +Queries Steam API to get mod details including correct Mod IDs with special characters. +Useful for generating properly formatted mod lists for Build 42 servers. + +Usage: + # Query individual workshop items (semicolon-separated) + python steam-workshop-query.py "ID1;ID2;ID3" + + # Query from a Steam Workshop collection + python steam-workshop-query.py --collection 3625776190 + python steam-workshop-query.py --collection "https://steamcommunity.com/sharedfiles/filedetails?id=3625776190" + + # Output formats + --json Output raw JSON data + --ansible Output workshop_items and mod_ids strings for ansible config + --report Human-readable report (default) + +Examples: + python steam-workshop-query.py "3171167894;3330403100" --ansible + python steam-workshop-query.py --collection 3625776190 --report +""" + +import requests +import json +import sys +import time +import re +import argparse +from typing import List, Dict, Optional, Tuple +from datetime import datetime + +STEAM_API_DETAILS = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/" +STEAM_API_COLLECTION = "https://api.steampowered.com/ISteamRemoteStorage/GetCollectionDetails/v1/" +BATCH_SIZE = 50 # Conservative batch size to avoid rate limits +DELAY_BETWEEN_BATCHES = 1.0 # seconds + + +def get_collection_items(collection_id: str) -> List[str]: + """Fetch all workshop item IDs from a Steam Workshop collection.""" + data = {"collectioncount": 1, "publishedfileids[0]": collection_id} + response = requests.post(STEAM_API_COLLECTION, data=data) + response.raise_for_status() + result = response.json() + + items = [] + collection_details = result.get("response", {}).get("collectiondetails", []) + + if not collection_details: + print(f"Warning: No collection found with ID {collection_id}", file=sys.stderr) + return items + + for coll in collection_details: + if coll.get("result") != 1: + print(f"Warning: Collection {collection_id} returned error result", file=sys.stderr) + continue + for child in coll.get("children", []): + file_id = child.get("publishedfileid") + if file_id: + items.append(file_id) + + return items + + +def query_workshop_items_batch(item_ids: List[str]) -> List[Dict]: + """Query Steam API for a batch of workshop item details.""" + data = {"itemcount": len(item_ids)} + for i, item_id in enumerate(item_ids): + data[f"publishedfileids[{i}]"] = item_id + + response = requests.post(STEAM_API_DETAILS, data=data) + response.raise_for_status() + result = response.json() + + return result.get("response", {}).get("publishedfiledetails", []) + + +def query_all_workshop_items(item_ids: List[str]) -> List[Dict]: + """Query Steam API for all workshop items, handling batching.""" + all_items = [] + + for i in range(0, len(item_ids), BATCH_SIZE): + batch = item_ids[i:i + BATCH_SIZE] + print(f"Querying batch {i // BATCH_SIZE + 1} ({len(batch)} items)...", file=sys.stderr) + + items = query_workshop_items_batch(batch) + all_items.extend(items) + + # Delay between batches to avoid rate limiting + if i + BATCH_SIZE < len(item_ids): + time.sleep(DELAY_BETWEEN_BATCHES) + + return all_items + + +def extract_mod_id(item: Dict) -> Optional[str]: + """ + Extract Mod ID(s) from item description. + PZ mods typically include 'Mod ID: xxx' in their description. + Some mods have multiple Mod IDs on separate lines or comma-separated. + """ + description = item.get("description", "") + + # Find ALL "Mod ID: xxx" patterns in description (multiple lines) + matches = re.findall(r'Mod ID:\s*([^\r\n]+)', description, re.IGNORECASE) + + if not matches: + return None + + all_mod_ids = [] + for match in matches: + mod_id_str = match.strip().rstrip('.') + # Handle comma or semicolon separated mod IDs on same line + if ',' in mod_id_str: + all_mod_ids.extend([m.strip() for m in mod_id_str.split(',')]) + elif ';' in mod_id_str: + all_mod_ids.extend([m.strip() for m in mod_id_str.split(';')]) + else: + all_mod_ids.append(mod_id_str) + + # Remove empty strings and duplicates while preserving order + seen = set() + unique_ids = [] + for mod_id in all_mod_ids: + if mod_id and mod_id not in seen: + seen.add(mod_id) + unique_ids.append(mod_id) + + return ';'.join(unique_ids) if unique_ids else None + + +def check_b42_compatible(item: Dict) -> Tuple[bool, str]: + """ + Check if mod appears to be B42 compatible. + Returns (is_compatible, reason). + """ + title = item.get("title", "").lower() + tags = [t.get("tag", "").lower() for t in item.get("tags", [])] + all_tags_str = " ".join(tags) + + # B42 indicators in title or tags + b42_patterns = [ + r'\bb42\b', + r'build\s*42', + r'\b42\.\d+', + r'\[b42\]', + r'\(b42\)', + ] + + for pattern in b42_patterns: + if re.search(pattern, title) or re.search(pattern, all_tags_str): + return True, "B42 mentioned in title/tags" + + # Check for B41 only indicators (might not be compatible) + b41_only = re.search(r'\bb41\b.*only', title) or re.search(r'build\s*41\s*only', title) + if b41_only: + return False, "B41 only" + + return False, "No B42 indicator found" + + +def has_special_characters(text: str) -> bool: + """Check if text contains special characters that need attention.""" + special = ["'", '"', "!", "&", "(", ")"] + return any(c in text for c in special) + + +def extract_collection_id(url_or_id: str) -> str: + """Extract collection ID from URL or return as-is if already an ID.""" + match = re.search(r'[?&]id=(\d+)', url_or_id) + return match.group(1) if match else url_or_id + + +def format_timestamp(unix_ts: int) -> str: + """Format Unix timestamp as readable date.""" + if not unix_ts: + return "Unknown" + return datetime.fromtimestamp(unix_ts).strftime("%Y-%m-%d") + + +def process_items(items: List[Dict]) -> Dict: + """ + Process workshop items and extract relevant information. + Returns a dict with processed data and analysis. + """ + processed = [] + duplicates = {} + issues = [] + + for item in items: + workshop_id = item.get("publishedfileid", "unknown") + title = item.get("title", "Unknown") + mod_id = extract_mod_id(item) + b42_compat, b42_reason = check_b42_compatible(item) + last_updated = item.get("time_updated", 0) + result_code = item.get("result", 0) + + entry = { + "workshop_id": workshop_id, + "title": title, + "mod_id": mod_id, + "b42_compatible": b42_compat, + "b42_reason": b42_reason, + "last_updated": format_timestamp(last_updated), + "has_special_chars": has_special_characters(mod_id or ""), + "result_code": result_code, + } + + # Track duplicates by mod_id + if mod_id: + if mod_id in duplicates: + duplicates[mod_id].append(workshop_id) + else: + duplicates[mod_id] = [workshop_id] + + # Track issues + if result_code != 1: + issues.append(f"Workshop item {workshop_id} returned error (result={result_code})") + if not mod_id: + issues.append(f"Workshop item {workshop_id} ({title}) has no Mod ID tag") + if entry["has_special_chars"]: + issues.append(f"Mod ID '{mod_id}' contains special characters") + + processed.append(entry) + + # Find actual duplicates (mod_id appearing more than once) + duplicate_mod_ids = {k: v for k, v in duplicates.items() if len(v) > 1} + + return { + "items": processed, + "duplicates": duplicate_mod_ids, + "issues": issues, + "total_count": len(items), + "valid_count": len([i for i in processed if i["mod_id"]]), + } + + +def output_report(data: Dict) -> None: + """Output human-readable report.""" + print("\n" + "=" * 80) + print("STEAM WORKSHOP MOD ANALYSIS REPORT") + print("=" * 80) + + print(f"\nTotal items: {data['total_count']}") + print(f"Valid items (with Mod ID): {data['valid_count']}") + + if data["duplicates"]: + print(f"\n{'=' * 40}") + print("DUPLICATE MOD IDs:") + print(f"{'=' * 40}") + for mod_id, workshop_ids in data["duplicates"].items(): + print(f" {mod_id}: {', '.join(workshop_ids)}") + + if data["issues"]: + print(f"\n{'=' * 40}") + print("ISSUES:") + print(f"{'=' * 40}") + for issue in data["issues"]: + print(f" - {issue}") + + print(f"\n{'=' * 40}") + print("MOD LIST:") + print(f"{'=' * 40}") + + for item in data["items"]: + b42_status = "[B42]" if item["b42_compatible"] else "[???]" + special = " [SPECIAL CHARS]" if item["has_special_chars"] else "" + mod_id_display = item["mod_id"] or "" + + print(f"\n Workshop: {item['workshop_id']}") + print(f" Title: {item['title']}") + print(f" Mod ID: {mod_id_display}{special}") + print(f" Status: {b42_status} {item['b42_reason']}") + print(f" Updated: {item['last_updated']}") + + +def output_ansible(data: Dict) -> None: + """Output ansible-ready configuration strings.""" + # Get unique, valid mod IDs (preserving order, removing duplicates) + seen_workshop = set() + seen_mod_ids = set() + workshop_items = [] + mod_ids = [] + + for item in data["items"]: + workshop_id = item["workshop_id"] + mod_id_str = item["mod_id"] + + # Skip if we've seen this workshop item + if workshop_id in seen_workshop: + continue + seen_workshop.add(workshop_id) + workshop_items.append(workshop_id) + + # Handle mod_id which may contain multiple IDs separated by semicolon + if mod_id_str: + for mod_id in mod_id_str.split(';'): + mod_id = mod_id.strip() + if mod_id and mod_id not in seen_mod_ids: + seen_mod_ids.add(mod_id) + mod_ids.append(mod_id) + + # Format for Build 42 (backslash prefix) + workshop_str = ";".join(workshop_items) + mod_ids_str = ";".join(f"\\{mid}" for mid in mod_ids) + + print("\n# Ansible Configuration for zomboid_mods") + print("# Copy these values to ansible/roles/podman/defaults/main.yml") + print("") + print("zomboid_mods:") + print(" workshop_items: >-") + print(f" {workshop_str}") + print(" mod_ids: >-") + print(f" {mod_ids_str}") + + if data["duplicates"]: + print("\n# WARNING: The following Mod IDs had duplicates (kept first occurrence):") + for mod_id, workshop_ids in data["duplicates"].items(): + print(f"# {mod_id}: {', '.join(workshop_ids)}") + + if data["issues"]: + print("\n# Issues found:") + for issue in data["issues"]: + print(f"# - {issue}") + + +def output_json(data: Dict) -> None: + """Output JSON data.""" + print(json.dumps(data, indent=2)) + + +def main(): + parser = argparse.ArgumentParser( + description="Query Steam Workshop for Project Zomboid mod details", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + + parser.add_argument( + "workshop_ids", + nargs="?", + help="Semicolon-separated workshop IDs (e.g., 'ID1;ID2;ID3')" + ) + parser.add_argument( + "--collection", "-c", + help="Steam Workshop collection ID or URL" + ) + parser.add_argument( + "--json", "-j", + action="store_true", + help="Output raw JSON data" + ) + parser.add_argument( + "--ansible", "-a", + action="store_true", + help="Output ansible-ready configuration" + ) + parser.add_argument( + "--report", "-r", + action="store_true", + help="Output human-readable report (default)" + ) + + args = parser.parse_args() + + # Determine input source + if args.collection: + collection_id = extract_collection_id(args.collection) + print(f"Fetching collection {collection_id}...", file=sys.stderr) + item_ids = get_collection_items(collection_id) + if not item_ids: + print("Error: No items found in collection", file=sys.stderr) + sys.exit(1) + print(f"Found {len(item_ids)} items in collection", file=sys.stderr) + elif args.workshop_ids: + item_ids = [id.strip() for id in args.workshop_ids.split(";") if id.strip()] + else: + parser.print_help() + sys.exit(1) + + # Query Steam API + print(f"Querying {len(item_ids)} workshop items...", file=sys.stderr) + items = query_all_workshop_items(item_ids) + print(f"Retrieved {len(items)} item details", file=sys.stderr) + + # Process items + data = process_items(items) + + # Output based on format + if args.json: + output_json(data) + elif args.ansible: + output_ansible(data) + else: + output_report(data) + + +if __name__ == "__main__": + main()