# Copyright 2014 - present MongoDB, Inc. # # 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. use strict; use warnings; package MongoDB::_URI; use version; our $VERSION = 'v2.2.2'; use Moo; use MongoDB::Error; use Encode (); use Time::HiRes qw(time); use MongoDB::_Constants qw( RESCAN_SRV_FREQUENCY_SEC ); use Types::Standard qw( Any ArrayRef HashRef Str Int Num ); use namespace::clean -except => 'meta'; use Scalar::Util qw/looks_like_number/; my $uri_re = qr{ mongodb(?:\+srv|):// (?: ([^:]*) (?: : ([^@]*) )? @ )? # [username(:password)?@] ([^/?]*) # host1[:port1][,host2[:port2],...[,hostN[:portN]]] (?: / ([^?]*) # /[database] (?: [?] (.*) )? # [?options] )? }x; my %options_with_list_type = map { lc($_) => 1 } qw( readPreferenceTags ); has uri => ( is => 'ro', isa => Str, required => 1, ); has username => ( is => 'ro', isa => Any, writer => '_set_username', ); has password => ( is => 'ro', isa => Any, writer => '_set_password', ); has db_name => ( is => 'ro', isa => Str, writer => '_set_db_name', default => '', ); has options => ( is => 'ro', isa => HashRef, writer => '_set_options', default => sub { {} }, ); has hostids => ( is => 'ro', isa => ArrayRef, writer => '_set_hostids', default => sub { [] }, ); has valid_options => ( is => 'lazy', isa => HashRef, ); has expires => ( is => 'ro', isa => Num, writer => '_set_expires', ); sub _build_valid_options { my $self = shift; return { map { lc($_) => 1 } qw( appName authMechanism authMechanismProperties authSource compressors connect connectTimeoutMS heartbeatFrequencyMS journal localThresholdMS maxStalenessSeconds maxTimeMS readConcernLevel readPreference readPreferenceTags replicaSet serverSelectionTimeoutMS serverSelectionTryOnce socketCheckIntervalMS socketTimeoutMS tlsCAFile tlsCertificateKeyFile tlsCertificateKeyFilePassword w wTimeoutMS zlibCompressionLevel ), keys %{ $self->_valid_str_to_bool_options } }; } has valid_srv_options => ( is => 'lazy', isa => HashRef, ); sub _build_valid_srv_options { return { map { lc($_) => 1 } qw( authSource replicaSet ) }; } has _valid_str_to_bool_options => ( is => 'lazy', isa => HashRef, builder => '_build_valid_str_to_bool_options', ); sub _build_valid_str_to_bool_options { return { map { lc($_) => 1 } qw( journal retryReads retryWrites serverselectiontryonce ssl tls tlsAllowInvalidCertificates tlsAllowInvalidHostnames tlsInsecure ) }; } has _extra_options_validation => ( is => 'lazy', isa => HashRef, builder => '_build_extra_options_validation', ); sub _build_extra_options_validation { return { _PositiveInt => sub { my $v = shift; Int->($v) && $v >= 0; }, wtimeoutms => '_PositiveInt', connecttimeoutms => '_PositiveInt', localthresholdms => '_PositiveInt', serverselectiontimeoutms => '_PositiveInt', sockettimeoutms => '_PositiveInt', w => sub { my $v = shift; if (looks_like_number($v)) { return $v >= 0; } return 1; # or any string }, zlibcompressionlevel => sub { my $v = shift; Int->($v) && $v >= -1 && $v <= 9; }, heartbeatfrequencyms => sub { my $v = shift; Int->($v) && $v >= 500; }, maxstalenessseconds => sub { my $v = shift; Int->($v) && ( $v == 1 || $v == -1 || $v >= 90 ); }, }; } sub _unescape_all { my $str = shift; return '' unless defined $str; if ( $str =~ s/%([0-9a-f]{2})/chr(hex($1))/ieg ) { $str = Encode::decode('UTF-8', $str); } return $str; } sub _parse_doc { my ($name, $string) = @_; my $set = {}; for my $tag ( split /,/, $string ) { if ( $tag =~ /\S/ ) { my @kv = map { my $s = $_; $s =~ s{^\s*}{}; $s =~ s{\s*$}{}; $s } split /:/, $tag, 2; if ( @kv != 2 ) { warn "in option '$name', '$tag' is not a key:value pair\n"; return } $set->{$kv[0]} = $kv[1]; } } return $set; } sub _parse_options { my ( $self, $valid, $result, $txt_record ) = @_; my %parsed; for my $opt ( split '&', $result->{options} ) { my @kv = split '=', $opt, -1; MongoDB::UsageError->throw("expected key value pair") unless @kv == 2; my ( $k, $v ) = map { _unescape_all($_) } @kv; # connection string spec calls for case normalization ( my $lc_k = $k ) =~ tr[A-Z][a-z]; if ( !$valid->{$lc_k} ) { if ( $txt_record ) { MongoDB::Error->throw("Unsupported option '$k' in URI $self for TXT record $txt_record\n"); } else { warn "Unsupported option '$k' in URI $self\n"; } next; } if ( exists $parsed{$lc_k} && !exists $options_with_list_type{$lc_k} ) { warn "Multiple options were found for the same value '$lc_k'. The first occurrence will be used\n"; next; } if ( $lc_k eq 'authmechanismproperties' ) { my $temp = _parse_doc( $k, $v ); if ( defined $temp ) { $parsed{$lc_k} = $temp; if ( exists $parsed{$lc_k}{CANONICALIZE_HOST_NAME} ) { my $temp = __str_to_bool( 'CANONICALIZE_HOST_NAME', $parsed{$lc_k}{CANONICALIZE_HOST_NAME} ); if ( defined $temp ) { $parsed{$lc_k}{CANONICALIZE_HOST_NAME} = $temp; } } } } elsif ( $lc_k eq 'compressors' ) { my @compressors = split /,/, $v, -1; my $valid_compressors = { snappy => 1, zlib => 1, zstd => 1 }; for my $compressor ( @compressors ) { warn("Unsupported compressor $compressor\n") unless $valid_compressors->{$compressor}; } $parsed{$lc_k} = [ @compressors ]; } elsif ( $lc_k eq 'authsource' ) { $parsed{$lc_k} = $v; } elsif ( $lc_k eq 'readpreferencetags' ) { $parsed{$lc_k} ||= []; my $temp = _parse_doc( $k, $v ); if ( defined $temp ) { push @{$parsed{$lc_k}}, $temp; } } elsif ( $self->_valid_str_to_bool_options->{ $lc_k } ) { my $temp = __str_to_bool( $k, $v ); if ( defined $temp ) { $parsed{$lc_k} = $temp } } elsif ( my $opt_validation = $self->_extra_options_validation->{ $lc_k } ) { unless (ref $opt_validation eq 'CODE') { $opt_validation = $self->_extra_options_validation->{ $opt_validation }; } my $valid = eval { $opt_validation->($v) }; my $err = "$@"; if ( ! $valid ) { warn("Unsupported URI value '$k' = '$v': $err"); } else { $parsed{$lc_k} = $v; } } else { $parsed{$lc_k} = $v; } } if ( exists $parsed{tlsinsecure} && ( exists $parsed{tlsallowinvalidcertificates} || exists $parsed{tlsallowinvalidhostnames} ) ) { MongoDB::Error->throw('tlsInsecure conflicts with other options'); } # If both exist, they must be identical. if ( exists( $parsed{tls} ) && exists( $parsed{ssl} ) && $parsed{tls} != $parsed{ssl} ) { MongoDB::Error->throw('tls and ssl must have the same value'); } # If either exists, set them both. if ( exists $parsed{tls} ) { $parsed{ssl} = $parsed{tls}; } elsif ( exists $parsed{ssl} ) { $parsed{tls} = $parsed{ssl}; } return \%parsed; } sub _fetch_dns_seedlist { my ( $self, $host_name, $phase ) = @_; my @split_name = split( '\.', $host_name ); MongoDB::Error->throw("URI '$self' must contain domain name and hostname") unless scalar( @split_name ) > 2; require Net::DNS; my $res = Net::DNS::Resolver->new; my $srv_data = $res->query( sprintf( '_mongodb._tcp.%s', $host_name ), 'SRV' ); my @hosts; my $options = {}; my $domain_name = join( '.', @split_name[1..$#split_name] ); my $minimum_ttl; if ( $srv_data ) { SRV_RECORD: foreach my $rr ( $srv_data->answer ) { next unless $rr->type eq 'SRV'; my $target = $rr->target; # search for dot before domain name for a valid hostname - can have sub-subdomain unless ( $target =~ /\.\Q$domain_name\E$/ ) { my $err_msg = "URI '$self' SRV record returns FQDN '$target'" . " which does not match domain name '${$domain_name}'"; if ($phase && $phase eq 'init') { MongoDB::Error->throw($err_msg); } else { warn $err_msg; } next SRV_RECORD; } push @hosts, { target => $target, port => $rr->port, }; $minimum_ttl = $rr->ttl if not defined $minimum_ttl or $rr->ttl < $minimum_ttl; } my $txt_data = $res->query( $host_name, 'TXT' ); if ( defined $txt_data ) { my @txt_answers; foreach my $rr ( $txt_data->answer ) { next unless $rr->type eq 'TXT'; push @txt_answers, $rr; } if ( scalar( @txt_answers ) > 1 ) { MongoDB::Error->throw("URI '$self' returned more than one TXT result"); } elsif ( scalar( @txt_answers ) == 1 ) { my $txt_opt_string = join ( '', $txt_answers[0]->txtdata ); $options = $self->_parse_options( $self->valid_srv_options, { options => $txt_opt_string }, $txt_opt_string ); } } } else { MongoDB::Error->throw("URI '$self' does not return any SRV results"); } unless (@hosts) { my $err_msg = "URI '$self' does not return any valid SRV results"; if ($phase && $phase eq 'init') { MongoDB::Error->throw($err_msg); } else { warn $err_msg; } } $minimum_ttl = RESCAN_SRV_FREQUENCY_SEC if $minimum_ttl < RESCAN_SRV_FREQUENCY_SEC && $phase && $phase ne 'init'; return ( \@hosts, $options, time + $minimum_ttl ); } sub _parse_srv_uri { my ( $self, $uri, $phase ) = @_; my %result; $uri =~ m{^$uri_re$}; ( $result{username}, $result{password}, $result{hostids}, $result{db_name}, $result{options} ) = ( $1, $2, $3, $4, $5 ); $result{hostids} = lc _unescape_all( $result{hostids} ); if ( !defined $result{hostids} || !length $result{hostids} ) { MongoDB::Error->throw("URI '$self' cannot be empty if using an SRV connection string"); } if ( $result{hostids} =~ /,/ ) { MongoDB::Error->throw("URI '$self' cannot contain a comma or multiple host names if using an SRV connection string"); } if ( $result{hostids} =~ /:\d+$/ ) { MongoDB::Error->throw("URI '$self' cannot contain port number if using an SRV connection string"); } if ( defined $result{options} ) { $result{options} = $self->_parse_options( $self->valid_options, \%result ); } my ( $hosts, $options, $expires ) = $self->_fetch_dns_seedlist( $result{hostids}, $phase ); # Default to SSL on unless specified in conn string options $options = { ssl => 'true', %$options, %{ $result{options} || {} }, }; # Reset str to bool options to string value, as _parse_options changes it to 0/1 if it exists during parsing # means we get the correct value when re-building the uri below. for my $stb_key ( keys %{ $self->_valid_str_to_bool_options } ) { # use exists just in case next unless exists $options->{ $stb_key }; $options->{ $stb_key } = ($options->{ $stb_key } || $options->{ $stb_key } eq 'true') ? 'true' : 'false'; } my $auth = ""; if ( defined $result{username} || defined $result{password} ) { $auth = join(":", map { $_ // "" } $result{username}, $result{password}); $auth .= "@"; } my $new_uri = sprintf( 'mongodb://%s%s/%s%s%s', $auth, join( ',', map { sprintf( '%s:%s', $_->{target}, $_->{port} ) } @$hosts ), ($result{db_name} // ""), scalar( keys %$options ) ? '?' : '', join( '&', map { sprintf( '%s=%s', $_, __uri_escape( $options->{$_} ) ) } keys %$options ), ); return( $new_uri, $expires ); } sub BUILD { my ($self) = @_; $self->_initialize_from_uri; } # Options: # - fallback_ttl_sec: Fallback TTL in seconds in case of an error sub check_for_changes { my ($self, $options) = @_; if (defined $self->{expires} && $self->{expires} <= time) { my @current = sort @{ $self->{hostids} }; local $@; my $ok = eval { $self->_update_from_uri; 1; }; if (!$ok) { warn "Error while fetching SRV records: $@"; $self->{expires} = $options->{fallback_ttl_sec}; }; return 0 unless $ok; my @new = sort @{ $self->{hostids} }; return 1 unless @current == @new; for my $index (0 .. $#current) { return 1 unless $new[$index] eq $current[$index]; } return 0; } return 0; } sub _prepare_dns_hosts { my ($self, $hostids) = @_; if ( !defined $hostids || !length $hostids ) { MongoDB::Error->throw("URI '$self' could not be parsed (missing host list)"); } $hostids = [ map { lc _unescape_all($_) } split ',', $hostids ]; for my $hostid (@$hostids) { MongoDB::Error->throw( "URI '$self' could not be parsed (Unix domain sockets are not supported)") if $hostid =~ /\// && $hostid =~ /\.sock/; MongoDB::Error->throw( "URI '$self' could not be parsed (IP literals are not supported)") if substr( $hostid, 0, 1 ) eq '['; my ( $host, $port ) = split ":", $hostid, 2; MongoDB::Error->throw("host list '@{ $hostids }' contains empty host") unless length $host; if ( defined $port ) { MongoDB::Error->throw("URI '$self' could not be parsed (invalid port '$port')") unless $port =~ /^\d+$/; MongoDB::Error->throw( "URI '$self' could not be parsed (invalid port '$port' (must be in range [1,65535])") unless $port >= 1 && $port <= 65535; } } $hostids = [ map { /:/ ? $_ : $_.":27017" } @$hostids ]; return $hostids; } sub _update_from_uri { my ($self) = @_; my $uri = $self->uri; my %result; ($uri, my $expires) = $self->_parse_srv_uri( $uri ); $self->{expires} = $expires; if ( $uri !~ m{^$uri_re$} ) { MongoDB::Error->throw("URI '$self' could not be parsed"); } my $hostids = $3; $hostids = $self->_prepare_dns_hosts($hostids); $self->{hostids} = $hostids; } sub _initialize_from_uri { my ($self) = @_; my $uri = $self->uri; my %result; if ( $uri =~ m{^mongodb\+srv://} ) { ($uri, my $expires) = $self->_parse_srv_uri( $uri, 'init' ); $result{expires} = $expires; } # we throw Error instead of UsageError for errors, to avoid stacktrace revealing credentials if ( $uri !~ m{^$uri_re$} ) { MongoDB::Error->throw("URI '$self' could not be parsed"); } ( $result{username}, $result{password}, $result{hostids}, $result{db_name}, $result{options} ) = ( $1, $2, $3, $4, $5 ); if ( defined $result{username} ) { MongoDB::Error->throw( "URI '$self' could not be parsed (username must be URL encoded)" ) if __userinfo_invalid_chars($result{username}); $result{username} = _unescape_all( $result{username} ); } if ( defined $result{password} ) { MongoDB::Error->throw( "URI '$self' could not be parsed (password must be URL encoded)" ) if __userinfo_invalid_chars($result{password}); $result{password} = _unescape_all( $result{password} ); } $result{hostids} = $self->_prepare_dns_hosts($result{hostids}); if ( defined $result{db_name} ) { MongoDB::Error->throw( "URI '$self' could not be parsed (database name must be URL encoded, found unescaped '/'" ) if $result{db_name} =~ /\//; $result{db_name} = _unescape_all( $result{db_name} ); } if ( defined $result{options} ) { $result{options} = $self->_parse_options( $self->valid_options, \%result ); } for my $attr (qw/username password db_name options hostids expires/) { my $setter = "_set_$attr"; $self->$setter( $result{$attr} ) if defined $result{$attr}; } return; } sub __str_to_bool { my ($k, $str) = @_; MongoDB::UsageError->throw("cannot convert undef to bool for key '$k'") unless defined $str; my $ret = $str eq "true" ? 1 : $str eq "false" ? 0 : undef; warn("expected boolean string 'true' or 'false' for key '$k' but instead received '$str'. Ignoring '$k'.\n") unless defined $ret; return $ret; } # uri_escape borrowed from HTTP::Tiny 0.070 my %escapes = map { chr($_) => sprintf("%%%02X", $_) } 0..255; $escapes{' '}="+"; my $unsafe_char = qr/[^A-Za-z0-9\-\._~]/; sub __uri_escape { my ($str) = @_; if ( $] ge '5.008' ) { utf8::encode($str); } else { $str = pack("U*", unpack("C*", $str)) # UTF-8 encode a byte string if ( length $str == do { use bytes; length $str } ); $str = pack("C*", unpack("C*", $str)); # clear UTF-8 flag } $str =~ s/($unsafe_char)/$escapes{$1}/ge; return $str; } # Rules for valid userinfo from RFC 3986 Section 3.2.1. my $unreserved = q[a-z0-9._~-]; # use this class last so regex ends in '-' my $subdelimit = q[!$&'()*+,;=]; my $allowed = "%$subdelimit$unreserved"; my $not_allowed_re = qr/[^$allowed]/i; my $not_pct_enc_re = qr/%(?![0-9a-f]{2})/i; sub __userinfo_invalid_chars { my ($str) = @_; return $str =~ $not_pct_enc_re || $str =~ $not_allowed_re; } # redact user credentials when stringifying use overload '""' => sub { (my $s = $_[0]->uri) =~ s{^([^:]+)://[^/]+\@}{$1://[**REDACTED**]\@}; return $s }, 'fallback' => 1; 1; # vim: ts=4 sts=4 sw=4 et: