the following three endpoints are used for migration on the remote side GET /nodes/NODE/qemu/VMID/mtunnel
returns identifier for this migration and ticket. both are passed to the other two endpoints to correspond that calls belong to the same migration run, and that permissions have been properly validated. note: neither ticket creation nor validation nor permission checks are implemented yet ;) POST /nodes/NODE/qemu/VMID/mtunnel which creates and locks an empty VM config, and spawns the main qmtunnel worker which binds to a VM-specific UNIX socket. this worker handles migration commands coming in via this UNIX socket: - config <BASE64(JSON($config))> - start (returning migration info) - resume - stop - unlock - cleanup (not yet implemented) this worker serves as a replacement for both 'qm mtunnel' and various manual calls via SSH. GET+WebSocket upgrade /nodes/NODE/qemu/VMID/mtunnelwebsocket gets called for connecting to a UNIX socket or TCP port via websocket forwarding, i.e. once for the main command mtunnel, and once each for the memory migration and each NBD drive-mirror. Signed-off-by: Fabian Grünbichler <f.gruenbich...@proxmox.com> --- PVE/API2/Qemu.pm | 292 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) diff --git a/PVE/API2/Qemu.pm b/PVE/API2/Qemu.pm index caca430..24f0dfd 100644 --- a/PVE/API2/Qemu.pm +++ b/PVE/API2/Qemu.pm @@ -6,6 +6,9 @@ use Cwd 'abs_path'; use Net::SSLeay; use POSIX; use IO::Socket::IP; +use IO::Socket::UNIX; +use JSON; +use MIME::Base64; use URI::Escape; use PVE::Cluster qw (cfs_read_file cfs_write_file);; @@ -715,6 +718,7 @@ __PACKAGE__->register_method({ { subdir => 'spiceproxy' }, { subdir => 'sendkey' }, { subdir => 'firewall' }, + { subdir => 'mtunnel' }, ]; return $res; @@ -4070,4 +4074,292 @@ __PACKAGE__->register_method({ return PVE::QemuServer::Cloudinit::dump_cloudinit_config($conf, $param->{vmid}, $param->{type}); }}); +# TODO add get call to retrieve tunnel ticket + + +# TODO verify ticket in mtunnel POST + +__PACKAGE__->register_method({ + name => 'mtunnel', + path => '{vmid}/mtunnel', + method => 'POST', + protected => 1, + proxyto => 'node', + description => 'Migration tunnel endpoint - only for internal use by VM migration.', + permissions => { + description => "You need 'VM.Allocate' permissions on /vms/{vmid}, as well as 'Datastore.AllocateSpace' on any used storage.", + user => 'all', # check inside + }, + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + version => { + type => 'integer', + optional => 0, + }, + vmid => get_standard_option('pve-vmid'), + }, + }, + returns => { + type => 'string', + }, + code => sub { + my ($param) = @_; + + my $rpcenv = PVE::RPCEnvironment::get(); + my $authuser = $rpcenv->get_user(); + + my $node = extract_param($param, 'node'); + my $vmid = extract_param($param, 'vmid'); + + my $lock = 'create'; + + PVE::Cluster::check_cfs_quorum(); + + eval { PVE::QemuConfig->create_and_lock_config($vmid, 0, $lock); }; + + raise_param_exc({ vmid => "unable to create empty VM config - $@"}) + if $@; + + my $realcmd = sub { + my $tunnel_socket; + my $pveproxy_uid; + + my $run_locked = sub { + my ($code) = @_; + PVE::QemuConfig->lock_config($vmid, sub { + my $conf = PVE::QemuConfig->load_config($vmid); + + die "Encountered wrong lock - aborting mtunnel command handling.\n" + if !PVE::QemuConfig->has_lock($conf, $lock); + + $code->(); + }); + }; + + $run_locked->(sub { + my $socket_addr = "/run/qemu-server/$vmid.mtunnel"; + unlink $socket_addr; + + $tunnel_socket = IO::Socket::UNIX->new( + Type => SOCK_STREAM(), + Local => $socket_addr, + Listen => 1, + ); + + $pveproxy_uid = getpwnam('www-data') + or die "Failed to resolve user 'www-data' to numeric UID\n"; + chown $pveproxy_uid, -1, $socket_addr; + }); + + print "mtunnel started\n"; + + my $conn = $tunnel_socket->accept(); + $conn->print("tunnel online\n"); + $conn->print("ver 2\n"); + + while (my $line = <$conn>) { + chomp $line; + print "command received: '$line'\n"; + if ($line =~ /^config (.*)$/) { + my $conf_str = $1; + eval { + my $newconf = JSON::decode_json(MIME::Base64::decode_base64url($conf_str)); + $newconf->{lock} = $lock; + $run_locked->(sub { + PVE::QemuConfig->write_config($vmid, $newconf); + }); + $conn->print("OK\n"); + }; + + $conn->print("ERR: failed to write config - $@\n") if $@; + } elsif ($line =~ /^start (.*)$/) { + my $param_str = $1; + eval { + my $param = JSON::decode_json($param_str); + print "start params:\n", Dumper($param), "\n"; + my $raddr; + my $rport; + my $ruri; # the whole migration dst. URI (protocol:address[:port]) + my $spice_port; + my $drive_info = {}; + my $nbd; + my $log = ""; + $run_locked->(sub { + PVE::Cluster::check_cfs_quorum(); + my $output; + open my $output_fh, '>>', \$output; + + select $output_fh; + my $storecfg = PVE::Storage::config(); + PVE::QemuServer::vm_start($storecfg, $vmid, $param->{state_uri}, 1, $node, undef, $param->{machine}, $param->{spice}, $param->{network}, $param->{migration_type}, $param->{targetstorage}); + select STDOUT; + # TODO weed out legacy stuff + foreach my $line (split("\n", $output)) { + chomp $line; + next if $line eq ''; + + if ($line =~ m/^migration listens on tcp:(localhost|[\d\.]+|\[[\d\.:a-fA-F]+\]):(\d+)$/) { + $raddr = $1; + $rport = int($2); + $ruri = "tcp:$raddr:$rport"; + } + elsif ($line =~ m!^migration listens on unix:(/run/qemu-server/(\d+)\.migrate)$!) { + $raddr = $1; + die "Destination UNIX sockets VMID does not match source VMID" if $vmid ne $2; + chown $pveproxy_uid, -1, $raddr; + $ruri = "unix:$raddr"; + } + elsif ($line =~ m/^migration listens on port (\d+)$/) { + $raddr = "localhost"; + $rport = int($1); + $ruri = "tcp:$raddr:$rport"; + } + elsif ($line =~ m/^spice listens on port (\d+)$/) { + $spice_port = int($1); + } + elsif ($line =~ m!^storage migration listens on nbd:unix:(/run/qemu-server/(\d+)_nbd\.migrate):exportname=(\S+) volume:(\S+)$!) { + die "Destination UNIX sockets VMID does not match source VMID" if $vmid ne $2; + my $nbd_unix_addr = $1; + my $nbd_uri = "nbd:unix:$nbd_unix_addr:exportname=$3"; + my $targetdrive = $3; + my $drivestr = $4; + $targetdrive =~ s/drive-//g; + $log .= "$targetdrive: $drivestr ('$line')\n"; + + $drive_info->{$targetdrive}->{drivestr} = $drivestr; + $drive_info->{$targetdrive}->{nbd_uri} = $nbd_uri; + + die "Migration returned two different NBD addresses: '$nbd_uri' / '$nbd'\n" + if $nbd && $nbd_unix_addr ne $nbd; + $nbd = $nbd_unix_addr; + chown $pveproxy_uid, -1, $nbd_unix_addr; + } else { + $log .= "[remote] $line\n"; + } + } + }); + + die "$@\n" if $@; + + select STDOUT; + die "unable to detect remote migration address\n" if !$raddr || !$ruri; + my $res = { + raddr => $raddr, + rport => $rport, + ruri => $ruri, + drives => $drive_info, + nbd => $nbd, + }; + $res->{spice_port} = $spice_port if $spice_port; + my $reply = JSON::encode_json($res); + $conn->print("OK\n"); + $conn->print("$reply\n"); + }; + $conn->print("ERR: failed to start VM - $@\n") if $@; + } elsif ($line =~ /^stop$/) { + eval { + $run_locked->(sub { + PVE::QemuServer::vm_stop(undef, $vmid, 1, 1); + }); + $conn->print("OK\n"); + }; + $conn->print("ERR: failed to stop VM - $@\n") if $@; + } elsif ($line =~ /^nbdstop$/) { + eval { + $run_locked->(sub { + PVE::QemuServer::nbd_stop($vmid); + }); + $conn->print("OK\n"); + }; + $conn->print("ERR: failed to stop NBD server - $@\n") if $@; + } elsif ($line =~ /^resume /) { + eval { + $run_locked->(sub { + if (PVE::QemuServer::check_running($vmid, 1)) { + PVE::QemuServer::vm_resume($vmid, 1, 1); + } else { + die "VM $vmid not running\n"; + } + }); + $conn->print("OK\n"); + }; + $conn->print("ERR: resume failed - $@") if $@; + } elsif ($line =~ /^unlock$/) { + eval { + PVE::QemuConfig->remove_lock($vmid, $lock); + $conn->print("OK\n"); + }; + $conn->print("ERR: unlock failed - $@") if $@; + } elsif ($line =~ /^quit$/) { + $conn->print("OK\n"); + $conn->close(); + $tunnel_socket->close(); + last; + } else { + $conn->print("ERR: unknown command '$line'"); + } + } + + print "mtunnel exited\n"; + }; + + $rpcenv->fork_worker('qmtunnel', $vmid, $authuser, $realcmd); + }}); + +__PACKAGE__->register_method({ + name => 'mtunnelwebsocket', + path => '{vmid}/mtunnelwebsocket', + method => 'GET', + proxyto => 'node', + permissions => { + description => "You need to pass a ticket valid for the selected port/socket. Tickets can be created via the mtunnel API call, which will check permissions accordingly.", + user => 'all', # check inside + }, + description => 'Migration tunnel endpoint for websocket upgrade - only for internal use by VM migration.', + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + vmid => get_standard_option('pve-vmid'), + port => { + type => "integer", + description => "TCP port to forward to", + optional => 1, + }, + socket => { + type => "string", + description => "unix socket to forward to", + optional => 1, + }, + }, + }, + returns => { + type => "object", + properties => { + port => { type => 'string', optional => 1 }, + socket => { type => 'string', optional => 1 }, + }, + }, + code => sub { + my ($param) = @_; + + my $rpcenv = PVE::RPCEnvironment::get(); + my $authuser = $rpcenv->get_user(); + + my $vmid = $param->{vmid}; + my $node = $param->{node}; + + # TODO: verify ticket here + + my $port = $param->{port}; + my $socket = $param->{socket}; + + # TODO either or + + return { port => $port } if $port; + return { socket => $socket } if $socket; + }}); + 1; -- 2.20.1 _______________________________________________ pve-devel mailing list pve-devel@pve.proxmox.com https://pve.proxmox.com/cgi-bin/mailman/listinfo/pve-devel