This commit adds an example implementation of a custom storage plugin that uses SSHFS [0] as the underlying filesystem.
The implementation is very similar to that of the NFS plugin; as a prerequisite, it is currently necessary to use pubkey auth and have the host's root user's public key deployed to the remote host like so: ssh-copy-id -i ~/.ssh/id_my_private_key \ -o UserKnownHostsFile=/etc/pve/priv/known_hosts [USER]@[HOST] Then, the storage can be added as follows: pvesm add sshfs [STOREID] \ --username [USER] \ --server [HOST] \ --sshfs-remote-path [ABS PATH ON REMOTE] \ --path /mnt/path/to/storage \ --sshfs-private-key ~/.ssh/id_my_private_key If the host is part of a cluster, other nodes may connect to the remote without any additional setup required. This is because we copy the private key to `/etc/pve/priv/storage/$KEYNAME.key` and use the cluster-wide `/etc/pve/priv/known_hosts` file. Also mark each SSHFS storage as `shared` by default in order to make use of this. Note: Because there's currently no way to officially and permanently mark a storage as shared (like some built-in plugins [1]) set `$scfg->{shared} = 1;` in `on_add_hook`. This has almost the same effect as modifying `@PVE::Storage::Plugin::SHARED_STORAGE` directly, except that the `shared` is written to `/etc/pve/storage.cfg`. [0]: https://github.com/libfuse/sshfs [1]: https://git.proxmox.com/?p=pve-storage.git;a=blob;f=src/PVE/Storage/Plugin.pm;h=4e16420f667f196e8eb99ae7c9f3f1d3e13791fb;hb=refs/heads/master#l37 Signed-off-by: Max Carrara <m.carr...@proxmox.com> --- Changes rfc-v1 --> v1: * rework most of the plugin * cease to call methods of DirPlugin (plugins should be isolated; we don't want to encourage third-party devs to do that) * handle SSH private key as sensitive property and place it on pmxcfs * make storage shared * manually implement attribute handling ("notes", "protected" for backups) .../lib/PVE/Storage/Custom/SSHFSPlugin.pm | 398 ++++++++++++++++++ 1 file changed, 398 insertions(+) create mode 100644 example/sshfs-plugin/lib/PVE/Storage/Custom/SSHFSPlugin.pm diff --git a/example/sshfs-plugin/lib/PVE/Storage/Custom/SSHFSPlugin.pm b/example/sshfs-plugin/lib/PVE/Storage/Custom/SSHFSPlugin.pm new file mode 100644 index 0000000..75b29c1 --- /dev/null +++ b/example/sshfs-plugin/lib/PVE/Storage/Custom/SSHFSPlugin.pm @@ -0,0 +1,398 @@ +package PVE::Storage::Custom::SSHFSPlugin; + +use strict; +use warnings; + +use feature 'signatures'; + +use Cwd qw(); +use Encode qw(decode encode); +use File::Path qw(make_path); +use File::Basename qw(dirname); +use IO::File; +use POSIX; + +use PVE::ProcFSTools; +use PVE::Tools qw( + file_copy + file_get_contents + file_set_contents + run_command +); + +use base qw(PVE::Storage::Plugin); + +my $CLUSTER_KNOWN_HOSTS = "/etc/pve/priv/known_hosts"; + +# Plugin Definition + +sub api { + return 11; +} + +sub type { + return 'sshfs'; +} + +sub plugindata { + return { + content => [ + { + images => 1, + rootdir => 1, + vztmpl => 1, + iso => 1, + backup => 1, + snippets => 1, + none => 1, + }, + { + images => 1, + rootdir => 1, + }, + ], + format => [ + { + raw => 1, + qcow2 => 1, + vmdk => 1, + }, + 'qcow2', + ], + 'sensitive-properties' => { + 'sshfs-private-key' => 1, + }, + }; +} + +sub properties { + return { + 'sshfs-remote-path' => { + description => "Path on the remote filesystem used for SSHFS. Must be absolute.", + type => 'string', + format => 'pve-storage-path', + }, + 'sshfs-private-key' => { + description => "Path to the private key to use for SSHFS.", + type => 'string', + format => 'pve-storage-path', + } + }; +} + +sub options { + return { + disable => { optional => 1 }, + path => { fixed => 1 }, + 'create-base-path' => { optional => 1 }, + content => { optional => 1 }, + 'create-subdirs' => { optional => 1 }, + 'content-dirs' => { optional => 1 }, + 'prune-backups' => { optional => 1 }, + 'max-protected-backups' => { optional => 1 }, + format => { optional => 1 }, + bwlimit => { optional => 1 }, + preallocation => { optional => 1 }, + nodes => { optional => 1 }, + shared => { optional => 1 }, + + # SSHFS Options + username => {}, + server => {}, + 'sshfs-remote-path' => {}, + port => { optional => 1 }, + 'sshfs-private-key' => { optional => 1 }, + }; +} + +# SSHFS Helpers + +my sub sshfs_remote_from_config: prototype($) ($scfg) { + my ($user, $host, $remote_path) = $scfg->@{qw(username server sshfs-remote-path)}; + return "${user}\@${host}:${remote_path}"; +} + +my sub sshfs_private_key_path: prototype($) ($storeid) { + return "/etc/pve/priv/storage/$storeid.key"; +} + +my sub sshfs_common_ssh_opts: prototype($) ($storeid) { + my $private_key_path = sshfs_private_key_path($storeid); + + my @common_opts = ( + '-o', "UserKnownHostsFile=${CLUSTER_KNOWN_HOSTS}", + '-o', 'GlobalKnownHostsFile=none', + '-o', "IdentityFile=${private_key_path}", + ); + + return @common_opts; +} + +my sub sshfs_set_private_key: prototype($$) ($storeid, $src_key_path) { + die "path of private key file not specified" if !defined($src_key_path); + die "path of private key file does not exist" if ! -e $src_key_path; + die "path of private key file does not point to a file" if ! -f $src_key_path; + + my $dest_key_path = sshfs_private_key_path($storeid); + + my $dest_key_parent_dir = dirname($dest_key_path); + if (! -e $dest_key_parent_dir) { + make_path($dest_key_parent_dir, { chmod => 0700 }); + } else { + die "'$dest_key_path' already exists" if -e $dest_key_path; + } + + file_copy($src_key_path, $dest_key_path, undef, 600); + + return undef; +} + +my sub sshfs_remove_private_key: prototype($) ($storeid) { + my $key_path = sshfs_private_key_path($storeid); + unlink($key_path) or $! == ENOENT or die "failed to remove private key '$key_path' - $!\n"; + + return undef; +} + +my sub sshfs_is_mounted: prototype($) ($scfg) { + my $remote = sshfs_remote_from_config($scfg); + + my $mountpoint = Cwd::realpath($scfg->{path}); # Resolve symlinks + return 0 if !defined($mountpoint); + + my $mountdata = PVE::ProcFSTools::parse_proc_mounts(); + + my $has_found_mountpoint = grep { + $_->[0] =~ m|^\Q${remote}\E$| + && $_->[1] eq $mountpoint + && $_->[2] eq 'fuse.sshfs' + } $mountdata->@*; + + return $has_found_mountpoint != 0; +} + +my sub sshfs_mount: prototype($$) ($scfg, $storeid) { + my $remote = sshfs_remote_from_config($scfg); + my ($port, $mountpoint) = $scfg->@{qw(port path)}; + + my @common_opts = sshfs_common_ssh_opts($storeid); + my $cmd = [ + '/usr/bin/sshfs', @common_opts, + '-o', 'noatime', + ]; + + push($cmd->@*, '-p', $port) if $port; + push($cmd->@*, $remote, $mountpoint); + + eval { + run_command( + $cmd, + timeout => 10, + errfunc => sub { warn "$_[0]\n"; }, + ); + }; + if (my $err = $@) { + die "failed to mount SSHFS storage '$remote' at '$mountpoint': $@\n"; + } + + die "SSHFS storage '$remote' not mounted at '$mountpoint' despite reported success\n" + if ! sshfs_is_mounted($scfg); + + return; +} + +my sub sshfs_umount: prototype($) ($scfg) { + my $mountpoint = $scfg->{path}; + + my $cmd = ['/usr/bin/umount', $mountpoint]; + + eval { + run_command( + $cmd, + timeout => 10, + errfunc => sub { warn "$_[0]\n"; }, + ); + }; + if (my $err = $@) { + die "failed to unmount SSHFS at '$mountpoint': $err\n"; + } + + return; +} + +# Storage Implementation + +sub on_add_hook ($class, $storeid, $scfg, %sensitive) { + $scfg->{shared} = 1; # mark SSHFS storages as shared by default + + eval { + my $src_key_path = $sensitive{'sshfs-private-key'}; + sshfs_set_private_key($storeid, $src_key_path); + }; + die "error while adding SSHFS storage '${storeid}': $@\n" if $@; + + return undef; +} + +sub on_update_hook ($class, $storeid, $scfg, %sensitive) { + return undef if !exists($sensitive{'sshfs-private-key'}); + + my $src_key_path = $sensitive{'sshfs-private-key'}; + + if (!defined($src_key_path)) { + warn "removing private key for SSHFS storage '${storeid}'"; + warn "the storage might not be mountable without a private key!"; + + eval { sshfs_remove_private_key($storeid); }; + die $@ if $@; + + return undef; + } + + my $dest_key_path = sshfs_private_key_path($storeid); + my $dest_key_path_tmp = "${dest_key_path}.old"; + + file_copy($dest_key_path, $dest_key_path_tmp) if -e $dest_key_path; + + eval { file_copy($src_key_path, $dest_key_path, undef, 600); }; + + if (my $err = $@) { + if (-e $dest_key_path_tmp) { + warn "attempting to restore previous private key for storage '${storeid}'\n"; + eval { file_copy($dest_key_path_tmp, $dest_key_path, undef, 600); }; + warn "$@\n" if $@; + + unlink $dest_key_path_tmp; + } + + die "failed to set private key for SSHFS storage '${storeid}': $err\n"; + } + + unlink $dest_key_path_tmp; + + return undef; +} + +sub on_delete_hook ($class, $storeid, $scfg) { + eval { sshfs_remove_private_key($storeid); }; + warn $@ if $@; + + eval { sshfs_umount($scfg) if sshfs_is_mounted($scfg); }; + warn $@ if $@; + + return undef; +} + +sub check_connection ($class, $storeid, $scfg) { + my ($user, $host, $port) = $scfg->@{qw(username server port)}; + + my @common_opts = sshfs_common_ssh_opts($storeid); + my $cmd = [ + '/usr/bin/ssh', + '-T', + @common_opts, + '-o', 'BatchMode=yes', + '-o', 'ConnectTimeout=5', + ]; + + push($cmd->@*, "-p", $port) if $port; + push($cmd->@*, "${user}\@${host}", 'exit 0'); + + eval { + run_command( + $cmd, + timeout => 10, + errfunc => sub { warn "$_[0]\n"; }, + ); + }; + if (my $err = $@) { + warn "$err"; + return 0; + } + + return 1; +} + +sub activate_storage ($class, $storeid, $scfg, $cache) { + my $mountpoint = $scfg->{path}; + + if (!sshfs_is_mounted($scfg)) { + if ($scfg->{'create-base-path'} // 1) { + make_path($mountpoint); + } + + die "unable to activate storage '$storeid' - directory '$mountpoint' does not exist\n" + if ! -d $mountpoint; + + sshfs_mount($scfg, $storeid); + } + + $class->SUPER::activate_storage($storeid, $scfg, $cache); + return; +} + +sub get_volume_attribute ($class, $scfg, $storeid, $volname, $attribute) { + my ($vtype) = $class->parse_volname($volname); + return if $vtype ne 'backup'; + + my $volume_path = PVE::Storage::Plugin->filesystem_path($scfg, $volname); + + if ($attribute eq 'notes') { + my $notes_path = $volume_path . $class->SUPER::NOTES_EXT; + + if (-f $notes_path) { + my $notes = file_get_contents($notes_path); + return eval { decode('UTF-8', $notes, 1) } // $notes; + } + + return ""; + } + + if ($attribute eq 'protected') { + return -e PVE::Storage::protection_file_path($volume_path) ? 1 : 0; + } + + return; +} + +sub update_volume_attribute ($class, $scfg, $storeid, $volname, $attribute, $value) { + my ($vtype, $name) = $class->parse_volname($volname); + die "only backups support attribute '$attribute'\n" if $vtype ne 'backup'; + + my $volume_path = PVE::Storage::Plugin->filesystem_path($scfg, $volname); + + if ($attribute eq 'notes') { + my $notes_path = $volume_path . $class->SUPER::NOTES_EXT; + + if (defined($value)) { + my $encoded_notes = encode('UTF-8', $value); + file_set_contents($notes_path, $encoded_notes); + } else { + unlink $notes_path or $! == ENOENT or die "could not delete notes - $!\n"; + } + + return; + } + + if ($attribute eq 'protected') { + my $protection_path = PVE::Storage::protection_file_path($volume_path); + + # Protection already set or unset + return if !((-e $protection_path) xor $value); + + if ($value) { + my $fh = IO::File->new($protection_path, O_CREAT, 0644) + or die "unable to create protection file '$protection_path' - $!\n"; + close($fh); + } else { + unlink $protection_path or $! == ENOENT + or die "could not delete protection file '$protection_path' - $!\n"; + } + + return; + } + + die "attribute '$attribute' is not supported for storage type '$scfg->{type}'\n"; +} + +1; -- 2.39.5 _______________________________________________ pve-devel mailing list pve-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel