PVE::Subscription - new class to simplify subscription management
[pve-common.git] / src / PVE / Subscription.pm
1 package PVE::Subscription;
2
3 use strict;
4 use warnings;
5 use Digest::MD5 qw(md5_hex md5_base64);
6 use MIME::Base64;
7 use HTTP::Request;
8 use URI;
9 use LWP::UserAgent;
10 use JSON;
11
12 use PVE::Tools;
13 use PVE::INotify;
14
15 # How long the local key is valid for in between remote checks
16 our $localkeydays = 15;
17 # How many days to allow after local key expiry before blocking
18 # access if connection cannot be made
19 my $allowcheckfaildays = 5;
20
21 my $shared_key_data = "kjfdlskfhiuewhfk947368";
22
23 my $saved_fields = {
24     key => 1,
25     checktime => 1,
26     status => 1,
27     message => 0,
28     validdirectory => 1,
29     productname => 1,
30     regdate => 1,
31     nextduedate => 1,
32 };
33
34 sub check_fields {
35     my ($info, $server_id) = @_;
36
37     foreach my $f (qw(status checktime key)) {
38         if (!$info->{$f}) {
39             die "Missing field '$f'\n";
40         }
41     }
42
43     if ($info->{checktime} > time()) {
44         die "Last check time in future.\n";
45     }
46
47     return undef if $info->{status} ne 'Active';
48
49     foreach my $f (keys %$saved_fields) {
50         next if !$saved_fields->{$f};
51         if (!$info->{$f}) {
52             die "Missing field '$f'\n";
53         }
54     }
55
56     my $found;
57     foreach my $hwid (split(/,/, $info->{validdirectory})) {
58         if ($hwid eq $server_id) {
59             $found = 1;
60             last;
61         }
62     }
63     die "Server ID does not match\n" if !$found;
64
65     return undef;
66 }
67
68 sub check_subscription {
69     my ($key, $server_id, $proxy) = @_;
70
71     my $whmcsurl = "https://shop.maurer-it.com";
72
73     my $uri = "$whmcsurl/modules/servers/licensing/verify.php";
74
75     my $check_token = time() . md5_hex(rand(8999999999) + 1000000000) . $key;
76
77     my $params = {
78         licensekey => $key,
79         dir => $server_id,
80         domain => 'www.proxmox.com',
81         ip => 'localhost',
82         check_token => $check_token,
83     };
84
85     my $req = HTTP::Request->new('POST' => $uri);
86     $req->header('Content-Type' => 'application/x-www-form-urlencoded');
87     # We use a temporary URI object to format
88     # the application/x-www-form-urlencoded content.
89     my $url = URI->new('http:');
90     $url->query_form(%$params);
91     my $content = $url->query;
92     $req->header('Content-Length' => length($content));
93     $req->content($content);
94
95     my $ua = LWP::UserAgent->new(protocols_allowed => ['https'], timeout => 30);
96
97     if ($proxy) {
98         $ua->proxy(['https'], $proxy);
99     } else {
100         $ua->env_proxy;
101     }
102
103     my $response = $ua->request($req);
104     my $code = $response->code;
105
106     if ($code != 200) {
107         my $msg = $response->message || 'unknown';
108         die "Invalid response from server: $code $msg\n";
109     }
110
111     my $raw = $response->decoded_content;
112
113     my $subinfo = {};
114     while ($raw =~ m/<(.*?)>([^<]+)<\/\1>/g) {
115         my ($k, $v) = ($1, $2);
116         next if !($k eq 'md5hash' || defined($saved_fields->{$k}));
117         $subinfo->{$k} = $v;
118     }
119     $subinfo->{checktime} = time();
120     $subinfo->{key} = $key;
121
122     if ($subinfo->{message}) {
123         $subinfo->{message} =~ s/^Directory Invalid$/Invalid Server ID/;
124     }
125
126     my $emd5sum = md5_hex($shared_key_data . $check_token);
127     if ($subinfo->{status} && $subinfo->{status} eq 'Active') {
128         if (!$subinfo->{md5hash} || ($subinfo->{md5hash} ne $emd5sum)) {
129             die "MD5 Checksum Verification Failed\n";
130         }
131     }
132
133     delete $subinfo->{md5hash};
134
135     check_fields($subinfo, $server_id);
136
137     return $subinfo;
138 }
139
140 sub read_subscription {
141     my ($server_id, $filename, $fh) = @_;
142
143     my $info = { status => 'Invalid' };
144
145     my $key = <$fh>; # first line is the key
146     chomp $key;
147
148     $info->{key} = $key;
149
150     my $csum = <$fh>; # second line is a checksum
151
152     my $data = '';
153     while (defined(my $line = <$fh>)) {
154         $data .= $line;
155     }
156
157     if ($csum && $data) {
158
159         chomp $csum;
160
161         my $localinfo = {};
162
163         eval {
164             my $json_text = decode_base64($data);
165             $localinfo = decode_json($json_text);
166             my $newcsum = md5_base64($localinfo->{checktime} . $data . $shared_key_data);
167             die "checksum failure\n" if $csum ne $newcsum;
168
169             check_fields($localinfo, $server_id);
170
171             my $age = time() -  $localinfo->{checktime};
172
173             my $maxage = ($localkeydays + $allowcheckfaildays)*60*60*24;
174             if ($localinfo->{status} eq 'Active' && $age > $maxage) {
175                 $localinfo->{status} = 'Invalid';
176                 $localinfo->{message} = "subscription info too old";
177             }
178         };
179         if (my $err = $@) {
180             warn $err;
181         } else {
182             $info = $localinfo;
183         }
184     }
185
186     return $info;
187 }
188
189 sub update_apt_auth {
190     my ($key, $server_id) = @_;
191
192     my $auth = { 'enterprise.proxmox.com' => { login => $key, password => $server_id } };
193     PVE::INotify::update_file('apt-auth', $auth);
194 }
195
196 sub write_subscription {
197     my ($server_id, $filename, $fh, $info) = @_;
198
199     if ($info->{status} eq 'New') {
200         PVE::Tools::safe_print($filename, $fh, "$info->{key}\n");
201     } else {
202         my $json = encode_json($info);
203         my $data = encode_base64($json);
204         my $csum = md5_base64($info->{checktime} . $data . $shared_key_data);
205
206         my $raw = "$info->{key}\n$csum\n$data";
207
208         PVE::Tools::safe_print($filename, $fh, $raw);
209     }
210
211     update_apt_auth($info->{key}, $server_id);
212 }
213
214 1;