Skip to content
Open
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
8 changes: 8 additions & 0 deletions rel/overlay/etc/default.ini
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,14 @@ partitioned||* = true
; Checkpoint interval
;checkpoint_interval = 30000

; DNS override for replicator outbound requests
; Format: pattern:target[,pattern:target,...]
; Examples:
; *.example.com:proxy.internal
; api.example.com:127.0.0.1
; *.example.com:[2001:db8::1]
;dns_overrides =

; Some socket options that might boost performance in some scenarios:
; {nodelay, boolean()}
; {sndbuf, integer()}
Expand Down
4 changes: 4 additions & 0 deletions src/couch_replicator/priv/stats_descriptions.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,7 @@
{type, counter},
{desc, <<"number of times a worker is gracefully shut down">>}
]}.
{[couch_replicator, dns_overrides_applied], [
{type, counter},
{desc, <<"number of times DNS overrides were applied to replication requests">>}
]}.
7 changes: 6 additions & 1 deletion src/couch_replicator/src/couch_replicator_auth_session.erl
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
]).

-include_lib("couch_replicator/include/couch_replicator_api_wrap.hrl").
-include_lib("ibrowse/include/ibrowse.hrl").

-type headers() :: [{string(), string()}].
-type code() :: non_neg_integer().
Expand Down Expand Up @@ -311,11 +312,15 @@ refresh(#state{session_url = Url, user = User, pass = Pass} = State) ->
{ok, string(), headers(), binary()} | {error, term()}.
http_request(#state{httpdb_pool = Pool} = State, Url, Headers, Method, Body) ->
Timeout = State#state.httpdb_timeout,
Opts = [

Opts0 = [
{response_format, binary},
{inactivity_timeout, Timeout}
| State#state.httpdb_ibrowse_options
],

Opts = couch_replicator_dns:apply_dns_override(Url, Opts0),

{ok, Wrk} = couch_replicator_httpc_pool:get_worker(Pool),
try
Result = ibrowse:send_req_direct(
Expand Down
206 changes: 206 additions & 0 deletions src/couch_replicator/src/couch_replicator_dns.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
% Licensed under the Apache License, Version 2.0 (the "License"); you may not
% use this file except in compliance with the License. You may obtain a copy of
% the License at
%
% http://www.apache.org/licenses/LICENSE-2.0
%
% Unless required by applicable law or agreed to in writing, software
% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
% License for the specific language governing permissions and limitations under
% the License.

-module(couch_replicator_dns).

-include_lib("ibrowse/include/ibrowse.hrl").

-export([
init/0,
apply_dns_override/2
]).

-ifdef(TEST).
-export([
parse_config/1,
match_pattern/2,
get_overrides/0,
resolve_host/1,
is_ip_address/1
]).
-endif.

-type dns_override() :: {binary(), binary()}.

-define(DNS_OVERRIDES_KEY, {?MODULE, dns_overrides}).

%% Initialize DNS overrides cache
-spec init() -> ok.
init() ->
Overrides =
case config:get("replicator", "dns_overrides", undefined) of
undefined -> [];
ConfigStr -> parse_config(ConfigStr)
end,
persistent_term:put(?DNS_OVERRIDES_KEY, Overrides),
ok.

-spec resolve_host(string()) -> {string(), string() | undefined}.
resolve_host(Host) ->
case find_override(list_to_binary(Host), get_overrides()) of
{ok, Target} ->
{binary_to_list(Target), Host};
not_found ->
{Host, undefined}
end.

-spec get_overrides() -> [dns_override()].
get_overrides() ->
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to have to reparse everything on each resolve. We could use a persistent_term perhaps, but it would add a more code here...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a commit which adds a cache using a persistent_term.

try
persistent_term:get(?DNS_OVERRIDES_KEY, [])
catch
error:badarg ->
% not initialized yet, fall back to reading config
case config:get("replicator", "dns_overrides", undefined) of
undefined -> [];
ConfigStr -> parse_config(ConfigStr)
end
end.

-spec parse_config(string()) -> [dns_override()].
parse_config(ConfigStr) ->
ConfigBin = list_to_binary(ConfigStr),
Entries = binary:split(ConfigBin, <<",">>, [global, trim]),
lists:filtermap(fun parse_entry/1, Entries).

% Note: IPv6 addresses in targets must be enclosed in brackets.
% Format: pattern:target
% Valid: *.example.com:[2001:db8::1]
% Invalid: [2001:db8::1]:proxy.internal (IPv6 as pattern not supported)
parse_entry(<<>>) ->
false;
parse_entry(Entry0) ->
Entry = string:trim(Entry0),
case binary:split(Entry, <<":">>) of
Comment thread
willholley marked this conversation as resolved.
[Pattern0, Target0] ->
Pattern = string:trim(Pattern0),
Target = string:trim(Target0),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the IPv6 is passed in with brackets we should see if ibrowse knows how to connect to a bracketed address. It may have to be stripped of brackets and/or also parsed into an ipv6 address tuple

case {Pattern, Target} of
{<<>>, _} ->
invalid_entry(Entry);
{_, <<>>} ->
invalid_entry(Entry);
% Reject IPv6 addresses as patterns (they start with '[')
{<<"[", _/binary>>, _} ->
invalid_entry_reason(Entry, "IPv6 addresses cannot be used as patterns");
_ ->
{true, {Pattern, Target}}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would *example:127.0.0.1 work or *:127.0.0.1 work?

end;
_ ->
invalid_entry(Entry)
end.

invalid_entry(Entry) ->
couch_log:warning("Invalid dns_override entry: ~ts", [Entry]),
false.

invalid_entry_reason(Entry, Reason) ->
couch_log:warning("Invalid dns_override entry: ~ts (~s)", [Entry, Reason]),
false.

find_override(_Host, []) ->
not_found;
find_override(Host, [{Pattern, Target} | Rest]) ->
case match_pattern(Host, Pattern) of
true ->
{ok, Target};
false ->
find_override(Host, Rest)
end.

% DNS Override Pattern Matching
%
% Supports leading wildcard patterns only:
% - *.example.com matches any.subdomain.example.com
% - *.example.com does NOT match example.com (requires at least one subdomain)
%
% Not supported:
% - middle wildcards: sub.*.example.com
% - trailing wildcards: example.*
% - multiple wildcards: *.*.example.com
-spec match_pattern(binary(), binary()) -> boolean().
match_pattern(Host, Pattern) when is_binary(Host), is_binary(Pattern) ->
% DNS names are case-insensitive
HostLower = string:lowercase(Host),
PatternLower = string:lowercase(Pattern),
match_pattern_impl(HostLower, PatternLower).

match_pattern_impl(Host, <<"*", Suffix/binary>>) ->
% wildcard match: extract last N bytes from Host and compare to Suffix
Comment thread
willholley marked this conversation as resolved.
HostSize = byte_size(Host),
SuffixSize = byte_size(Suffix),
% ensure we have enough bytes before extracting suffix
case HostSize >= SuffixSize of
true ->
Pos = HostSize - SuffixSize,
binary:part(Host, Pos, SuffixSize) =:= Suffix;
false ->
false
end;
match_pattern_impl(Host, Pattern) ->
Host =:= Pattern.

-spec is_ip_address(string()) -> boolean().
is_ip_address(Host) when is_list(Host) ->
% Strip brackets for IPv6 if present
HostStripped = string:trim(Host, both, "[]"),
case inet:parse_address(HostStripped) of
{ok, _} -> true;
_ -> false
end.

%% Apply DNS override and SNI configuration to ibrowse options
-spec apply_dns_override(string(), list()) -> list().
apply_dns_override(Url, IbrowseOptions) ->
case ibrowse_lib:parse_url(Url) of
{error, _} ->
IbrowseOptions;
#url{host = Host, protocol = Protocol} ->
{TargetHost, OriginalHost} = resolve_host(Host),
apply_override_options(
IbrowseOptions,
Protocol,
TargetHost,
OriginalHost
)
end.

%% Internal: Apply connect_to and SNI options
-spec apply_override_options(list(), atom(), string(), string() | undefined) -> list().
apply_override_options(Opts, _Protocol, _TargetHost, undefined) ->
% No override active
Opts;
apply_override_options(Opts, Protocol, TargetHost, OriginalHost) ->
% Log DNS override
couch_log:debug(
"DNS override (~p): ~s -> ~s",
[Protocol, OriginalHost, TargetHost]
),
couch_stats:increment_counter([couch_replicator, dns_overrides_applied]),
% Add connect_to option
Opts1 = [{connect_to, TargetHost} | Opts],
% Add SNI for HTTPS if OriginalHost is a hostname (not IP)
case {Protocol, is_ip_address(OriginalHost)} of
{https, false} ->
add_sni_option(Opts1, OriginalHost);
_ ->
Opts1
end.

-spec add_sni_option(list(), string()) -> list().
add_sni_option(IbrowseOpts, Host) ->
SslOpts = proplists:get_value(ssl_options, IbrowseOpts, []),
SslOpts1 = [
{server_name_indication, Host}
| proplists:delete(server_name_indication, SslOpts)
],
lists:keystore(ssl_options, 1, IbrowseOpts, {ssl_options, SslOpts1}).
9 changes: 8 additions & 1 deletion src/couch_replicator/src/couch_replicator_httpc.erl
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ send_ibrowse_req(#httpdb{headers = BaseHeaders} = HttpDb0, Params) ->
{User, Pass} when is_list(User), is_list(Pass) ->
[{basic_auth, {User, Pass}}]
end,
IbrowseOptions =
IbrowseOptions0 =
BasicAuthOpts ++
[
{response_format, binary},
Expand All @@ -142,6 +142,13 @@ send_ibrowse_req(#httpdb{headers = BaseHeaders} = HttpDb0, Params) ->
HttpDb#httpdb.ibrowse_options
)
],

% Apply DNS override and SNI configuration
IbrowseOptions = couch_replicator_dns:apply_dns_override(
Url,
IbrowseOptions0
),

backoff_before_request(Worker, HttpDb, Params),
Response = ibrowse:send_req_direct(
Worker, Url, Headers2, Method, Body, IbrowseOptions, Timeout
Expand Down
4 changes: 4 additions & 0 deletions src/couch_replicator/src/couch_replicator_scheduler.erl
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ init(_) ->
],
?MODULE = ets:new(?MODULE, EtsOpts),
ok = couch_replicator_share:init(),
ok = couch_replicator_dns:init(),
ok = config:listen_for_changes(?MODULE, nil),
Interval = get_interval_msec(),
MaxJobs = config:get_integer("replicator", "max_jobs", ?DEFAULT_MAX_JOBS),
Expand Down Expand Up @@ -385,6 +386,9 @@ handle_config_change("replicator", "interval", V, _, S) ->
handle_config_change("replicator", "max_history", V, _, S) ->
ok = gen_server:cast(?MODULE, {set_max_history, list_to_integer(V)}),
{ok, S};
handle_config_change("replicator", "dns_overrides", _, _, S) ->
ok = couch_replicator_dns:init(),
{ok, S};
handle_config_change("replicator.shares", Key, deleted, _, S) ->
ok = gen_server:cast(?MODULE, {reset_shares, list_to_binary(Key)}),
{ok, S};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
% Licensed under the Apache License, Version 2.0 (the "License"); you may not
% use this file except in compliance with the License. You may obtain a copy of
% the License at
%
% http://www.apache.org/licenses/LICENSE-2.0
%
% Unless required by applicable law or agreed to in writing, software
% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
% License for the specific language governing permissions and limitations under
% the License.

-module(couch_replicator_dns_override_tests).

-include_lib("couch/include/couch_eunit.hrl").
-include_lib("couch/include/couch_db.hrl").
-include_lib("ibrowse/include/ibrowse.hrl").

dns_override_replication_test_() ->
{
"DNS override replication tests",
{
foreach,
fun setup/0,
fun teardown/1,
[
?TDEF_FE(should_replicate_with_dns_override)
]
}
}.

setup() ->
couch_replicator_test_helper:test_setup().

teardown(Ctx) ->
config:delete("replicator", "dns_overrides", false),
couch_replicator_test_helper:test_teardown(Ctx).

should_replicate_with_dns_override({_Ctx, {Source, Target}}) ->
create_doc(Source),

SourceUrl = db_url(Source),
#url{host = SourceHost} = ibrowse_lib:parse_url(binary_to_list(SourceUrl)),

% configure DNS override: example.com -> actual source host
OverrideConfig = "example.com:" ++ SourceHost,
config:set("replicator", "dns_overrides", OverrideConfig, false),

% reinitialize DNS cache to pick up the new config
couch_replicator_dns:init(),

% replace source host with example.com
OverrideUrl = re:replace(SourceUrl, SourceHost, "example.com", [{return, binary}]),

% replicate using overridden URL
replicate(OverrideUrl, db_url(Target)),

% verify replication succeeded by comparing doc counts
?assertEqual(ok, compare(Source, Target)).

create_doc(DbName) ->
Doc = couch_doc:from_json_obj({[{<<"_id">>, <<"test-doc">>}, {<<"value">>, 42}]}),
{ok, _} = fabric:update_doc(DbName, Doc, [?ADMIN_CTX]).

db_url(DbName) ->
couch_replicator_test_helper:cluster_db_url(DbName).

compare(Source, Target) ->
couch_replicator_test_helper:cluster_compare_dbs(Source, Target).

replicate(SourceUrl, TargetUrl) ->
couch_replicator_test_helper:replicate(SourceUrl, TargetUrl).
Loading