]>
Commit | Line | Data |
---|---|---|
038808dd DM |
1 | #!/usr/bin/perl |
2 | ||
3 | # This demo requires some other packages: novnc-pve and | |
4 | # pve-manager (for PVE::NoVncIndex) | |
5 | ||
6 | ||
7 | # First, we need some helpers to create authentication Tickets | |
8 | ||
9 | package Ticket; | |
10 | ||
11 | use strict; | |
12 | use warnings; | |
13 | use Net::SSLeay; | |
14 | ||
15 | use PVE::Ticket; | |
16 | ||
17 | use Crypt::OpenSSL::RSA; | |
18 | ||
19 | my $min_ticket_lifetime = -60*5; # allow 5 minutes time drift | |
20 | my $max_ticket_lifetime = 60*60*2; # 2 hours | |
21 | ||
22 | my $rsa = Crypt::OpenSSL::RSA->generate_key(2048); | |
23 | ||
24 | sub create_ticket { | |
25 | my ($username) = @_; | |
26 | ||
27 | return PVE::Ticket::assemble_rsa_ticket($rsa, 'DEMO', $username); | |
28 | } | |
29 | ||
30 | sub verify_ticket { | |
31 | my ($ticket, $noerr) = @_; | |
32 | ||
33 | return PVE::Ticket::verify_rsa_ticket( | |
34 | $rsa, 'DEMO', $ticket, undef, | |
35 | $min_ticket_lifetime, $max_ticket_lifetime, $noerr); | |
36 | } | |
37 | ||
38 | # VNC tickets | |
39 | # - they do not contain the username in plain text | |
40 | # - they are restricted to a specific resource path (example: '/vms/100') | |
41 | sub assemble_vnc_ticket { | |
42 | my ($username, $path) = @_; | |
43 | ||
44 | my $secret_data = "$username:$path"; | |
45 | ||
46 | return PVE::Ticket::assemble_rsa_ticket( | |
47 | $rsa, 'DEMOVNC', undef, $secret_data); | |
48 | } | |
49 | ||
50 | sub verify_vnc_ticket { | |
51 | my ($ticket, $username, $path, $noerr) = @_; | |
52 | ||
53 | my $secret_data = "$username:$path"; | |
54 | ||
55 | return PVE::Ticket::verify_rsa_ticket( | |
56 | $rsa, 'DEMOVNC', $ticket, $secret_data, -20, 40, $noerr); | |
57 | } | |
58 | ||
59 | # We stack several PVE::RESTHandler classes to create | |
60 | # the API for the novnc-pve console. | |
61 | ||
62 | package NodeInfoAPI; | |
63 | ||
64 | use strict; | |
65 | use warnings; | |
66 | ||
67 | use PVE::RESTHandler; | |
68 | use PVE::JSONSchema qw(get_standard_option); | |
69 | use PVE::RESTEnvironment; | |
70 | use PVE::SafeSyslog; | |
71 | ||
72 | use base qw(PVE::RESTHandler); | |
73 | ||
74 | __PACKAGE__->register_method ({ | |
75 | name => 'index', | |
76 | path => '', | |
77 | method => 'GET', | |
78 | permissions => { user => 'all' }, | |
79 | description => "Node index.", | |
80 | parameters => { | |
81 | additionalProperties => 0, | |
82 | properties => { | |
83 | node => get_standard_option('pve-node'), | |
84 | }, | |
85 | }, | |
86 | returns => { | |
87 | type => 'array', | |
88 | items => { | |
89 | type => "object", | |
90 | properties => {}, | |
91 | }, | |
92 | links => [ { rel => 'child', href => "{name}" } ], | |
93 | }, | |
94 | code => sub { | |
95 | my ($param) = @_; | |
96 | ||
97 | my $result = [ | |
98 | { name => 'vncshell' }, | |
99 | ]; | |
100 | ||
101 | return $result; | |
102 | }}); | |
103 | ||
104 | __PACKAGE__->register_method ({ | |
105 | name => 'vncshell', | |
106 | path => 'vncshell', | |
107 | method => 'POST', | |
108 | description => "Creates a VNC Shell proxy.", | |
109 | parameters => { | |
110 | additionalProperties => 0, | |
111 | properties => { | |
112 | node => get_standard_option('pve-node'), | |
113 | websocket => { | |
114 | optional => 1, | |
115 | type => 'boolean', | |
116 | description => "use websocket instead of standard vnc.", | |
117 | default => 1, | |
118 | }, | |
119 | }, | |
120 | }, | |
121 | returns => { | |
122 | additionalProperties => 0, | |
123 | properties => { | |
124 | user => { type => 'string' }, | |
125 | ticket => { type => 'string' }, | |
126 | port => { type => 'integer' }, | |
127 | upid => { type => 'string' }, | |
128 | }, | |
129 | }, | |
130 | code => sub { | |
131 | my ($param) = @_; | |
132 | ||
133 | my $node = $param->{node}; | |
134 | ||
135 | # we only implement the websocket based VNC here | |
136 | my $websocket = $param->{websocket} // 1; | |
137 | die "standard VNC not implemented" if !$websocket; | |
138 | ||
139 | my $authpath = "/nodes/$node"; | |
140 | ||
141 | my $restenv = PVE::RESTEnvironment->get(); | |
142 | my $user = $restenv->get_user(); | |
143 | ||
144 | my $ticket = Ticket::assemble_vnc_ticket($user, $authpath); | |
145 | ||
146 | my $family = PVE::Tools::get_host_address_family($node); | |
147 | my $port = PVE::Tools::next_vnc_port($family); | |
148 | ||
149 | my $cmd = ['/usr/bin/vncterm', '-rfbport', $port, | |
150 | '-timeout', 10, '-notls', '-listen', 'localhost', | |
151 | '-c', '/usr/bin/top']; | |
152 | ||
153 | my $realcmd = sub { | |
154 | my $upid = shift; | |
155 | ||
156 | syslog ('info', "starting vnc proxy $upid\n"); | |
157 | ||
158 | my $cmdstr = join (' ', @$cmd); | |
159 | syslog ('info', "launch command: $cmdstr"); | |
160 | ||
161 | eval { | |
162 | foreach my $k (keys %ENV) { | |
163 | next if $k eq 'PATH' || $k eq 'TERM' || $k eq 'USER' || $k eq 'HOME'; | |
164 | delete $ENV{$k}; | |
165 | } | |
166 | $ENV{PWD} = '/'; | |
167 | ||
168 | $ENV{PVE_VNC_TICKET} = $ticket; # pass ticket to vncterm | |
169 | ||
170 | PVE::Tools::run_command($cmd, errmsg => "vncterm failed"); | |
171 | }; | |
172 | if (my $err = $@) { | |
173 | syslog('err', $err); | |
174 | } | |
175 | ||
176 | return; | |
177 | }; | |
178 | ||
179 | my $upid = $restenv->fork_worker('vncshell', "", $user, $realcmd); | |
180 | ||
181 | PVE::Tools::wait_for_vnc_port($port); | |
182 | ||
183 | return { | |
184 | user => $user, | |
185 | ticket => $ticket, | |
186 | port => $port, | |
187 | upid => $upid, | |
188 | }; | |
189 | }}); | |
190 | ||
191 | __PACKAGE__->register_method({ | |
192 | name => 'vncwebsocket', | |
193 | path => 'vncwebsocket', | |
194 | method => 'GET', | |
195 | description => "Opens a weksocket for VNC traffic.", | |
196 | parameters => { | |
197 | additionalProperties => 0, | |
198 | properties => { | |
199 | node => get_standard_option('pve-node'), | |
200 | vncticket => { | |
201 | description => "Ticket from previous call to vncproxy.", | |
202 | type => 'string', | |
203 | maxLength => 512, | |
204 | }, | |
205 | port => { | |
206 | description => "Port number returned by previous vncproxy call.", | |
207 | type => 'integer', | |
208 | minimum => 5900, | |
209 | maximum => 5999, | |
210 | }, | |
211 | }, | |
212 | }, | |
213 | returns => { | |
214 | type => "object", | |
215 | properties => { | |
216 | port => { type => 'string' }, | |
217 | }, | |
218 | }, | |
219 | code => sub { | |
220 | my ($param) = @_; | |
221 | ||
222 | my $authpath = "/nodes/$param->{node}"; | |
223 | ||
224 | my $restenv = PVE::RESTEnvironment->get(); | |
225 | my $user = $restenv->get_user(); | |
226 | ||
227 | Ticket::verify_vnc_ticket($param->{vncticket}, $user, $authpath); | |
228 | ||
229 | my $port = $param->{port}; | |
230 | ||
231 | return { port => $port }; | |
232 | }}); | |
233 | ||
234 | ||
235 | package NodeAPI; | |
236 | ||
237 | use strict; | |
238 | use warnings; | |
239 | ||
240 | use PVE::RESTHandler; | |
241 | use PVE::JSONSchema qw(get_standard_option); | |
242 | ||
243 | use base qw(PVE::RESTHandler); | |
244 | ||
245 | __PACKAGE__->register_method ({ | |
246 | subclass => "NodeInfoAPI", | |
247 | path => '{node}', | |
248 | }); | |
249 | ||
250 | __PACKAGE__->register_method ({ | |
251 | name => 'index', | |
252 | path => '', | |
253 | method => 'GET', | |
254 | permissions => { user => 'all' }, | |
255 | description => "Cluster node index.", | |
256 | parameters => { | |
257 | additionalProperties => 0, | |
258 | properties => {}, | |
259 | }, | |
260 | returns => { | |
261 | type => 'array', | |
262 | items => { | |
263 | type => "object", | |
264 | properties => {}, | |
265 | }, | |
266 | links => [ { rel => 'child', href => "{node}" } ], | |
267 | }, | |
268 | code => sub { | |
269 | my ($param) = @_; | |
270 | ||
271 | my $res = [ | |
272 | { node => 'elsa' }, | |
273 | ]; | |
274 | ||
275 | return $res; | |
276 | }}); | |
277 | ||
278 | ||
279 | package YourAPI; | |
280 | ||
281 | use strict; | |
282 | use warnings; | |
283 | ||
284 | use PVE::RESTHandler; | |
285 | use PVE::JSONSchema; | |
286 | ||
287 | use base qw(PVE::RESTHandler); | |
288 | ||
289 | __PACKAGE__->register_method ({ | |
290 | subclass => "NodeAPI", | |
291 | path => 'nodes', | |
292 | }); | |
293 | ||
294 | __PACKAGE__->register_method ({ | |
295 | name => 'index', | |
296 | path => '', | |
297 | method => 'GET', | |
298 | permissions => { user => 'all' }, | |
299 | description => "Directory index.", | |
300 | parameters => { | |
301 | additionalProperties => 0, | |
302 | properties => {}, | |
303 | }, | |
304 | returns => { | |
305 | type => 'array', | |
306 | items => { | |
307 | type => "object", | |
308 | properties => { | |
309 | subdir => { type => 'string' }, | |
310 | }, | |
311 | }, | |
312 | links => [ { rel => 'child', href => "{subdir}" } ], | |
313 | }, | |
314 | code => sub { | |
315 | my ($resp, $param) = @_; | |
316 | ||
317 | my $res = [ { subdir => 'nodes' } ]; | |
318 | ||
319 | return $res; | |
320 | }}); | |
321 | ||
322 | ||
323 | # This is the REST/HTTPS Server | |
324 | package DemoServer; | |
325 | ||
326 | use strict; | |
327 | use warnings; | |
328 | use HTTP::Status qw(:constants); | |
329 | use URI::Escape; | |
330 | ||
331 | use PVE::APIServer::AnyEvent; | |
332 | use PVE::Exception qw(raise_param_exc); | |
333 | use PVE::RESTEnvironment; | |
334 | ||
335 | use base('PVE::APIServer::AnyEvent'); | |
336 | ||
337 | sub new { | |
338 | my ($this, %args) = @_; | |
339 | ||
340 | my $class = ref($this) || $this; | |
341 | ||
342 | my $self = $class->SUPER::new(%args); | |
343 | ||
344 | PVE::RESTEnvironment->init('pub'); | |
345 | ||
346 | return $self; | |
347 | } | |
348 | ||
349 | sub auth_handler { | |
350 | my ($self, $method, $rel_uri, $ticket, $token, $peer_host) = @_; | |
351 | ||
352 | my $restenv = PVE::RESTEnvironment::get(); | |
353 | $restenv->set_user(undef); | |
354 | ||
355 | # explicitly allow some calls without authentication | |
356 | if ($rel_uri eq '/access/ticket' && | |
357 | ($method eq 'POST' || $method eq 'GET')) { | |
358 | return; # allow call to create ticket | |
359 | } | |
360 | ||
361 | my $userid = Ticket::verify_ticket($ticket); | |
362 | $restenv->set_user($userid); | |
363 | ||
364 | return { | |
365 | ticket => $ticket, | |
366 | userid => $userid, | |
367 | }; | |
368 | } | |
369 | ||
370 | sub rest_handler { | |
371 | my ($self, $clientip, $method, $rel_uri, $auth, $params) = @_; | |
372 | ||
373 | my $resp = { | |
374 | status => HTTP_NOT_IMPLEMENTED, | |
375 | message => "Method '$method $rel_uri' not implemented", | |
376 | }; | |
377 | ||
378 | if ($rel_uri eq '/access/ticket') { | |
379 | if ($method eq 'POST') { | |
380 | if ($params->{username} && $params->{username} eq 'demo' && | |
381 | $params->{password} && $params->{password} eq 'demo') { | |
382 | return { | |
383 | status => HTTP_OK, | |
384 | data => { | |
385 | ticket => Ticket::create_ticket($params->{username}), | |
386 | }, | |
387 | }; | |
388 | } | |
389 | return $resp; | |
390 | } elsif ($method eq 'GET') { | |
391 | # this is allowed to display the login form | |
392 | return { status => HTTP_OK, data => {} }; | |
393 | } else { | |
394 | return $resp; | |
395 | } | |
396 | } | |
397 | ||
398 | my ($handler, $info); | |
399 | ||
400 | eval { | |
401 | my $uri_param = {}; | |
402 | ($handler, $info) = YourAPI->find_handler($method, $rel_uri, $uri_param); | |
403 | return if !$handler || !$info; | |
404 | ||
405 | foreach my $p (keys %{$params}) { | |
406 | if (defined($uri_param->{$p})) { | |
407 | raise_param_exc({$p => "duplicate parameter (already defined in URI)"}); | |
408 | } | |
409 | $uri_param->{$p} = $params->{$p}; | |
410 | } | |
411 | ||
412 | $resp = { | |
413 | data => $handler->handle($info, $uri_param), | |
414 | info => $info, # useful to format output | |
415 | status => HTTP_OK, | |
416 | }; | |
417 | }; | |
418 | if (my $err = $@) { | |
419 | $resp = { info => $info }; | |
420 | if (ref($err) eq "PVE::Exception") { | |
421 | $resp->{status} = $err->{code} || HTTP_INTERNAL_SERVER_ERROR; | |
422 | $resp->{errors} = $err->{errors} if $err->{errors}; | |
423 | $resp->{message} = $err->{msg}; | |
424 | } else { | |
425 | $resp->{status} = HTTP_INTERNAL_SERVER_ERROR; | |
426 | $resp->{message} = $err; | |
427 | } | |
428 | } | |
429 | ||
430 | return $resp; | |
431 | } | |
432 | ||
433 | ||
434 | # The main package creates the socket and runs the server | |
435 | package main; | |
436 | ||
437 | use strict; | |
438 | use warnings; | |
439 | ||
440 | use Socket qw(IPPROTO_TCP TCP_NODELAY SOMAXCONN); | |
441 | use IO::Socket::IP; | |
442 | use HTTP::Headers; | |
443 | use HTTP::Response; | |
444 | use Data::Dumper; | |
445 | ||
446 | use PVE::Tools qw(run_command); | |
447 | use PVE::INotify; | |
448 | use PVE::APIServer::Formatter::Standard; | |
449 | use PVE::APIServer::Formatter::HTML; | |
450 | use PVE::NoVncIndex; | |
451 | ||
452 | my $nodename = PVE::INotify::nodename(); | |
453 | my $port = 9999; | |
454 | ||
455 | my $cert_file = "simple-demo.pem"; | |
456 | ||
457 | if (! -f $cert_file) { | |
458 | print "generating demo server certificate\n"; | |
459 | my $cmd = ['openssl', 'req', '-batch', '-x509', '-newkey', 'rsa:4096', | |
460 | '-nodes', '-keyout', $cert_file, '-out', $cert_file, | |
461 | '-subj', "/CN=$nodename/", | |
462 | '-days', '3650']; | |
463 | run_command($cmd); | |
464 | } | |
465 | ||
466 | my $socket = IO::Socket::IP->new( | |
467 | LocalAddr => $nodename, | |
468 | LocalPort => $port, | |
469 | Listen => SOMAXCONN, | |
470 | Proto => 'tcp', | |
471 | GetAddrInfoFlags => 0, | |
472 | ReuseAddr => 1) || | |
473 | die "unable to create socket - $@\n"; | |
474 | ||
475 | # we often observe delays when using Nagle algorithm, | |
476 | # so we disable that to maximize performance | |
477 | setsockopt($socket, IPPROTO_TCP, TCP_NODELAY, 1); | |
478 | ||
479 | my $accept_lock_fn = "simple-demo.lck"; | |
480 | my $lockfh = IO::File->new(">>${accept_lock_fn}") || | |
481 | die "unable to open lock file '${accept_lock_fn}' - $!\n"; | |
482 | ||
483 | my $dirs = {}; | |
484 | PVE::APIServer::AnyEvent::add_dirs( | |
485 | $dirs, '/novnc/' => '/usr/share/novnc-pve/'); | |
486 | ||
487 | my $server = DemoServer->new( | |
488 | debug => 1, | |
489 | socket => $socket, | |
490 | lockfile => $accept_lock_fn, | |
491 | lockfh => $lockfh, | |
492 | title => 'Simple Demo API', | |
493 | cookie_name => 'DEMO', | |
494 | logfh => \*STDOUT, | |
495 | tls_ctx => { verify => 0, cert_file => $cert_file }, | |
496 | dirs => $dirs, | |
497 | pages => { | |
498 | '/' => sub { get_index($nodename, @_) }, | |
499 | }, | |
500 | ); | |
501 | ||
502 | # NOTE: Requests to non-API pages are not authenticated | |
503 | # so you must be very careful here | |
504 | ||
505 | my $root_page = <<__EOD__; | |
506 | <!DOCTYPE html> | |
507 | <html> | |
508 | <head> | |
509 | <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> | |
510 | <meta http-equiv="X-UA-Compatible" content="IE=edge"> | |
511 | <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> | |
512 | <title>Simple Demo Server</title> | |
513 | </head> | |
514 | <body> | |
515 | <h1>Simple Demo Server ($nodename)</h1> | |
516 | ||
517 | <p>You can browse the API <a href='/api2/html' >here</a>. Please sign | |
518 | in with usrename <b>demo</b> and passwort <b>demo</b>.</p> | |
519 | ||
520 | <p>Server console is here: <a href="?console=shell&novnc=1&node=$nodename">Console</a> | |
521 | ||
522 | </body> | |
523 | </html> | |
524 | __EOD__ | |
525 | ||
526 | sub get_index { | |
527 | my ($nodename, $server, $r, $args) = @_; | |
528 | ||
529 | my $token = ''; | |
530 | ||
531 | my ($ticket, $userid); | |
532 | if (my $cookie = $r->header('Cookie')) { | |
533 | #$ticket = PVE::APIServer::Formatter::extract_auth_cookie($cookie, $server->{cookie_name}); | |
534 | # $userid = Ticket::verify_ticket($ticket, 1); | |
535 | } | |
536 | ||
537 | my $page = $root_page; | |
538 | ||
539 | if (defined($args->{console}) && $args->{novnc}) { | |
540 | $page = PVE::NoVncIndex::get_index('en', $userid, $token, | |
541 | $args->{console}, $nodename); | |
542 | } | |
543 | ||
544 | my $headers = HTTP::Headers->new(Content_Type => "text/html; charset=utf-8"); | |
545 | my $resp = HTTP::Response->new(200, "OK", $headers, $page); | |
546 | ||
547 | return $resp; | |
548 | } | |
549 | ||
550 | print "demo server listens at: https://$nodename:$port/\n"; | |
551 | ||
552 | $server->run(); |