File Coverage

blib/lib/Perlbal/Plugin/SessionAffinity.pm
Criterion Covered Total %
statement 30 190 15.7
branch 0 60 0.0
condition 0 30 0.0
subroutine 9 25 36.0
pod 8 10 80.0
total 47 315 14.9


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