Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,24 @@ This trial run is intentionally minimal. Use Docker Compose for Browserless, aut

## Deploy (Docker Compose)

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`.
Quick start:

```bash
export HTML2RSS_SECRET_KEY="$(openssl rand -hex 32)"
export HEALTH_CHECK_TOKEN="replace-with-a-strong-token"
export BROWSERLESS_IO_API_TOKEN="replace-with-your-browserless-token"
export BUILD_TAG="local"
export GIT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo dev)"
export AUTO_SOURCE_ENABLED=true
docker-compose up
```

Optional:

```bash
export SENTRY_DSN="https://examplePublicKey@o0.ingest.sentry.io/0"
export SENTRY_ENABLE_LOGS=true
```

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
Loading
Loading