| blib/lib/PAGI/Middleware/Maintenance.pm | |||
|---|---|---|---|
| Criterion | Covered | Total | % |
| statement | 53 | 74 | 71.6 |
| branch | 12 | 34 | 35.2 |
| condition | 9 | 27 | 33.3 |
| subroutine | 10 | 13 | 76.9 |
| pod | 1 | 1 | 100.0 |
| total | 85 | 149 | 57.0 |
| line | stmt | bran | cond | sub | pod | time | code |
|---|---|---|---|---|---|---|---|
| 1 | package PAGI::Middleware::Maintenance; | ||||||
| 2 | |||||||
| 3 | 1 | 1 | 584 | use strict; | |||
| 1 | 2 | ||||||
| 1 | 50 | ||||||
| 4 | 1 | 1 | 5 | use warnings; | |||
| 1 | 2 | ||||||
| 1 | 61 | ||||||
| 5 | 1 | 1 | 5 | use parent 'PAGI::Middleware'; | |||
| 1 | 1 | ||||||
| 1 | 7 | ||||||
| 6 | 1 | 1 | 77 | use Future::AsyncAwait; | |||
| 1 | 2 | ||||||
| 1 | 8 | ||||||
| 7 | |||||||
| 8 | =head1 NAME | ||||||
| 9 | |||||||
| 10 | PAGI::Middleware::Maintenance - Serve maintenance page when enabled | ||||||
| 11 | |||||||
| 12 | =head1 SYNOPSIS | ||||||
| 13 | |||||||
| 14 | use PAGI::Middleware::Builder; | ||||||
| 15 | |||||||
| 16 | my $app = builder { | ||||||
| 17 | enable 'Maintenance', | ||||||
| 18 | enabled => $ENV{MAINTENANCE_MODE}, | ||||||
| 19 | bypass_ips => ['10.0.0.0/8'], | ||||||
| 20 | retry_after => 3600; | ||||||
| 21 | $my_app; | ||||||
| 22 | }; | ||||||
| 23 | |||||||
| 24 | =head1 DESCRIPTION | ||||||
| 25 | |||||||
| 26 | PAGI::Middleware::Maintenance serves a 503 Service Unavailable page | ||||||
| 27 | when maintenance mode is enabled. Supports IP-based bypass for admins. | ||||||
| 28 | |||||||
| 29 | =head1 CONFIGURATION | ||||||
| 30 | |||||||
| 31 | =over 4 | ||||||
| 32 | |||||||
| 33 | =item * enabled (default: 0) | ||||||
| 34 | |||||||
| 35 | Enable maintenance mode. Can be a coderef for dynamic checking. | ||||||
| 36 | |||||||
| 37 | =item * bypass_ips (default: []) | ||||||
| 38 | |||||||
| 39 | Arrayref of IPs or CIDR ranges that bypass maintenance mode. | ||||||
| 40 | |||||||
| 41 | =item * bypass_paths (default: []) | ||||||
| 42 | |||||||
| 43 | Arrayref of paths that bypass maintenance mode (e.g., health checks). | ||||||
| 44 | |||||||
| 45 | =item * retry_after (optional) | ||||||
| 46 | |||||||
| 47 | Seconds until maintenance expected to end. Sets Retry-After header. | ||||||
| 48 | |||||||
| 49 | =item * content_type (default: 'text/html') | ||||||
| 50 | |||||||
| 51 | Content-Type of the maintenance page. | ||||||
| 52 | |||||||
| 53 | =item * body (default: built-in HTML page) | ||||||
| 54 | |||||||
| 55 | Custom maintenance page body. | ||||||
| 56 | |||||||
| 57 | =back | ||||||
| 58 | |||||||
| 59 | =cut | ||||||
| 60 | |||||||
| 61 | sub _init { | ||||||
| 62 | 4 | 4 | 7 | my ($self, $config) = @_; | |||
| 63 | |||||||
| 64 | 4 | 50 | 20 | $self->{enabled} = $config->{enabled} // 0; | |||
| 65 | 4 | 100 | 14 | $self->{bypass_ips} = $config->{bypass_ips} // []; | |||
| 66 | 4 | 100 | 12 | $self->{bypass_paths} = $config->{bypass_paths} // []; | |||
| 67 | 4 | 6 | $self->{retry_after} = $config->{retry_after}; | ||||
| 68 | 4 | 50 | 13 | $self->{content_type} = $config->{content_type} // 'text/html'; | |||
| 69 | 4 | 33 | 12 | $self->{body} = $config->{body} // $self->_default_body(); | |||
| 70 | } | ||||||
| 71 | |||||||
| 72 | sub wrap { | ||||||
| 73 | 4 | 4 | 1 | 26 | my ($self, $app) = @_; | ||
| 74 | |||||||
| 75 | 4 | 4 | 82 | return async sub { | |||
| 76 | 4 | 8 | my ($scope, $receive, $send) = @_; | ||||
| 77 | 4 | 50 | 11 | if ($scope->{type} ne 'http') { | |||
| 78 | 0 | 0 | await $app->($scope, $receive, $send); | ||||
| 79 | 0 | 0 | return; | ||||
| 80 | } | ||||||
| 81 | |||||||
| 82 | # Check if maintenance is enabled | ||||||
| 83 | my $enabled = ref $self->{enabled} eq 'CODE' | ||||||
| 84 | ? $self->{enabled}->() | ||||||
| 85 | 4 | 50 | 24 | : $self->{enabled}; | |||
| 86 | |||||||
| 87 | 4 | 100 | 9 | unless ($enabled) { | |||
| 88 | 1 | 2 | await $app->($scope, $receive, $send); | ||||
| 89 | 1 | 88 | return; | ||||
| 90 | } | ||||||
| 91 | |||||||
| 92 | # Check bypass conditions | ||||||
| 93 | 3 | 100 | 8 | if ($self->_should_bypass($scope)) { | |||
| 94 | 2 | 4 | await $app->($scope, $receive, $send); | ||||
| 95 | 2 | 207 | return; | ||||
| 96 | } | ||||||
| 97 | |||||||
| 98 | # Serve maintenance page | ||||||
| 99 | 1 | 3 | await $self->_send_maintenance($send); | ||||
| 100 | 4 | 15 | }; | ||||
| 101 | } | ||||||
| 102 | |||||||
| 103 | sub _should_bypass { | ||||||
| 104 | 3 | 3 | 4 | my ($self, $scope) = @_; | |||
| 105 | |||||||
| 106 | # Check bypass paths | ||||||
| 107 | 3 | 50 | 7 | my $path = $scope->{path} // ''; | |||
| 108 | 3 | 4 | for my $bypass_path (@{$self->{bypass_paths}}) { | ||||
| 3 | 7 | ||||||
| 109 | 1 | 50 | 5 | if (ref $bypass_path eq 'Regexp') { | |||
| 110 | 0 | 0 | 0 | return 1 if $path =~ $bypass_path; | |||
| 111 | } else { | ||||||
| 112 | 1 | 50 | 5 | return 1 if $path eq $bypass_path; | |||
| 113 | } | ||||||
| 114 | } | ||||||
| 115 | |||||||
| 116 | # Check bypass IPs | ||||||
| 117 | 2 | 50 | 50 | 7 | my $client_ip = exists $scope->{client} ? ($scope->{client}[0] // '') : ''; | ||
| 118 | 2 | 2 | for my $bypass_ip (@{$self->{bypass_ips}}) { | ||||
| 2 | 4 | ||||||
| 119 | 1 | 50 | 5 | if ($bypass_ip =~ m{/}) { | |||
| 120 | # CIDR notation | ||||||
| 121 | 0 | 0 | 0 | return 1 if $self->_ip_in_cidr($client_ip, $bypass_ip); | |||
| 122 | } else { | ||||||
| 123 | # Exact match | ||||||
| 124 | 1 | 50 | 4 | return 1 if $client_ip eq $bypass_ip; | |||
| 125 | } | ||||||
| 126 | } | ||||||
| 127 | |||||||
| 128 | 1 | 2 | return 0; | ||||
| 129 | } | ||||||
| 130 | |||||||
| 131 | sub _ip_in_cidr { | ||||||
| 132 | 0 | 0 | 0 | my ($self, $ip, $cidr) = @_; | |||
| 133 | |||||||
| 134 | 0 | 0 | my ($network, $bits) = split m{/}, $cidr; | ||||
| 135 | |||||||
| 136 | # Simple IPv4 check | ||||||
| 137 | 0 | 0 | 0 | 0 | return 0 unless $ip =~ /^[\d.]+$/ && $network =~ /^[\d.]+$/; | ||
| 138 | |||||||
| 139 | 0 | 0 | my $ip_num = $self->_ip_to_num($ip); | ||||
| 140 | 0 | 0 | my $net_num = $self->_ip_to_num($network); | ||||
| 141 | |||||||
| 142 | 0 | 0 | 0 | 0 | return 0 unless defined $ip_num && defined $net_num; | ||
| 143 | |||||||
| 144 | 0 | 0 | my $mask = ~((1 << (32 - $bits)) - 1) & 0xFFFFFFFF; | ||||
| 145 | 0 | 0 | return ($ip_num & $mask) == ($net_num & $mask); | ||||
| 146 | } | ||||||
| 147 | |||||||
| 148 | sub _ip_to_num { | ||||||
| 149 | 0 | 0 | 0 | my ($self, $ip) = @_; | |||
| 150 | |||||||
| 151 | 0 | 0 | my @octets = split /\./, $ip; | ||||
| 152 | 0 | 0 | 0 | return unless @octets == 4; | |||
| 153 | 0 | 0 | 0 | return unless _all_valid_octets(@octets); | |||
| 154 | 0 | 0 | return ($octets[0] << 24) + ($octets[1] << 16) + ($octets[2] << 8) + $octets[3]; | ||||
| 155 | } | ||||||
| 156 | |||||||
| 157 | sub _all_valid_octets { | ||||||
| 158 | 0 | 0 | 0 | for (@_) { | |||
| 159 | 0 | 0 | 0 | 0 | return 0 unless /^\d+$/ && $_ >= 0 && $_ <= 255; | ||
| 0 | |||||||
| 160 | } | ||||||
| 161 | 0 | 0 | return 1; | ||||
| 162 | } | ||||||
| 163 | |||||||
| 164 | 1 | 1 | 2 | async sub _send_maintenance { | |||
| 165 | 1 | 2 | my ($self, $send) = @_; | ||||
| 166 | |||||||
| 167 | 1 | 2 | my $body = $self->{body}; | ||||
| 168 | |||||||
| 169 | my @headers = ( | ||||||
| 170 | 1 | 4 | ['Content-Type', $self->{content_type}], | ||||
| 171 | ['Content-Length', length($body)], | ||||||
| 172 | ); | ||||||
| 173 | |||||||
| 174 | 1 | 50 | 3 | if (defined $self->{retry_after}) { | |||
| 175 | 0 | 0 | push @headers, ['Retry-After', $self->{retry_after}]; | ||||
| 176 | } | ||||||
| 177 | |||||||
| 178 | await $send->({ | ||||||
| 179 | type => 'http.response.start', | ||||||
| 180 | status => 503, | ||||||
| 181 | headers => \@headers, | ||||||
| 182 | 1 | 4 | }); | ||||
| 183 | 1 | 41 | await $send->({ | ||||
| 184 | type => 'http.response.body', | ||||||
| 185 | body => $body, | ||||||
| 186 | more => 0, | ||||||
| 187 | }); | ||||||
| 188 | } | ||||||
| 189 | |||||||
| 190 | sub _default_body { | ||||||
| 191 | 4 | 4 | 8 | my ($self) = @_; | |||
| 192 | |||||||
| 193 | 4 | 12 | return <<'HTML'; | ||||
| 194 | |||||||
| 195 | |||||||
| 196 | |||||||
| 197 | |||||||
| 198 | |||||||
| 199 | |
||||||
| 200 | |||||||
| 222 | |||||||
| 223 | |||||||
| 224 | |
||||||
| 225 | 🔧 |
||||||
| 226 | Under Maintenance |
||||||
| 227 | We're currently performing scheduled maintenance. Please check back soon. |
||||||
| 228 | |||||||
| 229 | |||||||
| 230 | |||||||
| 231 | HTML | ||||||
| 232 | } | ||||||
| 233 | |||||||
| 234 | 1; | ||||||
| 235 | |||||||
| 236 | __END__ |