File Coverage

blib/lib/Perlbal/Plugin/SessionAffinity.pm
Criterion Covered Total %
statement 30 193 15.5
branch 0 62 0.0
condition 0 33 0.0
subroutine 9 25 36.0
pod 8 10 80.0
total 47 323 14.5


line stmt bran cond sub pod time code
1             package Perlbal::Plugin::SessionAffinity;
2             $Perlbal::Plugin::SessionAffinity::VERSION = '0.110';
3 1     1   65697 use strict;
  1         13  
  1         30  
4 1     1   5 use warnings;
  1         2  
  1         25  
5             # ABSTRACT: Sane session affinity (sticky sessions) for Perlbal
6              
7 1     1   589 use Perlbal;
  1         217028  
  1         41  
8 1     1   9 use Hash::Util;
  1         2  
  1         11  
9 1     1   550 use CGI::Cookie;
  1         7203  
  1         38  
10 1     1   546 use MIME::Base64;
  1         704  
  1         70  
11 1     1   8 use Digest::MD5 'md5';
  1         2  
  1         50  
12 1     1   509 use Digest::SHA 'sha1_hex';
  1         2601  
  1         2496  
13              
14             my $default_cookie_hdr = 'X-SERVERID';
15             my $cookie_hdr_sub = sub { encode_base64( md5( $_[0] ) ) };
16             my $salt = join q{}, map { $_ = rand 999; s/\.//; $_ } 1 .. 10;
17             my $use_salt = 0;
18             my $use_domain = 0;
19             my $use_dynamic_cookie = 0;
20              
21             sub get_domain_from_req {
22 0     0 0 0 my $req = shift;
23             my $domain = ref $req eq 'Perlbal::XS::HTTPHeaders' ?
24             $req->getHeader('host') : # XS version
25 0 0       0 $req->{'headers'}{'host'}; # PP version
26              
27 0         0 return $domain;
28             }
29              
30             # get the ip and port of the requested backend from the cookie
31             sub get_ip_port {
32 0     0 1 0 my ( $svc, $req ) = @_;
33              
34 0         0 my $domain = get_domain_from_req($req);
35 0 0       0 my $cookie_hdr = $use_dynamic_cookie ?
36             $cookie_hdr_sub->($domain) :
37             $default_cookie_hdr;
38              
39 0         0 my $cookie = $req->header('Cookie');
40 0         0 my %cookies = ();
41              
42 0 0       0 if ( defined $cookie ) {
43 0         0 %cookies = CGI::Cookie->parse($cookie);
44              
45 0 0       0 if ( defined $cookies{$cookie_hdr} ) {
46 0   0     0 my $id = $cookies{$cookie_hdr}->value || '';
47 0         0 my $backend = find_backend_by_id( $svc, $id );
48              
49 0 0       0 ref $backend and return join ':', @{$backend};
  0         0  
50             }
51             }
52              
53 0         0 return;
54             }
55              
56             # create a domain ID
57             sub create_domain_id {
58 0   0 0 1 0 my $domain = shift || '';
59 0         0 my @nodes = @_;
60              
61             # the ID is determined by the specific server
62             # that has the matching index for the domain
63 0         0 my $index = domain_index( $domain, scalar @nodes );
64 0         0 my $node = join ':', @{ $nodes[$index] };
  0         0  
65 0 0       0 return sha1_hex( $use_salt ? $salt . $node : $node );
66             }
67              
68             # create an id from ip and optional port
69             sub create_id {
70 0     0 1 0 my $ip = shift;
71 0   0     0 my $port = shift || '';
72 0 0       0 my $str = $use_salt ? $salt . "$ip:$port" : "$ip:$port";
73 0         0 return sha1_hex($str);
74             }
75              
76             # a nifty little trick:
77             # we create a numeric value of the domain name
78             # then we use that as a seed for the random function
79             # then create a random number which is predictable
80             # that is the index of the domain
81             sub domain_index {
82 200     200 1 229419 my $domain = shift;
83 200         393 my $max = shift;
84 200         342 my $seed = 0;
85              
86 200         703 foreach my $char ( split //, $domain ) {
87 1500         2321 $seed += ord $char;
88             }
89              
90 200         602 return ( $seed % $max);
91             }
92              
93             # using an sha1 checksum id, find the matching backend
94             sub find_backend_by_id {
95 0     0 1   my ( $svc, $id ) = @_;
96              
97 0           foreach my $backend ( @{ $svc->{'pool'}{'nodes'} } ) {
  0            
98 0           my $backendid = create_id( @{$backend} );
  0            
99              
100 0 0         if ( $backendid eq $id ) {
101 0           return $backend;
102             }
103             }
104              
105 0           return;
106             }
107              
108             # TODO: refactor this function
109             sub find_backend_by_domain_id {
110 0     0 1   my ( $svc, $id ) = @_;
111              
112 0           foreach my $backend ( @{ $svc->{'pool'}{'nodes'} } ) {
  0            
113 0           my $backendid = create_id( @{$backend} );
  0            
114              
115 0 0         if ( $backendid eq $id ) {
116 0           return $backend;
117             }
118             }
119              
120 0           return;
121             }
122              
123             sub load {
124             # the name of header in the cookie that stores the backend ID
125             Perlbal::register_global_hook(
126             'manage_command.affinity_cookie_header', sub {
127 0     0     my $mc = shift->parse(qr/^\s*affinity_cookie_header\s+=\s+(.+)\s*$/,
128             "usage: AFFINITY_COOKIE_HEADER = ");
129              
130 0           ($default_cookie_hdr) = $mc->args;
131              
132 0           return $mc->ok;
133             },
134 0     0 0   );
135              
136             Perlbal::register_global_hook(
137             'manage_command.affinity_salt', sub {
138 0     0     my $mc = shift->parse(qr/^\s*affinity_salt\s+=\s+(.+)\s*$/,
139             "usage: AFFINITY_SALT = ");
140              
141 0           ($salt) = $mc->args;
142              
143 0           return $mc->ok;
144             },
145 0           );
146              
147             Perlbal::register_global_hook(
148             'manage_command.affinity_use_salt', sub {
149 0     0     my $mc = shift->parse(qr/^\s*affinity_use_salt\s+=\s+(.+)\s*$/,
150             "usage: AFFINITY_USE_SALT = ");
151              
152 0           my ($res) = $mc->args;
153 0 0 0       if ( $res eq 'yes' || $res == 1 ) {
    0 0        
154 0           $use_salt = 1;
155             } elsif ( $res eq 'no' || $res == 0 ) {
156 0           $use_salt = 0;
157             } else {
158 0           die qq"affinity_use_salt must be boolean (yes/no/1/0)";
159             }
160              
161 0           return $mc->ok;
162             },
163 0           );
164              
165             Perlbal::register_global_hook(
166             'manage_command.affinity_use_domain', sub {
167 0     0     my $mc = shift->parse(qr/^\s*affinity_use_domain\s+=\s+(.+)\s*$/,
168             "usage: AFFINITY_USE_DOMAIN = ");
169              
170 0           my ($res) = $mc->args;
171 0 0 0       if ( $res eq 'yes' || $res == 1 ) {
    0 0        
172 0           $use_domain = 1;
173             } elsif ( $res eq 'no' || $res == 0 ) {
174 0           $use_domain = 0;
175             } else {
176 0           die qq"affinity_use_domain must be boolean (yes/no/1/0)";
177             }
178              
179 0           return $mc->ok;
180             },
181 0           );
182              
183             Perlbal::register_global_hook(
184             'manage_command.affinity_use_dynamic_cookie', sub {
185 0     0     my $mc = shift->parse(qr/^\s*affinity_use_dynamic_cookie\s+=\s+(.+)\s*$/,
186             "usage: AFFINITY_USE_DYNAMIC_COOKIE = ");
187              
188 0           my ($res) = $mc->args;
189 0 0 0       if ( $res eq 'yes' || $res == 1 ) {
    0 0        
190 0           $use_dynamic_cookie = 1;
191             } elsif ( $res eq 'no' || $res == 0 ) {
192 0           $use_dynamic_cookie = 0;
193             } else {
194 0           die qq"affinity_use_dynamic_cookie must be boolean (yes/no/1/0)";
195             }
196              
197 0           return $mc->ok;
198             },
199 0           );
200              
201 0           return 1;
202             }
203              
204             sub register {
205 0     0 1   my ( $class, $gsvc ) = @_;
206              
207             my $check_cookie = sub {
208 0     0     my $client = shift;
209 0 0         my $req = $client->{'req_headers'} or return 0;
210 0           my $svc = $client->{'service'};
211 0           my $pool = $svc->{'pool'};
212              
213             # make sure all nodes in this service have their own pool
214 0           foreach my $node ( @{ $pool->{'nodes'} } ) {
  0            
215 0           my ( $ip, $port ) = @{$node};
  0            
216              
217             # pool
218 0           my $poolid = create_id( $ip, $port );
219 0 0         exists $Perlbal::pool{$poolid} and next;
220              
221 0           my $nodepool = Perlbal::Pool->new($poolid);
222 0           $nodepool->add( $ip, $port );
223 0           $Perlbal::pool{$poolid} = $nodepool;
224              
225             # service
226 0           my $serviceid = "${poolid}_service";
227 0 0         exists $Perlbal::service{$serviceid} and next;
228              
229 0           my $nodeservice = Perlbal->create_service($serviceid);
230 0           my $svc_role = $svc->{'role'};
231              
232             # role sets up constraints for the rest
233             # so it goes first
234 0           $nodeservice->set( role => $svc_role );
235              
236 0           foreach my $tunable_name ( keys %{$Perlbal::Service::tunables} ) {
  0            
237             # skip role because we had already set it
238 0 0         $tunable_name eq 'role' and next;
239              
240             # persist_client_timeout is DEPRECATED
241             # but not marked anywhere as deprecated. :(
242             # (well, nowhere we can actually predictably inspect)
243 0 0         $tunable_name eq 'persist_client_timeout' and next;
244              
245             # we skip the pool because we're gonna set it to a specific one
246 0 0         $tunable_name eq 'pool' and next;
247              
248             # make sure svc has value for this tunable
249             # we use 'exists' first because if it's an unknown key
250             # in a lock hash, it will crash with 'disallowed key' access
251 0 0 0       exists $svc->{$tunable_name} && defined $svc->{$tunable_name}
252             or next;
253              
254 0           my $tunable = $Perlbal::Service::tunables->{$tunable_name};
255 0           my $role = $tunable->{'check_role'};
256              
257 0 0 0       if ( $role eq '*' || $role eq $svc_role ) {
258 0           my $value = ref $svc->{$tunable_name};
259              
260             # It might be an arrayref
261             $value eq 'ARRAY'
262 0 0         and $value = join '', @{$value};
  0            
263              
264 0           $nodeservice->set( $tunable_name, $value );
265             }
266             }
267              
268             # restricted hashes are stupid
269             # so we have to use the API to add them
270 0           foreach my $hook_name ( keys %{ $svc->{'hooks'} } ) {
  0            
271 0           foreach my $set ( @{ $svc->{'hooks'}{$hook_name} } ) {
  0            
272 0           my ( $plugin, $sub ) = @{$set};
  0            
273 0           $nodeservice->register_hook( $plugin, $hook_name, $sub );
274             }
275             }
276              
277             # add all the extra config and extra headers
278 0           $nodeservice->{'extra_config'} = $svc->{'extra_config'};
279 0           $nodeservice->{'extra_headers'} = $svc->{'extra_headers'};
280              
281 0           $nodeservice->set( pool => $poolid );
282              
283 0           $Perlbal::service{$serviceid} = $nodeservice;
284             }
285              
286 0           my $ip_port = get_ip_port( $svc, $req );
287              
288 0 0         if ( ! $ip_port ) {
289 0 0         $use_domain or return 0;
290              
291             # we're going to override whatever Perlbal found
292             # because we only care about the domain
293 0           my $domain = get_domain_from_req($req);
294              
295             my @ordered_nodes = sort {
296 0           ( join ':', @{$a} ) cmp ( join ':', @{$b} )
  0            
  0            
297 0           } @{ $svc->{'pool'}{'nodes'} };
  0            
298              
299 0           my $id = create_domain_id( $domain, @ordered_nodes );
300 0           my $backend = find_backend_by_domain_id( $svc, $id );
301 0           $ip_port = join ':', @{$backend};
  0            
302             }
303              
304 0           my ( $ip, $port ) = split /:/, $ip_port;
305 0           my $req_pool_id = create_id( $ip, $port );
306 0           my $req_svc = $Perlbal::service{"${req_pool_id}_service"};
307 0           $client->{'service'} = $req_svc;
308              
309 0           return 0;
310 0           };
311              
312             my $set_cookie = sub {
313 0     0     my $backend = shift; # Perlbal::BackendHTTP
314              
315 0 0         defined $backend or return 0;
316              
317 0           my $res = $backend->{'res_headers'};
318 0           my $req = $backend->{'req_headers'};
319 0           my $svc = $backend->{'service'};
320 0           my $backend_id = create_id( split /:/, $backend->{'ipport'} );
321 0           my $domain = get_domain_from_req($req);
322 0 0         my $cookie_hdr = $use_dynamic_cookie ?
323             $cookie_hdr_sub->($domain) :
324             $default_cookie_hdr;
325              
326 0           my %cookies = ();
327 0 0         if ( my $cookie = $req->header('Cookie') ) {
328 0           %cookies = CGI::Cookie->parse($cookie);
329             }
330              
331 0 0 0       if ( ! defined $cookies{$cookie_hdr} ||
332             $cookies{$cookie_hdr}->value ne $backend_id ) {
333              
334 0           my $backend_cookie = CGI::Cookie->new(
335             -name => $cookie_hdr,
336             -value => $backend_id,
337             );
338              
339 0 0         if ( defined $res->header('set-cookie') ) {
340 0           my $value = $res->header('set-cookie') .
341             "\r\nSet-Cookie: " .
342             $backend_cookie->as_string;
343              
344 0           $res->header( 'Set-Cookie' => $value );
345             } else {
346 0           $res->header( 'Set-Cookie' => $backend_cookie->as_string );
347             }
348             }
349              
350 0           return 0;
351 0           };
352              
353 0           $gsvc->register_hook(
354             'SessionAffinity', 'start_proxy_request', $check_cookie,
355             );
356              
357 0           $gsvc->register_hook(
358             'SessionAffinity', 'backend_response_received', $set_cookie,
359             );
360              
361 0           return 1;
362             }
363              
364             sub unregister {
365 0     0 1   my ( $class, $svc ) = @_;
366              
367             # TODO: are we using setters?
368 0           $svc->unregister_hooks('SessionAffinity');
369 0           $svc->unregister_setters('SessionAffinity');
370              
371 0           return 1;
372             }
373              
374             1;
375              
376             __END__