Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ This trial run is intentionally minimal. Use Docker Compose for Browserless, aut

1. Generate a key: `openssl rand -hex 32`.
2. Export `HTML2RSS_SECRET_KEY`, `HEALTH_CHECK_TOKEN`, and `BROWSERLESS_IO_API_TOKEN` in your shell or `.env`.
3. Start: `docker-compose up`.
3. Optionally export `SENTRY_DSN` to send application errors to Sentry.
4. Optionally export `SENTRY_ENABLE_LOGS=true` to forward structured application logs to Sentry.
5. Start: `docker-compose up`.
Comment thread
gildesmarais marked this conversation as resolved.
Outdated

UI + API run on `http://localhost:4000`. The app exits if the secret key is missing.

Expand Down
4 changes: 4 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ desc 'Build and run docker image/container, and send requests to it'
task :test do
current_dir = ENV.fetch('GITHUB_WORKSPACE', __dir__)
smoke_auto_source_enabled = ENV.fetch('SMOKE_AUTO_SOURCE_ENABLED', 'false')
smoke_build_tag = ENV.fetch('SMOKE_BUILD_TAG', ENV.fetch('BUILD_TAG', 'docker-smoke'))
smoke_git_sha = ENV.fetch('SMOKE_GIT_SHA', ENV.fetch('GITHUB_SHA', ENV.fetch('GIT_SHA', 'docker-smoke')))
image_name = 'html2rss/web'
skip_build = ENV.fetch('DOCKER_SMOKE_SKIP_BUILD', 'false') == 'true'

Expand All @@ -60,6 +62,8 @@ task :test do
'-d',
'-p 4000:4000',
'--env PUMA_LOG_CONFIG=1',
"--env BUILD_TAG=#{smoke_build_tag}",
"--env GIT_SHA=#{smoke_git_sha}",
'--env HEALTH_CHECK_TOKEN=CHANGE_ME_HEALTH_CHECK_TOKEN',
'--env HTML2RSS_SECRET_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
"--env AUTO_SOURCE_ENABLED=#{smoke_auto_source_enabled}",
Expand Down
2 changes: 1 addition & 1 deletion app/web/api/v1/health.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def authorize_health_check!(request)
# @param request [Rack::Request]
# @return [Boolean]
def env_health_check_token?(request)
configured_token = ENV.fetch('HEALTH_CHECK_TOKEN', '').to_s
configured_token = RuntimeEnv.health_check_token.to_s
provided_token = bearer_token(request)
return false if configured_token.empty? || provided_token.nil?
return false unless configured_token.bytesize == provided_token.bytesize
Expand Down
56 changes: 56 additions & 0 deletions app/web/boot/sentry.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# frozen_string_literal: true

module Html2rss
module Web
module Boot
##
# Configures Sentry boot-time error and structured log capture.
module Sentry
class << self
# @return [void]
def configure!
return unless configure?

Bundler.require(:sentry)
require 'sentry-ruby'
initialize_sentry!
end

private

# @return [Boolean]
def configure?
RuntimeEnv.sentry_enabled? && !sentry_initialized?
end

# @return [void]
def initialize_sentry!
::Sentry.init do |config|
apply_settings(config)
end
end

# @param config [Object]
# @return [void]
def apply_settings(config)
config.dsn = RuntimeEnv.sentry_dsn
config.environment = RuntimeEnv.rack_env
config.enable_logs = RuntimeEnv.sentry_logs_enabled?
config.send_default_pii = false
config.release = release_name
end

# @return [String]
def release_name
"#{RuntimeEnv.build_tag}+#{RuntimeEnv.git_sha}"
end

# @return [Boolean]
def sentry_initialized?
defined?(::Sentry) && ::Sentry.respond_to?(:initialized?) && ::Sentry.initialized?
end
end
end
end
end
end
40 changes: 39 additions & 1 deletion app/web/boot/setup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,24 @@ module Boot
##
# Applies boot-time runtime configuration outside the Roda class body.
module Setup
RACK_TIMEOUT_BUFFER_SECONDS = 5

class << self
# @return [Boolean]
def sentry_enabled?
RuntimeEnv.sentry_enabled?
end

# Validates environment configuration and wires the request service.
#
# @return [void]
def call!
validate_environment!
capture_runtime_env!
configure_sentry!
configure_request_service!
configure_runtime_logging!
log_startup!
end

private
Expand All @@ -25,9 +35,24 @@ def validate_environment!
Flags.validate!
end

# @return [void]
def capture_runtime_env!
RuntimeEnv.capture!
end

# @return [void]
def configure_sentry!
Sentry.configure!
end

# @return [void]
def configure_request_service!
nil
return unless defined?(Rack::Timeout)
return unless Rack::Timeout.respond_to?(:service_timeout=)

Rack::Timeout.service_timeout =
Html2rss::RequestService::Policy::DEFAULTS[:total_timeout_seconds] +
RACK_TIMEOUT_BUFFER_SECONDS
end

# @return [void]
Expand All @@ -36,6 +61,19 @@ def configure_runtime_logging!

Rack::Timeout::Logger.logger = AppLogger.logger
end

# @return [void]
def log_startup!
AppLogger.logger.info(
{
component: 'boot',
event_name: 'app.start',
build_tag: RuntimeEnv.build_tag,
git_sha: RuntimeEnv.git_sha,
sentry_enabled: RuntimeEnv.sentry_enabled?
}.to_json
)
end
end
end
end
Expand Down
40 changes: 39 additions & 1 deletion app/web/config/environment_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ module Web
##
# Environment validation for html2rss-web
# Handles validation of environment variables and configuration
module EnvironmentValidator
module EnvironmentValidator # rubocop:disable Metrics/ModuleLength
# rubocop:disable Metrics/ClassLength
class << self
##
# Validate required environment variables on startup
Expand All @@ -28,6 +29,7 @@ def validate_production_security!

validate_secret_key!
validate_account_configuration!
validate_build_metadata!
end

# @return [Boolean]
Expand Down Expand Up @@ -92,6 +94,15 @@ def validate_secret_key!
exit 1
end

# @return [void]
def validate_build_metadata!
return unless missing_build_metadata?

log_missing_build_metadata!
warn_lines(*missing_build_metadata_warning_lines)
exit 1
end

def validate_account_configuration!
accounts = AccountManager.accounts
weak_tokens = accounts.select { |acc| acc[:token].length < 16 }
Expand Down Expand Up @@ -128,7 +139,34 @@ def handle_weak_account_tokens!(weak_tokens)
)
exit 1
end

# @return [Boolean]
def missing_build_metadata?
build_metadata_values.any?(&:empty?)
end

# @return [Array<String>]
def build_metadata_values
%w[BUILD_TAG GIT_SHA].map { |key| ENV.fetch(key, '').strip }
end

# @return [void]
def log_missing_build_metadata!
SecurityLogger.log_config_validation_failure(
'build_metadata',
'Missing BUILD_TAG or GIT_SHA'
)
end

# @return [Array<String>]
def missing_build_metadata_warning_lines
[
'CRITICAL: Missing build metadata for production deployment!',
'Set BUILD_TAG to the release build tag and GIT_SHA to the deployed commit SHA.'
]
end
end
# rubocop:enable Metrics/ClassLength
end
end
end
109 changes: 109 additions & 0 deletions app/web/config/runtime_env.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# frozen_string_literal: true

module Html2rss
module Web
##
# Captures boot-time environment configuration and scrubs selected secrets
# from the process environment after validation.
module RuntimeEnv
SENSITIVE_KEYS = %w[HTML2RSS_SECRET_KEY HEALTH_CHECK_TOKEN SENTRY_DSN].freeze
BOOT_METADATA_KEYS = %w[BUILD_TAG GIT_SHA RACK_ENV SENTRY_ENABLE_LOGS].freeze

class << self
# @return [void]
def capture!
@values = tracked_env_values.freeze # rubocop:disable ThreadSafety/ClassInstanceVariable
scrub_sensitive_env!
nil
end

# @return [void]
def reset!
@values = nil # rubocop:disable ThreadSafety/ClassInstanceVariable
end

# @return [String]
def secret_key
fetch('HTML2RSS_SECRET_KEY')
end

# @return [String]
def health_check_token
fetch('HEALTH_CHECK_TOKEN', '')
end

# @return [String, nil]
def sentry_dsn
fetch('SENTRY_DSN', nil)
end

# @return [Boolean]
def sentry_enabled?
!sentry_dsn.to_s.strip.empty?
end

# @return [Boolean]
def sentry_logs_enabled?
parse_boolean(fetch('SENTRY_ENABLE_LOGS', 'false'), default: false)
end

# @return [String]
def build_tag
fetch('BUILD_TAG', 'unknown')
end

# @return [String]
def git_sha
fetch('GIT_SHA', 'unknown')
end

# @return [String]
def rack_env
fetch('RACK_ENV', ENV.fetch('RACK_ENV', 'development'))
end

private

# @param key [String]
# @param default [Object]
# @return [Object]
def fetch(key, default = :__missing__)
return ENV.fetch(key) if ENV.key?(key)

values = @values || {} # rubocop:disable ThreadSafety/ClassInstanceVariable
return values.fetch(key) if values.key?(key)
return default unless default == :__missing__

raise KeyError, "key not found: #{key}"
end

# @return [Hash{String=>String}]
def tracked_env_values
(SENSITIVE_KEYS + BOOT_METADATA_KEYS).each_with_object({}) do |key, memo|
memo[key] = ENV[key] if ENV.key?(key)
end
end

# @return [void]
def scrub_sensitive_env!
return nil if rack_env == 'test'

SENSITIVE_KEYS.each { |key| ENV.delete(key) }
nil
end

# @param value [Object]
# @param default [Boolean]
# @return [Boolean]
def parse_boolean(value, default:)
normalized = value.to_s.strip.downcase
return true if normalized == 'true'
return false if normalized == 'false'
return default if normalized.empty?

raise ArgumentError, "Malformed env 'SENTRY_ENABLE_LOGS': expected true/false, got '#{value}'"
end
end
end
end
end
2 changes: 1 addition & 1 deletion app/web/security/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ def with_validated_token(feed_token, url)

# @return [String]
def secret_key
ENV.fetch('HTML2RSS_SECRET_KEY')
RuntimeEnv.secret_key
end

# @param username [String, nil]
Expand Down
12 changes: 11 additions & 1 deletion app/web/telemetry/app_logger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ def build_logger
# @param message [String]
# @return [String]
def format_entry(severity, datetime, _progname, message)
"#{base_payload(severity, datetime).merge(normalize_message(message)).to_json}\n"
payload = base_payload(severity, datetime).merge(normalize_message(message))
emit_to_sentry(payload)
"#{payload.to_json}\n"
Comment thread
gildesmarais marked this conversation as resolved.
end

# @param severity [String]
Expand Down Expand Up @@ -95,6 +97,14 @@ def normalize_logfmt_value(raw_value)

value
end

# @param payload [Hash{Symbol=>Object}]
# @return [void]
def emit_to_sentry(payload)
SentryLogs.emit(payload)
rescue StandardError
nil
end
end
end
end
Expand Down
Loading
Loading