From 3250ff481d3c8f5da4e38ab0b0c896e7608f8716 Mon Sep 17 00:00:00 2001 From: Kirari04 <103888491+Kirari04@users.noreply.github.com> Date: Tue, 27 Jan 2026 08:27:33 +0100 Subject: [PATCH] feat: SFTP configuration UI (enabled for `sftp:` URIs) with support for SSH key bootstrapping (#961) Co-authored-by: Gareth --- gen/go/v1/service.pb.go | 599 ++++++-- gen/go/v1/service_grpc.pb.go | 64 +- gen/go/v1/v1connect/service.connect.go | 52 +- go.mod | 2 + go.sum | 4 + internal/api/backresthandler.go | 386 +++++- internal/api/sftputil/sftputil.go | 210 +++ internal/api/sftputil/sftputil_test.go | 84 ++ internal/env/environment.go | 5 + proto/v1/service.proto | 36 +- test/e2e/first_run_test.go | 10 +- webui/gen/ts/v1/service_pb.ts | 189 ++- .../features/repositories/AddRepoModal.tsx | 1213 +++++++++++------ 13 files changed, 2257 insertions(+), 597 deletions(-) create mode 100644 internal/api/sftputil/sftputil.go create mode 100644 internal/api/sftputil/sftputil_test.go diff --git a/gen/go/v1/service.pb.go b/gen/go/v1/service.pb.go index caa566ba..f57c2f9d 100644 --- a/gen/go/v1/service.pb.go +++ b/gen/go/v1/service.pb.go @@ -79,7 +79,7 @@ func (x DoRepoTaskRequest_Task) Number() protoreflect.EnumNumber { // Deprecated: Use DoRepoTaskRequest_Task.Descriptor instead. func (DoRepoTaskRequest_Task) EnumDescriptor() ([]byte, []int) { - return file_v1_service_proto_rawDescGZIP(), []int{1, 0} + return file_v1_service_proto_rawDescGZIP(), []int{6, 0} } // OpSelector is a message that can be used to select operations e.g. by query. @@ -183,6 +183,306 @@ func (x *OpSelector) GetModnoGte() int64 { return 0 } +type SetupSftpRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + Port string `protobuf:"bytes,2,opt,name=port,proto3" json:"port,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + Password *string `protobuf:"bytes,4,opt,name=password,proto3,oneof" json:"password,omitempty"` // If not provided, we only generate the key and add host to known_hosts + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetupSftpRequest) Reset() { + *x = SetupSftpRequest{} + mi := &file_v1_service_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetupSftpRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetupSftpRequest) ProtoMessage() {} + +func (x *SetupSftpRequest) ProtoReflect() protoreflect.Message { + mi := &file_v1_service_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetupSftpRequest.ProtoReflect.Descriptor instead. +func (*SetupSftpRequest) Descriptor() ([]byte, []int) { + return file_v1_service_proto_rawDescGZIP(), []int{1} +} + +func (x *SetupSftpRequest) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *SetupSftpRequest) GetPort() string { + if x != nil { + return x.Port + } + return "" +} + +func (x *SetupSftpRequest) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *SetupSftpRequest) GetPassword() string { + if x != nil && x.Password != nil { + return *x.Password + } + return "" +} + +type SetupSftpResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + PublicKey string `protobuf:"bytes,1,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + KeyPath string `protobuf:"bytes,2,opt,name=key_path,json=keyPath,proto3" json:"key_path,omitempty"` + KnownHostsPath string `protobuf:"bytes,3,opt,name=known_hosts_path,json=knownHostsPath,proto3" json:"known_hosts_path,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetupSftpResponse) Reset() { + *x = SetupSftpResponse{} + mi := &file_v1_service_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetupSftpResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetupSftpResponse) ProtoMessage() {} + +func (x *SetupSftpResponse) ProtoReflect() protoreflect.Message { + mi := &file_v1_service_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetupSftpResponse.ProtoReflect.Descriptor instead. +func (*SetupSftpResponse) Descriptor() ([]byte, []int) { + return file_v1_service_proto_rawDescGZIP(), []int{2} +} + +func (x *SetupSftpResponse) GetPublicKey() string { + if x != nil { + return x.PublicKey + } + return "" +} + +func (x *SetupSftpResponse) GetKeyPath() string { + if x != nil { + return x.KeyPath + } + return "" +} + +func (x *SetupSftpResponse) GetKnownHostsPath() string { + if x != nil { + return x.KnownHostsPath + } + return "" +} + +func (x *SetupSftpResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type CheckRepoExistsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Repo *Repo `protobuf:"bytes,1,opt,name=repo,proto3" json:"repo,omitempty"` + TrustSftpHostKey bool `protobuf:"varint,2,opt,name=trust_sftp_host_key,json=trustSftpHostKey,proto3" json:"trust_sftp_host_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckRepoExistsRequest) Reset() { + *x = CheckRepoExistsRequest{} + mi := &file_v1_service_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckRepoExistsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckRepoExistsRequest) ProtoMessage() {} + +func (x *CheckRepoExistsRequest) ProtoReflect() protoreflect.Message { + mi := &file_v1_service_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CheckRepoExistsRequest.ProtoReflect.Descriptor instead. +func (*CheckRepoExistsRequest) Descriptor() ([]byte, []int) { + return file_v1_service_proto_rawDescGZIP(), []int{3} +} + +func (x *CheckRepoExistsRequest) GetRepo() *Repo { + if x != nil { + return x.Repo + } + return nil +} + +func (x *CheckRepoExistsRequest) GetTrustSftpHostKey() bool { + if x != nil { + return x.TrustSftpHostKey + } + return false +} + +type CheckRepoExistsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + HostKeyUntrusted bool `protobuf:"varint,5,opt,name=host_key_untrusted,json=hostKeyUntrusted,proto3" json:"host_key_untrusted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckRepoExistsResponse) Reset() { + *x = CheckRepoExistsResponse{} + mi := &file_v1_service_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckRepoExistsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckRepoExistsResponse) ProtoMessage() {} + +func (x *CheckRepoExistsResponse) ProtoReflect() protoreflect.Message { + mi := &file_v1_service_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CheckRepoExistsResponse.ProtoReflect.Descriptor instead. +func (*CheckRepoExistsResponse) Descriptor() ([]byte, []int) { + return file_v1_service_proto_rawDescGZIP(), []int{4} +} + +func (x *CheckRepoExistsResponse) GetExists() bool { + if x != nil { + return x.Exists + } + return false +} + +func (x *CheckRepoExistsResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *CheckRepoExistsResponse) GetHostKeyUntrusted() bool { + if x != nil { + return x.HostKeyUntrusted + } + return false +} + +type AddRepoRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Repo *Repo `protobuf:"bytes,1,opt,name=repo,proto3" json:"repo,omitempty"` + TrustSftpHostKey bool `protobuf:"varint,2,opt,name=trust_sftp_host_key,json=trustSftpHostKey,proto3" json:"trust_sftp_host_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddRepoRequest) Reset() { + *x = AddRepoRequest{} + mi := &file_v1_service_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddRepoRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddRepoRequest) ProtoMessage() {} + +func (x *AddRepoRequest) ProtoReflect() protoreflect.Message { + mi := &file_v1_service_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddRepoRequest.ProtoReflect.Descriptor instead. +func (*AddRepoRequest) Descriptor() ([]byte, []int) { + return file_v1_service_proto_rawDescGZIP(), []int{5} +} + +func (x *AddRepoRequest) GetRepo() *Repo { + if x != nil { + return x.Repo + } + return nil +} + +func (x *AddRepoRequest) GetTrustSftpHostKey() bool { + if x != nil { + return x.TrustSftpHostKey + } + return false +} + type DoRepoTaskRequest struct { state protoimpl.MessageState `protogen:"open.v1"` RepoId string `protobuf:"bytes,1,opt,name=repo_id,json=repoId,proto3" json:"repo_id,omitempty"` @@ -193,7 +493,7 @@ type DoRepoTaskRequest struct { func (x *DoRepoTaskRequest) Reset() { *x = DoRepoTaskRequest{} - mi := &file_v1_service_proto_msgTypes[1] + mi := &file_v1_service_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -205,7 +505,7 @@ func (x *DoRepoTaskRequest) String() string { func (*DoRepoTaskRequest) ProtoMessage() {} func (x *DoRepoTaskRequest) ProtoReflect() protoreflect.Message { - mi := &file_v1_service_proto_msgTypes[1] + mi := &file_v1_service_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -218,7 +518,7 @@ func (x *DoRepoTaskRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DoRepoTaskRequest.ProtoReflect.Descriptor instead. func (*DoRepoTaskRequest) Descriptor() ([]byte, []int) { - return file_v1_service_proto_rawDescGZIP(), []int{1} + return file_v1_service_proto_rawDescGZIP(), []int{6} } func (x *DoRepoTaskRequest) GetRepoId() string { @@ -245,7 +545,7 @@ type ClearHistoryRequest struct { func (x *ClearHistoryRequest) Reset() { *x = ClearHistoryRequest{} - mi := &file_v1_service_proto_msgTypes[2] + mi := &file_v1_service_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -257,7 +557,7 @@ func (x *ClearHistoryRequest) String() string { func (*ClearHistoryRequest) ProtoMessage() {} func (x *ClearHistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_v1_service_proto_msgTypes[2] + mi := &file_v1_service_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -270,7 +570,7 @@ func (x *ClearHistoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearHistoryRequest.ProtoReflect.Descriptor instead. func (*ClearHistoryRequest) Descriptor() ([]byte, []int) { - return file_v1_service_proto_rawDescGZIP(), []int{2} + return file_v1_service_proto_rawDescGZIP(), []int{7} } func (x *ClearHistoryRequest) GetSelector() *OpSelector { @@ -298,7 +598,7 @@ type ForgetRequest struct { func (x *ForgetRequest) Reset() { *x = ForgetRequest{} - mi := &file_v1_service_proto_msgTypes[3] + mi := &file_v1_service_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -310,7 +610,7 @@ func (x *ForgetRequest) String() string { func (*ForgetRequest) ProtoMessage() {} func (x *ForgetRequest) ProtoReflect() protoreflect.Message { - mi := &file_v1_service_proto_msgTypes[3] + mi := &file_v1_service_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -323,7 +623,7 @@ func (x *ForgetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ForgetRequest.ProtoReflect.Descriptor instead. func (*ForgetRequest) Descriptor() ([]byte, []int) { - return file_v1_service_proto_rawDescGZIP(), []int{3} + return file_v1_service_proto_rawDescGZIP(), []int{8} } func (x *ForgetRequest) GetRepoId() string { @@ -357,7 +657,7 @@ type ListSnapshotsRequest struct { func (x *ListSnapshotsRequest) Reset() { *x = ListSnapshotsRequest{} - mi := &file_v1_service_proto_msgTypes[4] + mi := &file_v1_service_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -369,7 +669,7 @@ func (x *ListSnapshotsRequest) String() string { func (*ListSnapshotsRequest) ProtoMessage() {} func (x *ListSnapshotsRequest) ProtoReflect() protoreflect.Message { - mi := &file_v1_service_proto_msgTypes[4] + mi := &file_v1_service_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -382,7 +682,7 @@ func (x *ListSnapshotsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSnapshotsRequest.ProtoReflect.Descriptor instead. func (*ListSnapshotsRequest) Descriptor() ([]byte, []int) { - return file_v1_service_proto_rawDescGZIP(), []int{4} + return file_v1_service_proto_rawDescGZIP(), []int{9} } func (x *ListSnapshotsRequest) GetRepoId() string { @@ -409,7 +709,7 @@ type GetOperationsRequest struct { func (x *GetOperationsRequest) Reset() { *x = GetOperationsRequest{} - mi := &file_v1_service_proto_msgTypes[5] + mi := &file_v1_service_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -421,7 +721,7 @@ func (x *GetOperationsRequest) String() string { func (*GetOperationsRequest) ProtoMessage() {} func (x *GetOperationsRequest) ProtoReflect() protoreflect.Message { - mi := &file_v1_service_proto_msgTypes[5] + mi := &file_v1_service_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -434,7 +734,7 @@ func (x *GetOperationsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetOperationsRequest.ProtoReflect.Descriptor instead. func (*GetOperationsRequest) Descriptor() ([]byte, []int) { - return file_v1_service_proto_rawDescGZIP(), []int{5} + return file_v1_service_proto_rawDescGZIP(), []int{10} } func (x *GetOperationsRequest) GetSelector() *OpSelector { @@ -464,7 +764,7 @@ type RestoreSnapshotRequest struct { func (x *RestoreSnapshotRequest) Reset() { *x = RestoreSnapshotRequest{} - mi := &file_v1_service_proto_msgTypes[6] + mi := &file_v1_service_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -476,7 +776,7 @@ func (x *RestoreSnapshotRequest) String() string { func (*RestoreSnapshotRequest) ProtoMessage() {} func (x *RestoreSnapshotRequest) ProtoReflect() protoreflect.Message { - mi := &file_v1_service_proto_msgTypes[6] + mi := &file_v1_service_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -489,7 +789,7 @@ func (x *RestoreSnapshotRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreSnapshotRequest.ProtoReflect.Descriptor instead. func (*RestoreSnapshotRequest) Descriptor() ([]byte, []int) { - return file_v1_service_proto_rawDescGZIP(), []int{6} + return file_v1_service_proto_rawDescGZIP(), []int{11} } func (x *RestoreSnapshotRequest) GetPlanId() string { @@ -538,7 +838,7 @@ type ListSnapshotFilesRequest struct { func (x *ListSnapshotFilesRequest) Reset() { *x = ListSnapshotFilesRequest{} - mi := &file_v1_service_proto_msgTypes[7] + mi := &file_v1_service_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -550,7 +850,7 @@ func (x *ListSnapshotFilesRequest) String() string { func (*ListSnapshotFilesRequest) ProtoMessage() {} func (x *ListSnapshotFilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_v1_service_proto_msgTypes[7] + mi := &file_v1_service_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -563,7 +863,7 @@ func (x *ListSnapshotFilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSnapshotFilesRequest.ProtoReflect.Descriptor instead. func (*ListSnapshotFilesRequest) Descriptor() ([]byte, []int) { - return file_v1_service_proto_rawDescGZIP(), []int{7} + return file_v1_service_proto_rawDescGZIP(), []int{12} } func (x *ListSnapshotFilesRequest) GetRepoGuid() string { @@ -597,7 +897,7 @@ type ListSnapshotFilesResponse struct { func (x *ListSnapshotFilesResponse) Reset() { *x = ListSnapshotFilesResponse{} - mi := &file_v1_service_proto_msgTypes[8] + mi := &file_v1_service_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -609,7 +909,7 @@ func (x *ListSnapshotFilesResponse) String() string { func (*ListSnapshotFilesResponse) ProtoMessage() {} func (x *ListSnapshotFilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_v1_service_proto_msgTypes[8] + mi := &file_v1_service_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -622,7 +922,7 @@ func (x *ListSnapshotFilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSnapshotFilesResponse.ProtoReflect.Descriptor instead. func (*ListSnapshotFilesResponse) Descriptor() ([]byte, []int) { - return file_v1_service_proto_rawDescGZIP(), []int{8} + return file_v1_service_proto_rawDescGZIP(), []int{13} } func (x *ListSnapshotFilesResponse) GetPath() string { @@ -648,7 +948,7 @@ type LogDataRequest struct { func (x *LogDataRequest) Reset() { *x = LogDataRequest{} - mi := &file_v1_service_proto_msgTypes[9] + mi := &file_v1_service_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -660,7 +960,7 @@ func (x *LogDataRequest) String() string { func (*LogDataRequest) ProtoMessage() {} func (x *LogDataRequest) ProtoReflect() protoreflect.Message { - mi := &file_v1_service_proto_msgTypes[9] + mi := &file_v1_service_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -673,7 +973,7 @@ func (x *LogDataRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LogDataRequest.ProtoReflect.Descriptor instead. func (*LogDataRequest) Descriptor() ([]byte, []int) { - return file_v1_service_proto_rawDescGZIP(), []int{9} + return file_v1_service_proto_rawDescGZIP(), []int{14} } func (x *LogDataRequest) GetRef() string { @@ -693,7 +993,7 @@ type GetDownloadURLRequest struct { func (x *GetDownloadURLRequest) Reset() { *x = GetDownloadURLRequest{} - mi := &file_v1_service_proto_msgTypes[10] + mi := &file_v1_service_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -705,7 +1005,7 @@ func (x *GetDownloadURLRequest) String() string { func (*GetDownloadURLRequest) ProtoMessage() {} func (x *GetDownloadURLRequest) ProtoReflect() protoreflect.Message { - mi := &file_v1_service_proto_msgTypes[10] + mi := &file_v1_service_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -718,7 +1018,7 @@ func (x *GetDownloadURLRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDownloadURLRequest.ProtoReflect.Descriptor instead. func (*GetDownloadURLRequest) Descriptor() ([]byte, []int) { - return file_v1_service_proto_rawDescGZIP(), []int{10} + return file_v1_service_proto_rawDescGZIP(), []int{15} } func (x *GetDownloadURLRequest) GetOpId() int64 { @@ -753,7 +1053,7 @@ type LsEntry struct { func (x *LsEntry) Reset() { *x = LsEntry{} - mi := &file_v1_service_proto_msgTypes[11] + mi := &file_v1_service_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -765,7 +1065,7 @@ func (x *LsEntry) String() string { func (*LsEntry) ProtoMessage() {} func (x *LsEntry) ProtoReflect() protoreflect.Message { - mi := &file_v1_service_proto_msgTypes[11] + mi := &file_v1_service_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -778,7 +1078,7 @@ func (x *LsEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use LsEntry.ProtoReflect.Descriptor instead. func (*LsEntry) Descriptor() ([]byte, []int) { - return file_v1_service_proto_rawDescGZIP(), []int{11} + return file_v1_service_proto_rawDescGZIP(), []int{16} } func (x *LsEntry) GetName() string { @@ -861,7 +1161,7 @@ type RunCommandRequest struct { func (x *RunCommandRequest) Reset() { *x = RunCommandRequest{} - mi := &file_v1_service_proto_msgTypes[12] + mi := &file_v1_service_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -873,7 +1173,7 @@ func (x *RunCommandRequest) String() string { func (*RunCommandRequest) ProtoMessage() {} func (x *RunCommandRequest) ProtoReflect() protoreflect.Message { - mi := &file_v1_service_proto_msgTypes[12] + mi := &file_v1_service_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -886,7 +1186,7 @@ func (x *RunCommandRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RunCommandRequest.ProtoReflect.Descriptor instead. func (*RunCommandRequest) Descriptor() ([]byte, []int) { - return file_v1_service_proto_rawDescGZIP(), []int{12} + return file_v1_service_proto_rawDescGZIP(), []int{17} } func (x *RunCommandRequest) GetRepoId() string { @@ -915,7 +1215,7 @@ type SummaryDashboardResponse struct { func (x *SummaryDashboardResponse) Reset() { *x = SummaryDashboardResponse{} - mi := &file_v1_service_proto_msgTypes[13] + mi := &file_v1_service_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -927,7 +1227,7 @@ func (x *SummaryDashboardResponse) String() string { func (*SummaryDashboardResponse) ProtoMessage() {} func (x *SummaryDashboardResponse) ProtoReflect() protoreflect.Message { - mi := &file_v1_service_proto_msgTypes[13] + mi := &file_v1_service_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -940,7 +1240,7 @@ func (x *SummaryDashboardResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SummaryDashboardResponse.ProtoReflect.Descriptor instead. func (*SummaryDashboardResponse) Descriptor() ([]byte, []int) { - return file_v1_service_proto_rawDescGZIP(), []int{13} + return file_v1_service_proto_rawDescGZIP(), []int{18} } func (x *SummaryDashboardResponse) GetRepoSummaries() []*SummaryDashboardResponse_Summary { @@ -991,7 +1291,7 @@ type SummaryDashboardResponse_Summary struct { func (x *SummaryDashboardResponse_Summary) Reset() { *x = SummaryDashboardResponse_Summary{} - mi := &file_v1_service_proto_msgTypes[14] + mi := &file_v1_service_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1003,7 +1303,7 @@ func (x *SummaryDashboardResponse_Summary) String() string { func (*SummaryDashboardResponse_Summary) ProtoMessage() {} func (x *SummaryDashboardResponse_Summary) ProtoReflect() protoreflect.Message { - mi := &file_v1_service_proto_msgTypes[14] + mi := &file_v1_service_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1016,7 +1316,7 @@ func (x *SummaryDashboardResponse_Summary) ProtoReflect() protoreflect.Message { // Deprecated: Use SummaryDashboardResponse_Summary.ProtoReflect.Descriptor instead. func (*SummaryDashboardResponse_Summary) Descriptor() ([]byte, []int) { - return file_v1_service_proto_rawDescGZIP(), []int{13, 0} + return file_v1_service_proto_rawDescGZIP(), []int{18, 0} } func (x *SummaryDashboardResponse_Summary) GetId() string { @@ -1109,7 +1409,7 @@ type SummaryDashboardResponse_BackupChart struct { func (x *SummaryDashboardResponse_BackupChart) Reset() { *x = SummaryDashboardResponse_BackupChart{} - mi := &file_v1_service_proto_msgTypes[15] + mi := &file_v1_service_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1121,7 +1421,7 @@ func (x *SummaryDashboardResponse_BackupChart) String() string { func (*SummaryDashboardResponse_BackupChart) ProtoMessage() {} func (x *SummaryDashboardResponse_BackupChart) ProtoReflect() protoreflect.Message { - mi := &file_v1_service_proto_msgTypes[15] + mi := &file_v1_service_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1134,7 +1434,7 @@ func (x *SummaryDashboardResponse_BackupChart) ProtoReflect() protoreflect.Messa // Deprecated: Use SummaryDashboardResponse_BackupChart.ProtoReflect.Descriptor instead. func (*SummaryDashboardResponse_BackupChart) Descriptor() ([]byte, []int) { - return file_v1_service_proto_rawDescGZIP(), []int{13, 1} + return file_v1_service_proto_rawDescGZIP(), []int{18, 1} } func (x *SummaryDashboardResponse_BackupChart) GetFlowId() []int64 { @@ -1199,7 +1499,29 @@ const file_v1_service_proto_rawDesc = "" + "\n" + "\b_flow_idB\f\n" + "\n" + - "_modno_gte\"\xce\x01\n" + + "_modno_gte\"\x84\x01\n" + + "\x10SetupSftpRequest\x12\x12\n" + + "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\x02 \x01(\tR\x04port\x12\x1a\n" + + "\busername\x18\x03 \x01(\tR\busername\x12\x1f\n" + + "\bpassword\x18\x04 \x01(\tH\x00R\bpassword\x88\x01\x01B\v\n" + + "\t_password\"\x8d\x01\n" + + "\x11SetupSftpResponse\x12\x1d\n" + + "\n" + + "public_key\x18\x01 \x01(\tR\tpublicKey\x12\x19\n" + + "\bkey_path\x18\x02 \x01(\tR\akeyPath\x12(\n" + + "\x10known_hosts_path\x18\x03 \x01(\tR\x0eknownHostsPath\x12\x14\n" + + "\x05error\x18\x04 \x01(\tR\x05error\"e\n" + + "\x16CheckRepoExistsRequest\x12\x1c\n" + + "\x04repo\x18\x01 \x01(\v2\b.v1.RepoR\x04repo\x12-\n" + + "\x13trust_sftp_host_key\x18\x02 \x01(\bR\x10trustSftpHostKey\"u\n" + + "\x17CheckRepoExistsResponse\x12\x16\n" + + "\x06exists\x18\x01 \x01(\bR\x06exists\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\x12,\n" + + "\x12host_key_untrusted\x18\x05 \x01(\bR\x10hostKeyUntrusted\"]\n" + + "\x0eAddRepoRequest\x12\x1c\n" + + "\x04repo\x18\x01 \x01(\v2\b.v1.RepoR\x04repo\x12-\n" + + "\x13trust_sftp_host_key\x18\x02 \x01(\bR\x10trustSftpHostKey\"\xce\x01\n" + "\x11DoRepoTaskRequest\x12\x17\n" + "\arepo_id\x18\x01 \x01(\tR\x06repoId\x12.\n" + "\x04task\x18\x02 \x01(\x0e2\x1a.v1.DoRepoTaskRequest.TaskR\x04task\"p\n" + @@ -1290,15 +1612,17 @@ const file_v1_service_proto_rawDesc = "" + "durationMs\x12+\n" + "\x06status\x18\x04 \x03(\x0e2\x13.v1.OperationStatusR\x06status\x12\x1f\n" + "\vbytes_added\x18\x05 \x03(\x03R\n" + - "bytesAdded2\xaf\t\n" + + "bytesAdded2\x92\n" + + "\n" + "\bBackrest\x121\n" + "\tGetConfig\x12\x16.google.protobuf.Empty\x1a\n" + ".v1.Config\"\x00\x12%\n" + "\tSetConfig\x12\n" + ".v1.Config\x1a\n" + - ".v1.Config\"\x00\x12/\n" + - "\x0fCheckRepoExists\x12\b.v1.Repo\x1a\x10.types.BoolValue\"\x00\x12!\n" + - "\aAddRepo\x12\b.v1.Repo\x1a\n" + + ".v1.Config\"\x00\x12:\n" + + "\tSetupSftp\x12\x14.v1.SetupSftpRequest\x1a\x15.v1.SetupSftpResponse\"\x00\x12L\n" + + "\x0fCheckRepoExists\x12\x1a.v1.CheckRepoExistsRequest\x1a\x1b.v1.CheckRepoExistsResponse\"\x00\x12+\n" + + "\aAddRepo\x12\x12.v1.AddRepoRequest\x1a\n" + ".v1.Config\"\x00\x12.\n" + "\n" + "RemoveRepo\x12\x12.types.StringValue\x1a\n" + @@ -1334,92 +1658,100 @@ func file_v1_service_proto_rawDescGZIP() []byte { } var file_v1_service_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_v1_service_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_v1_service_proto_msgTypes = make([]protoimpl.MessageInfo, 21) var file_v1_service_proto_goTypes = []any{ (DoRepoTaskRequest_Task)(0), // 0: v1.DoRepoTaskRequest.Task (*OpSelector)(nil), // 1: v1.OpSelector - (*DoRepoTaskRequest)(nil), // 2: v1.DoRepoTaskRequest - (*ClearHistoryRequest)(nil), // 3: v1.ClearHistoryRequest - (*ForgetRequest)(nil), // 4: v1.ForgetRequest - (*ListSnapshotsRequest)(nil), // 5: v1.ListSnapshotsRequest - (*GetOperationsRequest)(nil), // 6: v1.GetOperationsRequest - (*RestoreSnapshotRequest)(nil), // 7: v1.RestoreSnapshotRequest - (*ListSnapshotFilesRequest)(nil), // 8: v1.ListSnapshotFilesRequest - (*ListSnapshotFilesResponse)(nil), // 9: v1.ListSnapshotFilesResponse - (*LogDataRequest)(nil), // 10: v1.LogDataRequest - (*GetDownloadURLRequest)(nil), // 11: v1.GetDownloadURLRequest - (*LsEntry)(nil), // 12: v1.LsEntry - (*RunCommandRequest)(nil), // 13: v1.RunCommandRequest - (*SummaryDashboardResponse)(nil), // 14: v1.SummaryDashboardResponse - (*SummaryDashboardResponse_Summary)(nil), // 15: v1.SummaryDashboardResponse.Summary - (*SummaryDashboardResponse_BackupChart)(nil), // 16: v1.SummaryDashboardResponse.BackupChart - (OperationStatus)(0), // 17: v1.OperationStatus - (*emptypb.Empty)(nil), // 18: google.protobuf.Empty - (*Config)(nil), // 19: v1.Config - (*Repo)(nil), // 20: v1.Repo - (*types.StringValue)(nil), // 21: types.StringValue - (*types.Int64Value)(nil), // 22: types.Int64Value - (*types.BoolValue)(nil), // 23: types.BoolValue - (*OperationEvent)(nil), // 24: v1.OperationEvent - (*OperationList)(nil), // 25: v1.OperationList - (*ResticSnapshotList)(nil), // 26: v1.ResticSnapshotList - (*types.BytesValue)(nil), // 27: types.BytesValue - (*types.StringList)(nil), // 28: types.StringList + (*SetupSftpRequest)(nil), // 2: v1.SetupSftpRequest + (*SetupSftpResponse)(nil), // 3: v1.SetupSftpResponse + (*CheckRepoExistsRequest)(nil), // 4: v1.CheckRepoExistsRequest + (*CheckRepoExistsResponse)(nil), // 5: v1.CheckRepoExistsResponse + (*AddRepoRequest)(nil), // 6: v1.AddRepoRequest + (*DoRepoTaskRequest)(nil), // 7: v1.DoRepoTaskRequest + (*ClearHistoryRequest)(nil), // 8: v1.ClearHistoryRequest + (*ForgetRequest)(nil), // 9: v1.ForgetRequest + (*ListSnapshotsRequest)(nil), // 10: v1.ListSnapshotsRequest + (*GetOperationsRequest)(nil), // 11: v1.GetOperationsRequest + (*RestoreSnapshotRequest)(nil), // 12: v1.RestoreSnapshotRequest + (*ListSnapshotFilesRequest)(nil), // 13: v1.ListSnapshotFilesRequest + (*ListSnapshotFilesResponse)(nil), // 14: v1.ListSnapshotFilesResponse + (*LogDataRequest)(nil), // 15: v1.LogDataRequest + (*GetDownloadURLRequest)(nil), // 16: v1.GetDownloadURLRequest + (*LsEntry)(nil), // 17: v1.LsEntry + (*RunCommandRequest)(nil), // 18: v1.RunCommandRequest + (*SummaryDashboardResponse)(nil), // 19: v1.SummaryDashboardResponse + (*SummaryDashboardResponse_Summary)(nil), // 20: v1.SummaryDashboardResponse.Summary + (*SummaryDashboardResponse_BackupChart)(nil), // 21: v1.SummaryDashboardResponse.BackupChart + (*Repo)(nil), // 22: v1.Repo + (OperationStatus)(0), // 23: v1.OperationStatus + (*emptypb.Empty)(nil), // 24: google.protobuf.Empty + (*Config)(nil), // 25: v1.Config + (*types.StringValue)(nil), // 26: types.StringValue + (*types.Int64Value)(nil), // 27: types.Int64Value + (*OperationEvent)(nil), // 28: v1.OperationEvent + (*OperationList)(nil), // 29: v1.OperationList + (*ResticSnapshotList)(nil), // 30: v1.ResticSnapshotList + (*types.BytesValue)(nil), // 31: types.BytesValue + (*types.StringList)(nil), // 32: types.StringList } var file_v1_service_proto_depIdxs = []int32{ - 0, // 0: v1.DoRepoTaskRequest.task:type_name -> v1.DoRepoTaskRequest.Task - 1, // 1: v1.ClearHistoryRequest.selector:type_name -> v1.OpSelector - 1, // 2: v1.GetOperationsRequest.selector:type_name -> v1.OpSelector - 12, // 3: v1.ListSnapshotFilesResponse.entries:type_name -> v1.LsEntry - 15, // 4: v1.SummaryDashboardResponse.repo_summaries:type_name -> v1.SummaryDashboardResponse.Summary - 15, // 5: v1.SummaryDashboardResponse.plan_summaries:type_name -> v1.SummaryDashboardResponse.Summary - 16, // 6: v1.SummaryDashboardResponse.Summary.recent_backups:type_name -> v1.SummaryDashboardResponse.BackupChart - 17, // 7: v1.SummaryDashboardResponse.BackupChart.status:type_name -> v1.OperationStatus - 18, // 8: v1.Backrest.GetConfig:input_type -> google.protobuf.Empty - 19, // 9: v1.Backrest.SetConfig:input_type -> v1.Config - 20, // 10: v1.Backrest.CheckRepoExists:input_type -> v1.Repo - 20, // 11: v1.Backrest.AddRepo:input_type -> v1.Repo - 21, // 12: v1.Backrest.RemoveRepo:input_type -> types.StringValue - 18, // 13: v1.Backrest.GetOperationEvents:input_type -> google.protobuf.Empty - 6, // 14: v1.Backrest.GetOperations:input_type -> v1.GetOperationsRequest - 5, // 15: v1.Backrest.ListSnapshots:input_type -> v1.ListSnapshotsRequest - 8, // 16: v1.Backrest.ListSnapshotFiles:input_type -> v1.ListSnapshotFilesRequest - 21, // 17: v1.Backrest.Backup:input_type -> types.StringValue - 2, // 18: v1.Backrest.DoRepoTask:input_type -> v1.DoRepoTaskRequest - 4, // 19: v1.Backrest.Forget:input_type -> v1.ForgetRequest - 7, // 20: v1.Backrest.Restore:input_type -> v1.RestoreSnapshotRequest - 22, // 21: v1.Backrest.Cancel:input_type -> types.Int64Value - 10, // 22: v1.Backrest.GetLogs:input_type -> v1.LogDataRequest - 13, // 23: v1.Backrest.RunCommand:input_type -> v1.RunCommandRequest - 11, // 24: v1.Backrest.GetDownloadURL:input_type -> v1.GetDownloadURLRequest - 3, // 25: v1.Backrest.ClearHistory:input_type -> v1.ClearHistoryRequest - 21, // 26: v1.Backrest.PathAutocomplete:input_type -> types.StringValue - 18, // 27: v1.Backrest.GetSummaryDashboard:input_type -> google.protobuf.Empty - 19, // 28: v1.Backrest.GetConfig:output_type -> v1.Config - 19, // 29: v1.Backrest.SetConfig:output_type -> v1.Config - 23, // 30: v1.Backrest.CheckRepoExists:output_type -> types.BoolValue - 19, // 31: v1.Backrest.AddRepo:output_type -> v1.Config - 19, // 32: v1.Backrest.RemoveRepo:output_type -> v1.Config - 24, // 33: v1.Backrest.GetOperationEvents:output_type -> v1.OperationEvent - 25, // 34: v1.Backrest.GetOperations:output_type -> v1.OperationList - 26, // 35: v1.Backrest.ListSnapshots:output_type -> v1.ResticSnapshotList - 9, // 36: v1.Backrest.ListSnapshotFiles:output_type -> v1.ListSnapshotFilesResponse - 18, // 37: v1.Backrest.Backup:output_type -> google.protobuf.Empty - 18, // 38: v1.Backrest.DoRepoTask:output_type -> google.protobuf.Empty - 18, // 39: v1.Backrest.Forget:output_type -> google.protobuf.Empty - 18, // 40: v1.Backrest.Restore:output_type -> google.protobuf.Empty - 18, // 41: v1.Backrest.Cancel:output_type -> google.protobuf.Empty - 27, // 42: v1.Backrest.GetLogs:output_type -> types.BytesValue - 22, // 43: v1.Backrest.RunCommand:output_type -> types.Int64Value - 21, // 44: v1.Backrest.GetDownloadURL:output_type -> types.StringValue - 18, // 45: v1.Backrest.ClearHistory:output_type -> google.protobuf.Empty - 28, // 46: v1.Backrest.PathAutocomplete:output_type -> types.StringList - 14, // 47: v1.Backrest.GetSummaryDashboard:output_type -> v1.SummaryDashboardResponse - 28, // [28:48] is the sub-list for method output_type - 8, // [8:28] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 22, // 0: v1.CheckRepoExistsRequest.repo:type_name -> v1.Repo + 22, // 1: v1.AddRepoRequest.repo:type_name -> v1.Repo + 0, // 2: v1.DoRepoTaskRequest.task:type_name -> v1.DoRepoTaskRequest.Task + 1, // 3: v1.ClearHistoryRequest.selector:type_name -> v1.OpSelector + 1, // 4: v1.GetOperationsRequest.selector:type_name -> v1.OpSelector + 17, // 5: v1.ListSnapshotFilesResponse.entries:type_name -> v1.LsEntry + 20, // 6: v1.SummaryDashboardResponse.repo_summaries:type_name -> v1.SummaryDashboardResponse.Summary + 20, // 7: v1.SummaryDashboardResponse.plan_summaries:type_name -> v1.SummaryDashboardResponse.Summary + 21, // 8: v1.SummaryDashboardResponse.Summary.recent_backups:type_name -> v1.SummaryDashboardResponse.BackupChart + 23, // 9: v1.SummaryDashboardResponse.BackupChart.status:type_name -> v1.OperationStatus + 24, // 10: v1.Backrest.GetConfig:input_type -> google.protobuf.Empty + 25, // 11: v1.Backrest.SetConfig:input_type -> v1.Config + 2, // 12: v1.Backrest.SetupSftp:input_type -> v1.SetupSftpRequest + 4, // 13: v1.Backrest.CheckRepoExists:input_type -> v1.CheckRepoExistsRequest + 6, // 14: v1.Backrest.AddRepo:input_type -> v1.AddRepoRequest + 26, // 15: v1.Backrest.RemoveRepo:input_type -> types.StringValue + 24, // 16: v1.Backrest.GetOperationEvents:input_type -> google.protobuf.Empty + 11, // 17: v1.Backrest.GetOperations:input_type -> v1.GetOperationsRequest + 10, // 18: v1.Backrest.ListSnapshots:input_type -> v1.ListSnapshotsRequest + 13, // 19: v1.Backrest.ListSnapshotFiles:input_type -> v1.ListSnapshotFilesRequest + 26, // 20: v1.Backrest.Backup:input_type -> types.StringValue + 7, // 21: v1.Backrest.DoRepoTask:input_type -> v1.DoRepoTaskRequest + 9, // 22: v1.Backrest.Forget:input_type -> v1.ForgetRequest + 12, // 23: v1.Backrest.Restore:input_type -> v1.RestoreSnapshotRequest + 27, // 24: v1.Backrest.Cancel:input_type -> types.Int64Value + 15, // 25: v1.Backrest.GetLogs:input_type -> v1.LogDataRequest + 18, // 26: v1.Backrest.RunCommand:input_type -> v1.RunCommandRequest + 16, // 27: v1.Backrest.GetDownloadURL:input_type -> v1.GetDownloadURLRequest + 8, // 28: v1.Backrest.ClearHistory:input_type -> v1.ClearHistoryRequest + 26, // 29: v1.Backrest.PathAutocomplete:input_type -> types.StringValue + 24, // 30: v1.Backrest.GetSummaryDashboard:input_type -> google.protobuf.Empty + 25, // 31: v1.Backrest.GetConfig:output_type -> v1.Config + 25, // 32: v1.Backrest.SetConfig:output_type -> v1.Config + 3, // 33: v1.Backrest.SetupSftp:output_type -> v1.SetupSftpResponse + 5, // 34: v1.Backrest.CheckRepoExists:output_type -> v1.CheckRepoExistsResponse + 25, // 35: v1.Backrest.AddRepo:output_type -> v1.Config + 25, // 36: v1.Backrest.RemoveRepo:output_type -> v1.Config + 28, // 37: v1.Backrest.GetOperationEvents:output_type -> v1.OperationEvent + 29, // 38: v1.Backrest.GetOperations:output_type -> v1.OperationList + 30, // 39: v1.Backrest.ListSnapshots:output_type -> v1.ResticSnapshotList + 14, // 40: v1.Backrest.ListSnapshotFiles:output_type -> v1.ListSnapshotFilesResponse + 24, // 41: v1.Backrest.Backup:output_type -> google.protobuf.Empty + 24, // 42: v1.Backrest.DoRepoTask:output_type -> google.protobuf.Empty + 24, // 43: v1.Backrest.Forget:output_type -> google.protobuf.Empty + 24, // 44: v1.Backrest.Restore:output_type -> google.protobuf.Empty + 24, // 45: v1.Backrest.Cancel:output_type -> google.protobuf.Empty + 31, // 46: v1.Backrest.GetLogs:output_type -> types.BytesValue + 27, // 47: v1.Backrest.RunCommand:output_type -> types.Int64Value + 26, // 48: v1.Backrest.GetDownloadURL:output_type -> types.StringValue + 24, // 49: v1.Backrest.ClearHistory:output_type -> google.protobuf.Empty + 32, // 50: v1.Backrest.PathAutocomplete:output_type -> types.StringList + 19, // 51: v1.Backrest.GetSummaryDashboard:output_type -> v1.SummaryDashboardResponse + 31, // [31:52] is the sub-list for method output_type + 10, // [10:31] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_v1_service_proto_init() } @@ -1431,13 +1763,14 @@ func file_v1_service_proto_init() { file_v1_restic_proto_init() file_v1_operations_proto_init() file_v1_service_proto_msgTypes[0].OneofWrappers = []any{} + file_v1_service_proto_msgTypes[1].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_service_proto_rawDesc), len(file_v1_service_proto_rawDesc)), NumEnums: 1, - NumMessages: 16, + NumMessages: 21, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/go/v1/service_grpc.pb.go b/gen/go/v1/service_grpc.pb.go index a174a5cd..28afdce4 100644 --- a/gen/go/v1/service_grpc.pb.go +++ b/gen/go/v1/service_grpc.pb.go @@ -23,6 +23,7 @@ const _ = grpc.SupportPackageIsVersion9 const ( Backrest_GetConfig_FullMethodName = "/v1.Backrest/GetConfig" Backrest_SetConfig_FullMethodName = "/v1.Backrest/SetConfig" + Backrest_SetupSftp_FullMethodName = "/v1.Backrest/SetupSftp" Backrest_CheckRepoExists_FullMethodName = "/v1.Backrest/CheckRepoExists" Backrest_AddRepo_FullMethodName = "/v1.Backrest/AddRepo" Backrest_RemoveRepo_FullMethodName = "/v1.Backrest/RemoveRepo" @@ -49,8 +50,9 @@ const ( type BackrestClient interface { GetConfig(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*Config, error) SetConfig(ctx context.Context, in *Config, opts ...grpc.CallOption) (*Config, error) - CheckRepoExists(ctx context.Context, in *Repo, opts ...grpc.CallOption) (*types.BoolValue, error) - AddRepo(ctx context.Context, in *Repo, opts ...grpc.CallOption) (*Config, error) + SetupSftp(ctx context.Context, in *SetupSftpRequest, opts ...grpc.CallOption) (*SetupSftpResponse, error) + CheckRepoExists(ctx context.Context, in *CheckRepoExistsRequest, opts ...grpc.CallOption) (*CheckRepoExistsResponse, error) + AddRepo(ctx context.Context, in *AddRepoRequest, opts ...grpc.CallOption) (*Config, error) RemoveRepo(ctx context.Context, in *types.StringValue, opts ...grpc.CallOption) (*Config, error) GetOperationEvents(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[OperationEvent], error) GetOperations(ctx context.Context, in *GetOperationsRequest, opts ...grpc.CallOption) (*OperationList, error) @@ -108,9 +110,19 @@ func (c *backrestClient) SetConfig(ctx context.Context, in *Config, opts ...grpc return out, nil } -func (c *backrestClient) CheckRepoExists(ctx context.Context, in *Repo, opts ...grpc.CallOption) (*types.BoolValue, error) { +func (c *backrestClient) SetupSftp(ctx context.Context, in *SetupSftpRequest, opts ...grpc.CallOption) (*SetupSftpResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(types.BoolValue) + out := new(SetupSftpResponse) + err := c.cc.Invoke(ctx, Backrest_SetupSftp_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *backrestClient) CheckRepoExists(ctx context.Context, in *CheckRepoExistsRequest, opts ...grpc.CallOption) (*CheckRepoExistsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CheckRepoExistsResponse) err := c.cc.Invoke(ctx, Backrest_CheckRepoExists_FullMethodName, in, out, cOpts...) if err != nil { return nil, err @@ -118,7 +130,7 @@ func (c *backrestClient) CheckRepoExists(ctx context.Context, in *Repo, opts ... return out, nil } -func (c *backrestClient) AddRepo(ctx context.Context, in *Repo, opts ...grpc.CallOption) (*Config, error) { +func (c *backrestClient) AddRepo(ctx context.Context, in *AddRepoRequest, opts ...grpc.CallOption) (*Config, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Config) err := c.cc.Invoke(ctx, Backrest_AddRepo_FullMethodName, in, out, cOpts...) @@ -312,8 +324,9 @@ func (c *backrestClient) GetSummaryDashboard(ctx context.Context, in *emptypb.Em type BackrestServer interface { GetConfig(context.Context, *emptypb.Empty) (*Config, error) SetConfig(context.Context, *Config) (*Config, error) - CheckRepoExists(context.Context, *Repo) (*types.BoolValue, error) - AddRepo(context.Context, *Repo) (*Config, error) + SetupSftp(context.Context, *SetupSftpRequest) (*SetupSftpResponse, error) + CheckRepoExists(context.Context, *CheckRepoExistsRequest) (*CheckRepoExistsResponse, error) + AddRepo(context.Context, *AddRepoRequest) (*Config, error) RemoveRepo(context.Context, *types.StringValue) (*Config, error) GetOperationEvents(*emptypb.Empty, grpc.ServerStreamingServer[OperationEvent]) error GetOperations(context.Context, *GetOperationsRequest) (*OperationList, error) @@ -357,10 +370,13 @@ func (UnimplementedBackrestServer) GetConfig(context.Context, *emptypb.Empty) (* func (UnimplementedBackrestServer) SetConfig(context.Context, *Config) (*Config, error) { return nil, status.Errorf(codes.Unimplemented, "method SetConfig not implemented") } -func (UnimplementedBackrestServer) CheckRepoExists(context.Context, *Repo) (*types.BoolValue, error) { +func (UnimplementedBackrestServer) SetupSftp(context.Context, *SetupSftpRequest) (*SetupSftpResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SetupSftp not implemented") +} +func (UnimplementedBackrestServer) CheckRepoExists(context.Context, *CheckRepoExistsRequest) (*CheckRepoExistsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method CheckRepoExists not implemented") } -func (UnimplementedBackrestServer) AddRepo(context.Context, *Repo) (*Config, error) { +func (UnimplementedBackrestServer) AddRepo(context.Context, *AddRepoRequest) (*Config, error) { return nil, status.Errorf(codes.Unimplemented, "method AddRepo not implemented") } func (UnimplementedBackrestServer) RemoveRepo(context.Context, *types.StringValue) (*Config, error) { @@ -468,8 +484,26 @@ func _Backrest_SetConfig_Handler(srv interface{}, ctx context.Context, dec func( return interceptor(ctx, in, info, handler) } +func _Backrest_SetupSftp_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetupSftpRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(BackrestServer).SetupSftp(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Backrest_SetupSftp_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(BackrestServer).SetupSftp(ctx, req.(*SetupSftpRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _Backrest_CheckRepoExists_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(Repo) + in := new(CheckRepoExistsRequest) if err := dec(in); err != nil { return nil, err } @@ -481,13 +515,13 @@ func _Backrest_CheckRepoExists_Handler(srv interface{}, ctx context.Context, dec FullMethod: Backrest_CheckRepoExists_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(BackrestServer).CheckRepoExists(ctx, req.(*Repo)) + return srv.(BackrestServer).CheckRepoExists(ctx, req.(*CheckRepoExistsRequest)) } return interceptor(ctx, in, info, handler) } func _Backrest_AddRepo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(Repo) + in := new(AddRepoRequest) if err := dec(in); err != nil { return nil, err } @@ -499,7 +533,7 @@ func _Backrest_AddRepo_Handler(srv interface{}, ctx context.Context, dec func(in FullMethod: Backrest_AddRepo_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(BackrestServer).AddRepo(ctx, req.(*Repo)) + return srv.(BackrestServer).AddRepo(ctx, req.(*AddRepoRequest)) } return interceptor(ctx, in, info, handler) } @@ -793,6 +827,10 @@ var Backrest_ServiceDesc = grpc.ServiceDesc{ MethodName: "SetConfig", Handler: _Backrest_SetConfig_Handler, }, + { + MethodName: "SetupSftp", + Handler: _Backrest_SetupSftp_Handler, + }, { MethodName: "CheckRepoExists", Handler: _Backrest_CheckRepoExists_Handler, diff --git a/gen/go/v1/v1connect/service.connect.go b/gen/go/v1/v1connect/service.connect.go index e9ce9f3c..861db9a4 100644 --- a/gen/go/v1/v1connect/service.connect.go +++ b/gen/go/v1/v1connect/service.connect.go @@ -39,6 +39,8 @@ const ( BackrestGetConfigProcedure = "/v1.Backrest/GetConfig" // BackrestSetConfigProcedure is the fully-qualified name of the Backrest's SetConfig RPC. BackrestSetConfigProcedure = "/v1.Backrest/SetConfig" + // BackrestSetupSftpProcedure is the fully-qualified name of the Backrest's SetupSftp RPC. + BackrestSetupSftpProcedure = "/v1.Backrest/SetupSftp" // BackrestCheckRepoExistsProcedure is the fully-qualified name of the Backrest's CheckRepoExists // RPC. BackrestCheckRepoExistsProcedure = "/v1.Backrest/CheckRepoExists" @@ -86,8 +88,9 @@ const ( type BackrestClient interface { GetConfig(context.Context, *connect.Request[emptypb.Empty]) (*connect.Response[v1.Config], error) SetConfig(context.Context, *connect.Request[v1.Config]) (*connect.Response[v1.Config], error) - CheckRepoExists(context.Context, *connect.Request[v1.Repo]) (*connect.Response[types.BoolValue], error) - AddRepo(context.Context, *connect.Request[v1.Repo]) (*connect.Response[v1.Config], error) + SetupSftp(context.Context, *connect.Request[v1.SetupSftpRequest]) (*connect.Response[v1.SetupSftpResponse], error) + CheckRepoExists(context.Context, *connect.Request[v1.CheckRepoExistsRequest]) (*connect.Response[v1.CheckRepoExistsResponse], error) + AddRepo(context.Context, *connect.Request[v1.AddRepoRequest]) (*connect.Response[v1.Config], error) RemoveRepo(context.Context, *connect.Request[types.StringValue]) (*connect.Response[v1.Config], error) GetOperationEvents(context.Context, *connect.Request[emptypb.Empty]) (*connect.ServerStreamForClient[v1.OperationEvent], error) GetOperations(context.Context, *connect.Request[v1.GetOperationsRequest]) (*connect.Response[v1.OperationList], error) @@ -140,13 +143,19 @@ func NewBackrestClient(httpClient connect.HTTPClient, baseURL string, opts ...co connect.WithSchema(backrestMethods.ByName("SetConfig")), connect.WithClientOptions(opts...), ), - checkRepoExists: connect.NewClient[v1.Repo, types.BoolValue]( + setupSftp: connect.NewClient[v1.SetupSftpRequest, v1.SetupSftpResponse]( + httpClient, + baseURL+BackrestSetupSftpProcedure, + connect.WithSchema(backrestMethods.ByName("SetupSftp")), + connect.WithClientOptions(opts...), + ), + checkRepoExists: connect.NewClient[v1.CheckRepoExistsRequest, v1.CheckRepoExistsResponse]( httpClient, baseURL+BackrestCheckRepoExistsProcedure, connect.WithSchema(backrestMethods.ByName("CheckRepoExists")), connect.WithClientOptions(opts...), ), - addRepo: connect.NewClient[v1.Repo, v1.Config]( + addRepo: connect.NewClient[v1.AddRepoRequest, v1.Config]( httpClient, baseURL+BackrestAddRepoProcedure, connect.WithSchema(backrestMethods.ByName("AddRepo")), @@ -255,8 +264,9 @@ func NewBackrestClient(httpClient connect.HTTPClient, baseURL string, opts ...co type backrestClient struct { getConfig *connect.Client[emptypb.Empty, v1.Config] setConfig *connect.Client[v1.Config, v1.Config] - checkRepoExists *connect.Client[v1.Repo, types.BoolValue] - addRepo *connect.Client[v1.Repo, v1.Config] + setupSftp *connect.Client[v1.SetupSftpRequest, v1.SetupSftpResponse] + checkRepoExists *connect.Client[v1.CheckRepoExistsRequest, v1.CheckRepoExistsResponse] + addRepo *connect.Client[v1.AddRepoRequest, v1.Config] removeRepo *connect.Client[types.StringValue, v1.Config] getOperationEvents *connect.Client[emptypb.Empty, v1.OperationEvent] getOperations *connect.Client[v1.GetOperationsRequest, v1.OperationList] @@ -285,13 +295,18 @@ func (c *backrestClient) SetConfig(ctx context.Context, req *connect.Request[v1. return c.setConfig.CallUnary(ctx, req) } +// SetupSftp calls v1.Backrest.SetupSftp. +func (c *backrestClient) SetupSftp(ctx context.Context, req *connect.Request[v1.SetupSftpRequest]) (*connect.Response[v1.SetupSftpResponse], error) { + return c.setupSftp.CallUnary(ctx, req) +} + // CheckRepoExists calls v1.Backrest.CheckRepoExists. -func (c *backrestClient) CheckRepoExists(ctx context.Context, req *connect.Request[v1.Repo]) (*connect.Response[types.BoolValue], error) { +func (c *backrestClient) CheckRepoExists(ctx context.Context, req *connect.Request[v1.CheckRepoExistsRequest]) (*connect.Response[v1.CheckRepoExistsResponse], error) { return c.checkRepoExists.CallUnary(ctx, req) } // AddRepo calls v1.Backrest.AddRepo. -func (c *backrestClient) AddRepo(ctx context.Context, req *connect.Request[v1.Repo]) (*connect.Response[v1.Config], error) { +func (c *backrestClient) AddRepo(ctx context.Context, req *connect.Request[v1.AddRepoRequest]) (*connect.Response[v1.Config], error) { return c.addRepo.CallUnary(ctx, req) } @@ -379,8 +394,9 @@ func (c *backrestClient) GetSummaryDashboard(ctx context.Context, req *connect.R type BackrestHandler interface { GetConfig(context.Context, *connect.Request[emptypb.Empty]) (*connect.Response[v1.Config], error) SetConfig(context.Context, *connect.Request[v1.Config]) (*connect.Response[v1.Config], error) - CheckRepoExists(context.Context, *connect.Request[v1.Repo]) (*connect.Response[types.BoolValue], error) - AddRepo(context.Context, *connect.Request[v1.Repo]) (*connect.Response[v1.Config], error) + SetupSftp(context.Context, *connect.Request[v1.SetupSftpRequest]) (*connect.Response[v1.SetupSftpResponse], error) + CheckRepoExists(context.Context, *connect.Request[v1.CheckRepoExistsRequest]) (*connect.Response[v1.CheckRepoExistsResponse], error) + AddRepo(context.Context, *connect.Request[v1.AddRepoRequest]) (*connect.Response[v1.Config], error) RemoveRepo(context.Context, *connect.Request[types.StringValue]) (*connect.Response[v1.Config], error) GetOperationEvents(context.Context, *connect.Request[emptypb.Empty], *connect.ServerStream[v1.OperationEvent]) error GetOperations(context.Context, *connect.Request[v1.GetOperationsRequest]) (*connect.Response[v1.OperationList], error) @@ -429,6 +445,12 @@ func NewBackrestHandler(svc BackrestHandler, opts ...connect.HandlerOption) (str connect.WithSchema(backrestMethods.ByName("SetConfig")), connect.WithHandlerOptions(opts...), ) + backrestSetupSftpHandler := connect.NewUnaryHandler( + BackrestSetupSftpProcedure, + svc.SetupSftp, + connect.WithSchema(backrestMethods.ByName("SetupSftp")), + connect.WithHandlerOptions(opts...), + ) backrestCheckRepoExistsHandler := connect.NewUnaryHandler( BackrestCheckRepoExistsProcedure, svc.CheckRepoExists, @@ -543,6 +565,8 @@ func NewBackrestHandler(svc BackrestHandler, opts ...connect.HandlerOption) (str backrestGetConfigHandler.ServeHTTP(w, r) case BackrestSetConfigProcedure: backrestSetConfigHandler.ServeHTTP(w, r) + case BackrestSetupSftpProcedure: + backrestSetupSftpHandler.ServeHTTP(w, r) case BackrestCheckRepoExistsProcedure: backrestCheckRepoExistsHandler.ServeHTTP(w, r) case BackrestAddRepoProcedure: @@ -596,11 +620,15 @@ func (UnimplementedBackrestHandler) SetConfig(context.Context, *connect.Request[ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("v1.Backrest.SetConfig is not implemented")) } -func (UnimplementedBackrestHandler) CheckRepoExists(context.Context, *connect.Request[v1.Repo]) (*connect.Response[types.BoolValue], error) { +func (UnimplementedBackrestHandler) SetupSftp(context.Context, *connect.Request[v1.SetupSftpRequest]) (*connect.Response[v1.SetupSftpResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("v1.Backrest.SetupSftp is not implemented")) +} + +func (UnimplementedBackrestHandler) CheckRepoExists(context.Context, *connect.Request[v1.CheckRepoExistsRequest]) (*connect.Response[v1.CheckRepoExistsResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("v1.Backrest.CheckRepoExists is not implemented")) } -func (UnimplementedBackrestHandler) AddRepo(context.Context, *connect.Request[v1.Repo]) (*connect.Response[v1.Config], error) { +func (UnimplementedBackrestHandler) AddRepo(context.Context, *connect.Request[v1.AddRepoRequest]) (*connect.Response[v1.Config], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("v1.Backrest.AddRepo is not implemented")) } diff --git a/go.mod b/go.mod index 782e9c65..892da514 100644 --- a/go.mod +++ b/go.mod @@ -54,10 +54,12 @@ require ( github.com/go-stack/stack v1.8.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/josephspurrier/goversioninfo v1.5.0 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/julianday v1.0.0 // indirect github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect + github.com/pkg/sftp v1.13.10 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.2 // indirect diff --git a/go.sum b/go.sum index 73f21add..e6cb4939 100644 --- a/go.sum +++ b/go.sum @@ -90,6 +90,8 @@ github.com/josephspurrier/goversioninfo v1.5.0 h1:9TJtORoyf4YMoWSOo/cXFN9A/lB3Pn github.com/josephspurrier/goversioninfo v1.5.0/go.mod h1:6MoTvFZ6GKJkzcdLnU5T/RGYUbHQbKpYeNP0AgQLd2o= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -124,6 +126,8 @@ github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzL github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= diff --git a/internal/api/backresthandler.go b/internal/api/backresthandler.go index fc3cf894..e4f6c87d 100644 --- a/internal/api/backresthandler.go +++ b/internal/api/backresthandler.go @@ -3,11 +3,19 @@ package api import ( "bytes" "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" "errors" "fmt" "io" + "net" "os" + "os/exec" "path" + "path/filepath" + "regexp" + "runtime" "slices" "strings" "sync" @@ -17,6 +25,7 @@ import ( "github.com/garethgeorge/backrest/gen/go/types" v1 "github.com/garethgeorge/backrest/gen/go/v1" "github.com/garethgeorge/backrest/gen/go/v1/v1connect" + "github.com/garethgeorge/backrest/internal/api/sftputil" syncapi "github.com/garethgeorge/backrest/internal/api/syncapi" "github.com/garethgeorge/backrest/internal/config" "github.com/garethgeorge/backrest/internal/cryptoutil" @@ -29,7 +38,9 @@ import ( "github.com/garethgeorge/backrest/internal/protoutil" "github.com/garethgeorge/backrest/internal/resticinstaller" "github.com/garethgeorge/backrest/pkg/restic" + "github.com/pkg/sftp" "go.uber.org/zap" + "golang.org/x/crypto/ssh" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/emptypb" ) @@ -98,21 +109,33 @@ func (s *BackrestHandler) SetConfig(ctx context.Context, req *connect.Request[v1 return connect.NewResponse(newConfig), nil } -func (s *BackrestHandler) CheckRepoExists(ctx context.Context, req *connect.Request[v1.Repo]) (*connect.Response[types.BoolValue], error) { +func (s *BackrestHandler) CheckRepoExists(ctx context.Context, req *connect.Request[v1.CheckRepoExistsRequest]) (*connect.Response[v1.CheckRepoExistsResponse], error) { c, err := s.config.Get() if err != nil { return nil, fmt.Errorf("failed to get config: %w", err) } + sanitizeRepoFlags(req.Msg.Repo) + c = proto.Clone(c).(*v1.Config) - if idx := slices.IndexFunc(c.Repos, func(r *v1.Repo) bool { return r.Id == req.Msg.Id }); idx != -1 { - c.Repos[idx] = req.Msg + if idx := slices.IndexFunc(c.Repos, func(r *v1.Repo) bool { return r.Id == req.Msg.Repo.Id }); idx != -1 { + c.Repos[idx] = req.Msg.Repo } else { - c.Repos = append(c.Repos, req.Msg) + c.Repos = append(c.Repos, req.Msg.Repo) } - if req.Msg.Guid == "" { - req.Msg.Guid = cryptoutil.MustRandomID(cryptoutil.DefaultIDBits) + if req.Msg.Repo.Guid == "" { + req.Msg.Repo.Guid = cryptoutil.MustRandomID(cryptoutil.DefaultIDBits) + } + + if err := s.addSftpHostKey(req.Msg.Repo, req.Msg.GetTrustSftpHostKey()); err != nil { + if strings.Contains(err.Error(), "host key verification failed") { + return connect.NewResponse(&v1.CheckRepoExistsResponse{ + HostKeyUntrusted: true, + Error: err.Error(), + }), nil + } + return nil, fmt.Errorf("failed to add sftp host key: %w", err) } if err := config.ValidateConfig(c); err != nil { @@ -124,7 +147,7 @@ func (s *BackrestHandler) CheckRepoExists(ctx context.Context, req *connect.Requ return nil, fmt.Errorf("failed to find or install restic binary: %w", err) } - r, err := repo.NewRepoOrchestrator(c, req.Msg, bin) + r, err := repo.NewRepoOrchestrator(c, req.Msg.Repo, bin) if err != nil { return nil, fmt.Errorf("failed to configure repo: %w", err) } @@ -133,24 +156,25 @@ func (s *BackrestHandler) CheckRepoExists(ctx context.Context, req *connect.Requ defer cancel() if err := r.Exists(ctx); err != nil { - zap.S().Debugf("repo %q exists or not: %v", req.Msg.Id, err) + zap.S().Debugf("repo %q exists or not: %v", req.Msg.Repo.Id, err) if errors.Is(err, restic.ErrRepoNotFound) { - zap.S().Debugf("repo %q does not exist", req.Msg.Id) - return connect.NewResponse(&types.BoolValue{Value: false}), nil + zap.S().Debugf("repo %q does not exist", req.Msg.Repo.Id) + return connect.NewResponse(&v1.CheckRepoExistsResponse{Exists: false}), nil } return nil, err } - return connect.NewResponse(&types.BoolValue{Value: true}), nil + return connect.NewResponse(&v1.CheckRepoExistsResponse{Exists: true}), nil } // AddRepo implements POST /v1/config/repo, it includes validation that the repo can be initialized. -func (s *BackrestHandler) AddRepo(ctx context.Context, req *connect.Request[v1.Repo]) (*connect.Response[v1.Config], error) { +func (s *BackrestHandler) AddRepo(ctx context.Context, req *connect.Request[v1.AddRepoRequest]) (*connect.Response[v1.Config], error) { c, err := s.config.Get() if err != nil { return nil, fmt.Errorf("failed to get config: %w", err) } - newRepo := req.Msg + newRepo := req.Msg.Repo + sanitizeRepoFlags(newRepo) // Deep copy the configuration c = proto.Clone(c).(*v1.Config) @@ -189,6 +213,10 @@ func (s *BackrestHandler) AddRepo(ctx context.Context, req *connect.Request[v1.R newRepo.Guid = guid + if err := s.addSftpHostKey(newRepo, req.Msg.GetTrustSftpHostKey()); err != nil { + return nil, fmt.Errorf("failed to add sftp host key: %w", err) + } + if err := config.ValidateConfig(c); err != nil { return nil, fmt.Errorf("validation error: %w", err) } @@ -267,6 +295,158 @@ func (s *BackrestHandler) RemoveRepo(ctx context.Context, req *connect.Request[t return connect.NewResponse(cfg), nil } +// SetupSftp implements SetupSftp RPC +func (s *BackrestHandler) SetupSftp(ctx context.Context, req *connect.Request[v1.SetupSftpRequest]) (*connect.Response[v1.SetupSftpResponse], error) { + host := req.Msg.Host + port := req.Msg.Port + if port == "" { + port = "22" + } + user := req.Msg.Username + password := req.Msg.Password // Optional + + if runtime.GOOS == "windows" { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("automated SFTP setup is not supported on Windows")) + } + + // 1. Host Key Verification/Addition + if err := sftputil.AddHostKey(host, port, env.SSHDir()); err != nil { + return connect.NewResponse(&v1.SetupSftpResponse{ + Error: fmt.Sprintf("Failed to add host key: %v", err), + }), nil + } + + // 2. Generate Key + _, pubBytes, keyPath, err := sftputil.GenerateKey(host, env.SSHDir()) + if err != nil { + return nil, fmt.Errorf("failed to generate key: %w", err) + } + + pubKeyStr := string(pubBytes) + + // 3. Install if password provided + if password != nil { + if err := sftputil.InstallKey(host, port, user, *password, pubBytes); err != nil { + return connect.NewResponse(&v1.SetupSftpResponse{ + Error: fmt.Sprintf("Failed to install key: %v", err), + }), nil + } + + // Verify + privPEM, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("failed to read generated private key for verification: %w", err) + } + + if err := sftputil.VerifyConnection(host, port, user, privPEM); err != nil { + return connect.NewResponse(&v1.SetupSftpResponse{ + Error: fmt.Sprintf("Key installed but verification failed: %v", err), + }), nil + } + } + + return connect.NewResponse(&v1.SetupSftpResponse{ + PublicKey: pubKeyStr, + KeyPath: keyPath, + KnownHostsPath: filepath.Join(env.SSHDir(), "known_hosts"), + }), nil +} + +// This is equivalent to what `ssh-keyscan` does. +func (s *BackrestHandler) addSftpHostKey(repo *v1.Repo, trust bool) error { + uri := repo.GetUri() + if !strings.HasPrefix(uri, "sftp:") { + return nil + } + uri = strings.TrimPrefix(uri, "sftp:") + + slashIdx := strings.Index(uri, "/") + if slashIdx == -1 { + slashIdx = len(uri) + } + + authority := uri[:slashIdx] + hostPart := authority + if atIdx := strings.LastIndex(authority, "@"); atIdx != -1 { + hostPart = authority[atIdx+1:] + } + + host, port, err := net.SplitHostPort(hostPart) + if err != nil { + host = hostPart + } + + if host == "" { + return errors.New("could not parse host from sftp uri") + } + + // Extract port from flags if possible + // Check for port in sftp.args + // e.g. --option=sftp.args='-oBatchMode=yes -p 23' + re := regexp.MustCompile(`(?:^|\s)-[pP]\s*(\d+)`) + for _, flag := range repo.Flags { + if strings.HasPrefix(flag, "--option=sftp.args=") { + matches := re.FindStringSubmatch(flag) + if len(matches) > 1 { + port = matches[1] + break + } + } + } + + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("could not get home directory: %w", err) + } + knownHostsPath := path.Join(home, ".ssh", "known_hosts") + + if err := os.MkdirAll(path.Dir(knownHostsPath), 0700); err != nil { + return err + } + + // Construct host spec for ssh-keygen and ssh-keyscan + // If port is non-standard, host spec is usually [host]:port + hostSpec := host + if port != "" && port != "22" { + hostSpec = fmt.Sprintf("[%s]:%s", host, port) + } + + checkCmd := exec.Command("ssh-keygen", "-F", hostSpec) + if err := checkCmd.Run(); err == nil { + zap.S().Debugf("SFTP host %s already in known_hosts", hostSpec) + return nil + } + + if !trust { + return fmt.Errorf("SFTP host key verification failed: key for host %s is unknown", hostSpec) + } + + keyscanArgs := []string{"-H"} + if port != "" { + keyscanArgs = append(keyscanArgs, "-p", port) + } + keyscanArgs = append(keyscanArgs, host) + + keyscanCmd := exec.Command("ssh-keyscan", keyscanArgs...) + keyOutput, err := keyscanCmd.Output() + if err != nil { + return fmt.Errorf("ssh-keyscan for host %s failed: %w", host, err) + } + + f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("failed to open known_hosts file: %w", err) + } + defer f.Close() + + if _, err := f.Write(keyOutput); err != nil { + return fmt.Errorf("failed to write to known_hosts file: %w", err) + } + + zap.S().Infof("Added SFTP host %s to known_hosts file at %s", hostSpec, knownHostsPath) + return nil +} + // ListSnapshots implements POST /v1/snapshots func (s *BackrestHandler) ListSnapshots(ctx context.Context, req *connect.Request[v1.ListSnapshotsRequest]) (*connect.Response[v1.ResticSnapshotList], error) { query := req.Msg @@ -904,3 +1084,183 @@ func (s *BackrestHandler) GetSummaryDashboard(ctx context.Context, req *connect. return connect.NewResponse(response), nil } + +func parseSftpConfig(repo *v1.Repo) (user, host, port string, err error) { + uri := repo.GetUri() + if !strings.HasPrefix(uri, "sftp:") { + return "", "", "", errors.New("not an sftp repo") + } + uri = strings.TrimPrefix(uri, "sftp:") + + slashIdx := strings.Index(uri, "/") + if slashIdx == -1 { + slashIdx = len(uri) + } + + authority := uri[:slashIdx] + hostPart := authority + if atIdx := strings.LastIndex(authority, "@"); atIdx != -1 { + user = authority[:atIdx] + hostPart = authority[atIdx+1:] + } + + host, port, err = net.SplitHostPort(hostPart) + if err != nil { + host = hostPart + } + + // Parse port from flags + re := regexp.MustCompile(`(?:^|\s)-[pP]\s*(\d+)`) + for _, flag := range repo.Flags { + if strings.HasPrefix(flag, "--option=sftp.args=") { + matches := re.FindStringSubmatch(flag) + if len(matches) > 1 { + port = matches[1] + break + } + } + } + return user, host, port, nil +} + +func sanitizeRepoFlags(repo *v1.Repo) { + for i, flag := range repo.Flags { + if strings.HasPrefix(flag, "--option=sftp.args=") { + repo.Flags[i] = strings.ReplaceAll(flag, "-i @", "-i ") + } + } +} + +func (s *BackrestHandler) generateAndInstallSSHKey(host, port, user, password string) (string, error) { + zap.S().Debugf("Generating ED25519 key for %s@%s:%s", user, host, port) + // Generate Key + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", err + } + + privBlock, err := ssh.MarshalPrivateKey(priv, "") + if err != nil { + return "", fmt.Errorf("failed to marshal private key: %w", err) + } + privPEM := pem.EncodeToMemory(privBlock) + + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + return "", err + } + pubBytes := ssh.MarshalAuthorizedKey(sshPub) + + // Save to file + keyDir := env.SSHDir() + if err := os.MkdirAll(keyDir, 0700); err != nil { + return "", err + } + keyPath := path.Join(keyDir, fmt.Sprintf("id_ed25519_%s_%d", host, time.Now().Unix())) + if err := os.WriteFile(keyPath, privPEM, 0600); err != nil { + return "", err + } + if err := os.WriteFile(keyPath+".pub", pubBytes, 0644); err != nil { + zap.S().Warnf("failed to write public key: %v", err) + } + zap.S().Debugf("Saved private key to %s", keyPath) + + // Verify key file is readable and valid + if diskBytes, err := os.ReadFile(keyPath); err != nil { + return "", fmt.Errorf("failed to read back key file: %w", err) + } else if _, err := ssh.ParsePrivateKey(diskBytes); err != nil { + return "", fmt.Errorf("generated key file is invalid or unparseable: %w", err) + } + + // Install on server + sshConfig := &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ + ssh.Password(password), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + } + zap.S().Debugf("Dialing SSH to %s:%s to install key", host, port) + conn, err := ssh.Dial("tcp", net.JoinHostPort(host, port), sshConfig) + if err != nil { + return "", fmt.Errorf("failed to dial ssh: %w", err) + } + defer conn.Close() + + // Use SFTP to install key (handles restricted shells better) + sftpClient, err := sftp.NewClient(conn) + if err != nil { + return "", fmt.Errorf("failed to create sftp client: %w", err) + } + defer sftpClient.Close() + + // Ensure .ssh directory exists + if _, err := sftpClient.Stat(".ssh"); errors.Is(err, os.ErrNotExist) { + if err := sftpClient.Mkdir(".ssh"); err != nil { + return "", fmt.Errorf("failed to create .ssh directory: %w", err) + } + if err := sftpClient.Chmod(".ssh", 0700); err != nil { + zap.S().Warnf("failed to chmod .ssh: %v", err) + } + } + + // Open authorized_keys for appending + f, err := sftpClient.OpenFile(".ssh/authorized_keys", os.O_APPEND|os.O_CREATE|os.O_WRONLY) + if err != nil { + return "", fmt.Errorf("failed to open .ssh/authorized_keys: %w", err) + } + defer f.Close() + + if err := f.Chmod(0600); err != nil { + zap.S().Warnf("failed to chmod authorized_keys: %v", err) + } + + // Append key with leading newline to be safe + if _, err := f.Write([]byte("\n")); err != nil { + return "", fmt.Errorf("failed to write newline to authorized_keys: %w", err) + } + if _, err := f.Write(pubBytes); err != nil { + return "", fmt.Errorf("failed to write key to authorized_keys: %w", err) + } + if _, err := f.Write([]byte("\n")); err != nil { + return "", fmt.Errorf("failed to write newline to authorized_keys: %w", err) + } + + zap.S().Debug("Key installed successfully via SFTP") + + // Verify key works + keySigner, err := ssh.NewSignerFromKey(priv) + if err != nil { + return "", fmt.Errorf("failed to create signer: %w", err) + } + + testConfig := &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(keySigner), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + } + + zap.S().Debugf("Verifying connection with new key...") + testConn, err := ssh.Dial("tcp", net.JoinHostPort(host, port), testConfig) + if err != nil { + zap.S().Warnf("Verification failed: %v", err) + return "", fmt.Errorf("generated key was installed but failed to authenticate: %w", err) + } + defer testConn.Close() + + // Verify SFTP subsystem + verifySftpClient, err := sftp.NewClient(testConn) + if err != nil { + zap.S().Warnf("SFTP verification failed: %v", err) + return "", fmt.Errorf("generated key authenticated but SFTP subsystem failed: %w", err) + } + verifySftpClient.Close() + + zap.S().Debug("Verification successful") + + return keyPath, nil +} diff --git a/internal/api/sftputil/sftputil.go b/internal/api/sftputil/sftputil.go new file mode 100644 index 00000000..c2d4cf39 --- /dev/null +++ b/internal/api/sftputil/sftputil.go @@ -0,0 +1,210 @@ +package sftputil + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "fmt" + "net" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + "time" + + "github.com/pkg/sftp" + "go.uber.org/zap" + "golang.org/x/crypto/ssh" +) + +// AddHostKey adds the SFTP host key to the known_hosts file. +// It uses ssh-keyscan to fetch the key. +func AddHostKey(host, port string, sshDir string) error { + hostSpec := host + if port != "" && port != "22" { + hostSpec = fmt.Sprintf("[%s]:%s", host, port) + } + + knownHostsPath := filepath.Join(sshDir, "known_hosts") + if err := os.MkdirAll(path.Dir(knownHostsPath), 0700); err != nil { + return fmt.Errorf("failed to create ssh dir: %w", err) + } + + // Check if already known + checkCmd := exec.Command("ssh-keygen", "-F", hostSpec, "-f", knownHostsPath) + if checkCmd.Run() == nil { + zap.S().Debugf("SFTP host %s already in known_hosts", hostSpec) + return nil + } + if err := os.MkdirAll(path.Dir(knownHostsPath), 0700); err != nil { + return fmt.Errorf("failed to create ssh dir: %w", err) + } + + keyscanArgs := []string{"-H"} + if port != "" { + keyscanArgs = append(keyscanArgs, "-p", port) + } + keyscanArgs = append(keyscanArgs, host) + + keyscanCmd := exec.Command("ssh-keyscan", keyscanArgs...) + keyOutput, err := keyscanCmd.Output() + if err != nil { + return fmt.Errorf("ssh-keyscan for host %s failed: %w", host, err) + } + + f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("failed to open known_hosts file: %w", err) + } + defer f.Close() + + if _, err := f.Write(keyOutput); err != nil { + return fmt.Errorf("failed to write to known_hosts file: %w", err) + } + + zap.S().Infof("Added SFTP host %s to known_hosts file at %s", hostSpec, knownHostsPath) + return nil +} + +// GenerateKey generates an Ed25519 key pair and saves it to the specified directory. +// Returns the private key in OpenSSH PEM format, public key in SSH format, and the full path to the private key file. +func GenerateKey(host string, sshDir string) ([]byte, []byte, string, error) { + zap.S().Debugf("Generating ED25519 key for host %s", host) + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to generate key: %w", err) + } + + // Marshal private key to OpenSSH PEM format (requires "golang.org/x/crypto/ssh") + // Note: ssh.MarshalPrivateKey returns a PEM block since Go 1.16+ for Ed25519? + // Actually ssh.MarshalPrivateKey returns an *pem.Block. + privBlock, err := ssh.MarshalPrivateKey(priv, "") + if err != nil { + return nil, nil, "", fmt.Errorf("failed to marshal private key: %w", err) + } + privPEM := pem.EncodeToMemory(privBlock) + + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to create public key: %w", err) + } + pubBytes := ssh.MarshalAuthorizedKey(sshPub) + + // Save to file + if err := os.MkdirAll(sshDir, 0700); err != nil { + return nil, nil, "", fmt.Errorf("failed to create ssh dir: %w", err) + } + sanitizedHost := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '-' { + return r + } + return '_' + }, host) + + keyPath := filepath.Join(sshDir, "id_ed25519_"+string(sanitizedHost)) + // check if file exists + if _, err := os.Stat(keyPath); err == nil { + // read the key from disk instead + privPEM, err = os.ReadFile(keyPath) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to read private key: %w", err) + } + pubBytes, err = os.ReadFile(keyPath + ".pub") + if err != nil { + return nil, nil, "", fmt.Errorf("failed to read public key: %w", err) + } + return privPEM, pubBytes, keyPath, nil + } + + if err := os.WriteFile(keyPath, privPEM, 0600); err != nil { + return nil, nil, "", fmt.Errorf("failed to write private key: %w", err) + } + if err := os.WriteFile(keyPath+".pub", pubBytes, 0644); err != nil { + zap.S().Warnf("failed to write public key: %v", err) + } + + return privPEM, pubBytes, keyPath, nil +} + +// InstallKey connects to the SFTP server using a password and appends the public key to authorized_keys. +func InstallKey(host, port, user, password string, pubBytes []byte) error { + sshConfig := &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ + ssh.Password(password), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), // Verification assumed done via AddHostKey + Timeout: 10 * time.Second, + } + + conn, err := ssh.Dial("tcp", net.JoinHostPort(host, port), sshConfig) + if err != nil { + return fmt.Errorf("failed to connect with password: %w", err) + } + defer conn.Close() + + sftpClient, err := sftp.NewClient(conn) + if err != nil { + return fmt.Errorf("failed to create sftp client: %w", err) + } + defer sftpClient.Close() + + // Ensure .ssh directory exists + if _, err := sftpClient.Stat(".ssh"); errors.Is(err, os.ErrNotExist) { + if err := sftpClient.Mkdir(".ssh"); err != nil { + return fmt.Errorf("failed to create .ssh directory: %w", err) + } + if err := sftpClient.Chmod(".ssh", 0700); err != nil { + zap.S().Warnf("failed to chmod .ssh: %v", err) + } + } + + f, err := sftpClient.OpenFile(".ssh/authorized_keys", os.O_APPEND|os.O_CREATE|os.O_WRONLY) + if err != nil { + return fmt.Errorf("failed to open authorized_keys: %w", err) + } + defer f.Close() + + if err := f.Chmod(0600); err != nil { + zap.S().Warnf("failed to chmod authorized_keys: %v", err) + } + + if _, err := f.Write([]byte("\n")); err != nil { + return fmt.Errorf("write error: %w", err) + } + if _, err := f.Write(pubBytes); err != nil { + return fmt.Errorf("write error: %w", err) + } + if _, err := f.Write([]byte("\n")); err != nil { + return fmt.Errorf("write error: %w", err) + } + + return nil +} + +// VerifyConnection attempts to connect using the provided private key. +func VerifyConnection(host, port, user string, privPEM []byte) error { + signer, err := ssh.ParsePrivateKey(privPEM) + if err != nil { + return fmt.Errorf("failed to parse private key: %w", err) + } + + clientConfig := &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + } + + conn, err := ssh.Dial("tcp", net.JoinHostPort(host, port), clientConfig) + if err != nil { + return fmt.Errorf("verification connection failed: %w", err) + } + defer conn.Close() + + return nil +} diff --git a/internal/api/sftputil/sftputil_test.go b/internal/api/sftputil/sftputil_test.go new file mode 100644 index 00000000..b9c382e3 --- /dev/null +++ b/internal/api/sftputil/sftputil_test.go @@ -0,0 +1,84 @@ +package sftputil + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestGenerateKey_ReuseAndSanitization(t *testing.T) { + // Create a temporary directory for SSH keys + tempDir, err := os.MkdirTemp("", "sftp_test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + host := "example.com" + + // First call: should generate new keys + priv1, pub1, path1, err := GenerateKey(host, tempDir) + if err != nil { + t.Fatalf("GenerateKey failed: %v", err) + } + if priv1 == nil || pub1 == nil { + t.Fatal("GenerateKey returned nil keys") + } + + // Verify file existence + expectedFilename := "id_ed25519_example.com" + if filepath.Base(path1) != expectedFilename { + t.Errorf("expected filename %s, got %s", expectedFilename, filepath.Base(path1)) + } + if _, err := os.Stat(path1); os.IsNotExist(err) { + t.Errorf("private key file does not exist at %s", path1) + } + if _, err := os.Stat(path1 + ".pub"); os.IsNotExist(err) { + t.Errorf("public key file does not exist at %s.pub", path1) + } + + // Second call: should reuse existing keys + priv2, pub2, path2, err := GenerateKey(host, tempDir) + if err != nil { + t.Fatalf("GenerateKey (2nd call) failed: %v", err) + } + + // Verify keys are identical + if !bytes.Equal(priv1, priv2) { + t.Error("GenerateKey did not reuse the private key") + } + if !bytes.Equal(pub1, pub2) { + t.Error("GenerateKey did not reuse the public key") + } + if path1 != path2 { + t.Errorf("GenerateKey returned different paths: %s vs %s", path1, path2) + } +} + +func TestGenerateKey_Sanitization(t *testing.T) { + tempDir, err := os.MkdirTemp("", "sftp_retry_test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Host with special characters + unsafeHost := "bad/host:name!@#" + _, _, keyPath, err := GenerateKey(unsafeHost, tempDir) + if err != nil { + t.Fatalf("GenerateKey failed for unsafe host: %v", err) + } + + // Expected sanitization: bad_host_name___ + // characters allowed: a-z, A-Z, 0-9, ., - + // '/' -> '_' + // ':' -> '_' + // '!' -> '_' + // '@' -> '_' + // '#' -> '_' + expectedFilename := "id_ed25519_bad_host_name___" + if filepath.Base(keyPath) != expectedFilename { + t.Errorf("Sanitization failed. Expected %s, got %s", expectedFilename, filepath.Base(keyPath)) + } +} diff --git a/internal/env/environment.go b/internal/env/environment.go index 75736de2..01bbde4c 100644 --- a/internal/env/environment.go +++ b/internal/env/environment.go @@ -99,6 +99,11 @@ func LogsPath() string { return filepath.Join(dataDir, "processlogs") } +func SSHDir() string { + // This is awkward, we don't have a flag that specifies a "config" directory persay, so we default to the directory containing the config file for our SSH keys/known_hosts file. + return filepath.Join(filepath.Dir(ConfigFilePath()), ".backrest-ssh") +} + func getHomeDir() string { home, err := os.UserHomeDir() if err != nil { diff --git a/proto/v1/service.proto b/proto/v1/service.proto index 6b3c54ef..dc0f08d4 100644 --- a/proto/v1/service.proto +++ b/proto/v1/service.proto @@ -16,9 +16,11 @@ service Backrest { rpc SetConfig (Config) returns (Config) {} - rpc CheckRepoExists (Repo) returns (types.BoolValue) {} // returns an error if the repo does not exist + rpc SetupSftp (SetupSftpRequest) returns (SetupSftpResponse) {} - rpc AddRepo (Repo) returns (Config) {} + rpc CheckRepoExists (CheckRepoExistsRequest) returns (CheckRepoExistsResponse) {} // returns an error if the repo does not exist + + rpc AddRepo (AddRepoRequest) returns (Config) {} rpc RemoveRepo (types.StringValue) returns (Config) {} // remvoes a repo from the config and deletes its history, does not delete the repo on disk @@ -76,6 +78,36 @@ message OpSelector { optional int64 modno_gte = 9; } +message SetupSftpRequest { + string host = 1; + string port = 2; + string username = 3; + optional string password = 4; // If not provided, we only generate the key and add host to known_hosts +} + +message SetupSftpResponse { + string public_key = 1; + string key_path = 2; + string known_hosts_path = 3; + string error = 4; +} + +message CheckRepoExistsRequest { + Repo repo = 1; + bool trust_sftp_host_key = 2; +} + +message CheckRepoExistsResponse { + bool exists = 1; + string error = 2; + bool host_key_untrusted = 5; +} + +message AddRepoRequest { + Repo repo = 1; + bool trust_sftp_host_key = 2; +} + message DoRepoTaskRequest { string repo_id = 1; enum Task { diff --git a/test/e2e/first_run_test.go b/test/e2e/first_run_test.go index 92fdcae4..5d547462 100644 --- a/test/e2e/first_run_test.go +++ b/test/e2e/first_run_test.go @@ -115,10 +115,12 @@ func TestFirstRun(t *testing.T) { fmt.Sprintf("http://%s", addr), ) - req := connect.NewRequest(&v1.Repo{ - Id: "test-repo", - Uri: filepath.Join(tmpDir, "test-repo"), - Password: "1234", + req := connect.NewRequest(&v1.AddRepoRequest{ + Repo: &v1.Repo{ + Id: "test-repo", + Uri: filepath.Join(tmpDir, "test-repo"), + Password: "1234", + }, }) _, err := client.AddRepo(context.Background(), req) if err != nil { diff --git a/webui/gen/ts/v1/service_pb.ts b/webui/gen/ts/v1/service_pb.ts index e508b06f..38bc07ed 100644 --- a/webui/gen/ts/v1/service_pb.ts +++ b/webui/gen/ts/v1/service_pb.ts @@ -4,13 +4,13 @@ import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; -import type { ConfigSchema, RepoSchema } from "./config_pb"; +import type { ConfigSchema, Repo } from "./config_pb"; import { file_v1_config } from "./config_pb"; import type { ResticSnapshotListSchema } from "./restic_pb"; import { file_v1_restic } from "./restic_pb"; import type { OperationEventSchema, OperationListSchema, OperationStatus } from "./operations_pb"; import { file_v1_operations } from "./operations_pb"; -import type { BoolValueSchema, BytesValueSchema, Int64ValueSchema, StringListSchema, StringValueSchema } from "../types/value_pb"; +import type { BytesValueSchema, Int64ValueSchema, StringListSchema, StringValueSchema } from "../types/value_pb"; import { file_types_value } from "../types/value_pb"; import type { EmptySchema } from "@bufbuild/protobuf/wkt"; import { file_google_protobuf_empty } from "@bufbuild/protobuf/wkt"; @@ -21,7 +21,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file v1/service.proto. */ export const file_v1_service: GenFile = /*@__PURE__*/ - fileDesc("ChB2MS9zZXJ2aWNlLnByb3RvEgJ2MSK/AgoKT3BTZWxlY3RvchILCgNpZHMYASADKAMSGAoLaW5zdGFuY2VfaWQYBiABKAlIAIgBARIkChdvcmlnaW5hbF9pbnN0YW5jZV9rZXlpZBgIIAEoCUgBiAEBEhYKCXJlcG9fZ3VpZBgHIAEoCUgCiAEBEhQKB3BsYW5faWQYAyABKAlIA4gBARIYCgtzbmFwc2hvdF9pZBgEIAEoCUgEiAEBEhQKB2Zsb3dfaWQYBSABKANIBYgBARIWCgltb2Rub19ndGUYCSABKANIBogBAUIOCgxfaW5zdGFuY2VfaWRCGgoYX29yaWdpbmFsX2luc3RhbmNlX2tleWlkQgwKCl9yZXBvX2d1aWRCCgoIX3BsYW5faWRCDgoMX3NuYXBzaG90X2lkQgoKCF9mbG93X2lkQgwKCl9tb2Rub19ndGUiwAEKEURvUmVwb1Rhc2tSZXF1ZXN0Eg8KB3JlcG9faWQYASABKAkSKAoEdGFzaxgCIAEoDjIaLnYxLkRvUmVwb1Rhc2tSZXF1ZXN0LlRhc2sicAoEVGFzaxINCglUQVNLX05PTkUQABIYChRUQVNLX0lOREVYX1NOQVBTSE9UUxABEg4KClRBU0tfUFJVTkUQAhIOCgpUQVNLX0NIRUNLEAMSDgoKVEFTS19TVEFUUxAEEg8KC1RBU0tfVU5MT0NLEAUiTAoTQ2xlYXJIaXN0b3J5UmVxdWVzdBIgCghzZWxlY3RvchgBIAEoCzIOLnYxLk9wU2VsZWN0b3ISEwoLb25seV9mYWlsZWQYAiABKAgiRgoNRm9yZ2V0UmVxdWVzdBIPCgdyZXBvX2lkGAEgASgJEg8KB3BsYW5faWQYAiABKAkSEwoLc25hcHNob3RfaWQYAyABKAkiOAoUTGlzdFNuYXBzaG90c1JlcXVlc3QSDwoHcmVwb19pZBgBIAEoCRIPCgdwbGFuX2lkGAIgASgJIkgKFEdldE9wZXJhdGlvbnNSZXF1ZXN0EiAKCHNlbGVjdG9yGAEgASgLMg4udjEuT3BTZWxlY3RvchIOCgZsYXN0X24YAiABKAMibQoWUmVzdG9yZVNuYXBzaG90UmVxdWVzdBIPCgdwbGFuX2lkGAEgASgJEg8KB3JlcG9faWQYBSABKAkSEwoLc25hcHNob3RfaWQYAiABKAkSDAoEcGF0aBgDIAEoCRIOCgZ0YXJnZXQYBCABKAkiUAoYTGlzdFNuYXBzaG90RmlsZXNSZXF1ZXN0EhEKCXJlcG9fZ3VpZBgBIAEoCRITCgtzbmFwc2hvdF9pZBgCIAEoCRIMCgRwYXRoGAMgASgJIkcKGUxpc3RTbmFwc2hvdEZpbGVzUmVzcG9uc2USDAoEcGF0aBgBIAEoCRIcCgdlbnRyaWVzGAIgAygLMgsudjEuTHNFbnRyeSIdCg5Mb2dEYXRhUmVxdWVzdBILCgNyZWYYASABKAkiOQoVR2V0RG93bmxvYWRVUkxSZXF1ZXN0Eg0KBW9wX2lkGAEgASgDEhEKCWZpbGVfcGF0aBgCIAEoCSKWAQoHTHNFbnRyeRIMCgRuYW1lGAEgASgJEgwKBHR5cGUYAiABKAkSDAoEcGF0aBgDIAEoCRILCgN1aWQYBCABKAMSCwoDZ2lkGAUgASgDEgwKBHNpemUYBiABKAMSDAoEbW9kZRgHIAEoAxINCgVtdGltZRgIIAEoCRINCgVhdGltZRgJIAEoCRINCgVjdGltZRgKIAEoCSI1ChFSdW5Db21tYW5kUmVxdWVzdBIPCgdyZXBvX2lkGAEgASgJEg8KB2NvbW1hbmQYAiABKAkitQUKGFN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZRI8Cg5yZXBvX3N1bW1hcmllcxgBIAMoCzIkLnYxLlN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZS5TdW1tYXJ5EjwKDnBsYW5fc3VtbWFyaWVzGAIgAygLMiQudjEuU3VtbWFyeURhc2hib2FyZFJlc3BvbnNlLlN1bW1hcnkSEwoLY29uZmlnX3BhdGgYCiABKAkSEQoJZGF0YV9wYXRoGAsgASgJGu4CCgdTdW1tYXJ5EgoKAmlkGAEgASgJEh0KFWJhY2t1cHNfZmFpbGVkXzMwZGF5cxgCIAEoAxIjChtiYWNrdXBzX3dhcm5pbmdfbGFzdF8zMGRheXMYAyABKAMSIwobYmFja3Vwc19zdWNjZXNzX2xhc3RfMzBkYXlzGAQgASgDEiEKGWJ5dGVzX3NjYW5uZWRfbGFzdF8zMGRheXMYBSABKAMSHwoXYnl0ZXNfYWRkZWRfbGFzdF8zMGRheXMYBiABKAMSFwoPdG90YWxfc25hcHNob3RzGAcgASgDEhkKEWJ5dGVzX3NjYW5uZWRfYXZnGAggASgDEhcKD2J5dGVzX2FkZGVkX2F2ZxgJIAEoAxIbChNuZXh0X2JhY2t1cF90aW1lX21zGAogASgDEkAKDnJlY2VudF9iYWNrdXBzGAsgASgLMigudjEuU3VtbWFyeURhc2hib2FyZFJlc3BvbnNlLkJhY2t1cENoYXJ0GoMBCgtCYWNrdXBDaGFydBIPCgdmbG93X2lkGAEgAygDEhQKDHRpbWVzdGFtcF9tcxgCIAMoAxITCgtkdXJhdGlvbl9tcxgDIAMoAxIjCgZzdGF0dXMYBCADKA4yEy52MS5PcGVyYXRpb25TdGF0dXMSEwoLYnl0ZXNfYWRkZWQYBSADKAMyrwkKCEJhY2tyZXN0EjEKCUdldENvbmZpZxIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRoKLnYxLkNvbmZpZyIAEiUKCVNldENvbmZpZxIKLnYxLkNvbmZpZxoKLnYxLkNvbmZpZyIAEi8KD0NoZWNrUmVwb0V4aXN0cxIILnYxLlJlcG8aEC50eXBlcy5Cb29sVmFsdWUiABIhCgdBZGRSZXBvEggudjEuUmVwbxoKLnYxLkNvbmZpZyIAEi4KClJlbW92ZVJlcG8SEi50eXBlcy5TdHJpbmdWYWx1ZRoKLnYxLkNvbmZpZyIAEkQKEkdldE9wZXJhdGlvbkV2ZW50cxIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRoSLnYxLk9wZXJhdGlvbkV2ZW50IgAwARI+Cg1HZXRPcGVyYXRpb25zEhgudjEuR2V0T3BlcmF0aW9uc1JlcXVlc3QaES52MS5PcGVyYXRpb25MaXN0IgASQwoNTGlzdFNuYXBzaG90cxIYLnYxLkxpc3RTbmFwc2hvdHNSZXF1ZXN0GhYudjEuUmVzdGljU25hcHNob3RMaXN0IgASUgoRTGlzdFNuYXBzaG90RmlsZXMSHC52MS5MaXN0U25hcHNob3RGaWxlc1JlcXVlc3QaHS52MS5MaXN0U25hcHNob3RGaWxlc1Jlc3BvbnNlIgASNgoGQmFja3VwEhIudHlwZXMuU3RyaW5nVmFsdWUaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABI9CgpEb1JlcG9UYXNrEhUudjEuRG9SZXBvVGFza1JlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABI1CgZGb3JnZXQSES52MS5Gb3JnZXRSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IgASPwoHUmVzdG9yZRIaLnYxLlJlc3RvcmVTbmFwc2hvdFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABI1CgZDYW5jZWwSES50eXBlcy5JbnQ2NFZhbHVlGhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IgASNAoHR2V0TG9ncxISLnYxLkxvZ0RhdGFSZXF1ZXN0GhEudHlwZXMuQnl0ZXNWYWx1ZSIAMAESOAoKUnVuQ29tbWFuZBIVLnYxLlJ1bkNvbW1hbmRSZXF1ZXN0GhEudHlwZXMuSW50NjRWYWx1ZSIAEkEKDkdldERvd25sb2FkVVJMEhkudjEuR2V0RG93bmxvYWRVUkxSZXF1ZXN0GhIudHlwZXMuU3RyaW5nVmFsdWUiABJBCgxDbGVhckhpc3RvcnkSFy52MS5DbGVhckhpc3RvcnlSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IgASOwoQUGF0aEF1dG9jb21wbGV0ZRISLnR5cGVzLlN0cmluZ1ZhbHVlGhEudHlwZXMuU3RyaW5nTGlzdCIAEk0KE0dldFN1bW1hcnlEYXNoYm9hcmQSFi5nb29nbGUucHJvdG9idWYuRW1wdHkaHC52MS5TdW1tYXJ5RGFzaGJvYXJkUmVzcG9uc2UiAEIsWipnaXRodWIuY29tL2dhcmV0aGdlb3JnZS9iYWNrcmVzdC9nZW4vZ28vdjFiBnByb3RvMw", [file_v1_config, file_v1_restic, file_v1_operations, file_types_value, file_google_protobuf_empty, file_google_api_annotations]); + fileDesc("ChB2MS9zZXJ2aWNlLnByb3RvEgJ2MSK/AgoKT3BTZWxlY3RvchILCgNpZHMYASADKAMSGAoLaW5zdGFuY2VfaWQYBiABKAlIAIgBARIkChdvcmlnaW5hbF9pbnN0YW5jZV9rZXlpZBgIIAEoCUgBiAEBEhYKCXJlcG9fZ3VpZBgHIAEoCUgCiAEBEhQKB3BsYW5faWQYAyABKAlIA4gBARIYCgtzbmFwc2hvdF9pZBgEIAEoCUgEiAEBEhQKB2Zsb3dfaWQYBSABKANIBYgBARIWCgltb2Rub19ndGUYCSABKANIBogBAUIOCgxfaW5zdGFuY2VfaWRCGgoYX29yaWdpbmFsX2luc3RhbmNlX2tleWlkQgwKCl9yZXBvX2d1aWRCCgoIX3BsYW5faWRCDgoMX3NuYXBzaG90X2lkQgoKCF9mbG93X2lkQgwKCl9tb2Rub19ndGUiZAoQU2V0dXBTZnRwUmVxdWVzdBIMCgRob3N0GAEgASgJEgwKBHBvcnQYAiABKAkSEAoIdXNlcm5hbWUYAyABKAkSFQoIcGFzc3dvcmQYBCABKAlIAIgBAUILCglfcGFzc3dvcmQiYgoRU2V0dXBTZnRwUmVzcG9uc2USEgoKcHVibGljX2tleRgBIAEoCRIQCghrZXlfcGF0aBgCIAEoCRIYChBrbm93bl9ob3N0c19wYXRoGAMgASgJEg0KBWVycm9yGAQgASgJIk0KFkNoZWNrUmVwb0V4aXN0c1JlcXVlc3QSFgoEcmVwbxgBIAEoCzIILnYxLlJlcG8SGwoTdHJ1c3Rfc2Z0cF9ob3N0X2tleRgCIAEoCCJUChdDaGVja1JlcG9FeGlzdHNSZXNwb25zZRIOCgZleGlzdHMYASABKAgSDQoFZXJyb3IYAiABKAkSGgoSaG9zdF9rZXlfdW50cnVzdGVkGAUgASgIIkUKDkFkZFJlcG9SZXF1ZXN0EhYKBHJlcG8YASABKAsyCC52MS5SZXBvEhsKE3RydXN0X3NmdHBfaG9zdF9rZXkYAiABKAgiwAEKEURvUmVwb1Rhc2tSZXF1ZXN0Eg8KB3JlcG9faWQYASABKAkSKAoEdGFzaxgCIAEoDjIaLnYxLkRvUmVwb1Rhc2tSZXF1ZXN0LlRhc2sicAoEVGFzaxINCglUQVNLX05PTkUQABIYChRUQVNLX0lOREVYX1NOQVBTSE9UUxABEg4KClRBU0tfUFJVTkUQAhIOCgpUQVNLX0NIRUNLEAMSDgoKVEFTS19TVEFUUxAEEg8KC1RBU0tfVU5MT0NLEAUiTAoTQ2xlYXJIaXN0b3J5UmVxdWVzdBIgCghzZWxlY3RvchgBIAEoCzIOLnYxLk9wU2VsZWN0b3ISEwoLb25seV9mYWlsZWQYAiABKAgiRgoNRm9yZ2V0UmVxdWVzdBIPCgdyZXBvX2lkGAEgASgJEg8KB3BsYW5faWQYAiABKAkSEwoLc25hcHNob3RfaWQYAyABKAkiOAoUTGlzdFNuYXBzaG90c1JlcXVlc3QSDwoHcmVwb19pZBgBIAEoCRIPCgdwbGFuX2lkGAIgASgJIkgKFEdldE9wZXJhdGlvbnNSZXF1ZXN0EiAKCHNlbGVjdG9yGAEgASgLMg4udjEuT3BTZWxlY3RvchIOCgZsYXN0X24YAiABKAMibQoWUmVzdG9yZVNuYXBzaG90UmVxdWVzdBIPCgdwbGFuX2lkGAEgASgJEg8KB3JlcG9faWQYBSABKAkSEwoLc25hcHNob3RfaWQYAiABKAkSDAoEcGF0aBgDIAEoCRIOCgZ0YXJnZXQYBCABKAkiUAoYTGlzdFNuYXBzaG90RmlsZXNSZXF1ZXN0EhEKCXJlcG9fZ3VpZBgBIAEoCRITCgtzbmFwc2hvdF9pZBgCIAEoCRIMCgRwYXRoGAMgASgJIkcKGUxpc3RTbmFwc2hvdEZpbGVzUmVzcG9uc2USDAoEcGF0aBgBIAEoCRIcCgdlbnRyaWVzGAIgAygLMgsudjEuTHNFbnRyeSIdCg5Mb2dEYXRhUmVxdWVzdBILCgNyZWYYASABKAkiOQoVR2V0RG93bmxvYWRVUkxSZXF1ZXN0Eg0KBW9wX2lkGAEgASgDEhEKCWZpbGVfcGF0aBgCIAEoCSKWAQoHTHNFbnRyeRIMCgRuYW1lGAEgASgJEgwKBHR5cGUYAiABKAkSDAoEcGF0aBgDIAEoCRILCgN1aWQYBCABKAMSCwoDZ2lkGAUgASgDEgwKBHNpemUYBiABKAMSDAoEbW9kZRgHIAEoAxINCgVtdGltZRgIIAEoCRINCgVhdGltZRgJIAEoCRINCgVjdGltZRgKIAEoCSI1ChFSdW5Db21tYW5kUmVxdWVzdBIPCgdyZXBvX2lkGAEgASgJEg8KB2NvbW1hbmQYAiABKAkitQUKGFN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZRI8Cg5yZXBvX3N1bW1hcmllcxgBIAMoCzIkLnYxLlN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZS5TdW1tYXJ5EjwKDnBsYW5fc3VtbWFyaWVzGAIgAygLMiQudjEuU3VtbWFyeURhc2hib2FyZFJlc3BvbnNlLlN1bW1hcnkSEwoLY29uZmlnX3BhdGgYCiABKAkSEQoJZGF0YV9wYXRoGAsgASgJGu4CCgdTdW1tYXJ5EgoKAmlkGAEgASgJEh0KFWJhY2t1cHNfZmFpbGVkXzMwZGF5cxgCIAEoAxIjChtiYWNrdXBzX3dhcm5pbmdfbGFzdF8zMGRheXMYAyABKAMSIwobYmFja3Vwc19zdWNjZXNzX2xhc3RfMzBkYXlzGAQgASgDEiEKGWJ5dGVzX3NjYW5uZWRfbGFzdF8zMGRheXMYBSABKAMSHwoXYnl0ZXNfYWRkZWRfbGFzdF8zMGRheXMYBiABKAMSFwoPdG90YWxfc25hcHNob3RzGAcgASgDEhkKEWJ5dGVzX3NjYW5uZWRfYXZnGAggASgDEhcKD2J5dGVzX2FkZGVkX2F2ZxgJIAEoAxIbChNuZXh0X2JhY2t1cF90aW1lX21zGAogASgDEkAKDnJlY2VudF9iYWNrdXBzGAsgASgLMigudjEuU3VtbWFyeURhc2hib2FyZFJlc3BvbnNlLkJhY2t1cENoYXJ0GoMBCgtCYWNrdXBDaGFydBIPCgdmbG93X2lkGAEgAygDEhQKDHRpbWVzdGFtcF9tcxgCIAMoAxITCgtkdXJhdGlvbl9tcxgDIAMoAxIjCgZzdGF0dXMYBCADKA4yEy52MS5PcGVyYXRpb25TdGF0dXMSEwoLYnl0ZXNfYWRkZWQYBSADKAMykgoKCEJhY2tyZXN0EjEKCUdldENvbmZpZxIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRoKLnYxLkNvbmZpZyIAEiUKCVNldENvbmZpZxIKLnYxLkNvbmZpZxoKLnYxLkNvbmZpZyIAEjoKCVNldHVwU2Z0cBIULnYxLlNldHVwU2Z0cFJlcXVlc3QaFS52MS5TZXR1cFNmdHBSZXNwb25zZSIAEkwKD0NoZWNrUmVwb0V4aXN0cxIaLnYxLkNoZWNrUmVwb0V4aXN0c1JlcXVlc3QaGy52MS5DaGVja1JlcG9FeGlzdHNSZXNwb25zZSIAEisKB0FkZFJlcG8SEi52MS5BZGRSZXBvUmVxdWVzdBoKLnYxLkNvbmZpZyIAEi4KClJlbW92ZVJlcG8SEi50eXBlcy5TdHJpbmdWYWx1ZRoKLnYxLkNvbmZpZyIAEkQKEkdldE9wZXJhdGlvbkV2ZW50cxIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRoSLnYxLk9wZXJhdGlvbkV2ZW50IgAwARI+Cg1HZXRPcGVyYXRpb25zEhgudjEuR2V0T3BlcmF0aW9uc1JlcXVlc3QaES52MS5PcGVyYXRpb25MaXN0IgASQwoNTGlzdFNuYXBzaG90cxIYLnYxLkxpc3RTbmFwc2hvdHNSZXF1ZXN0GhYudjEuUmVzdGljU25hcHNob3RMaXN0IgASUgoRTGlzdFNuYXBzaG90RmlsZXMSHC52MS5MaXN0U25hcHNob3RGaWxlc1JlcXVlc3QaHS52MS5MaXN0U25hcHNob3RGaWxlc1Jlc3BvbnNlIgASNgoGQmFja3VwEhIudHlwZXMuU3RyaW5nVmFsdWUaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABI9CgpEb1JlcG9UYXNrEhUudjEuRG9SZXBvVGFza1JlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABI1CgZGb3JnZXQSES52MS5Gb3JnZXRSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IgASPwoHUmVzdG9yZRIaLnYxLlJlc3RvcmVTbmFwc2hvdFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABI1CgZDYW5jZWwSES50eXBlcy5JbnQ2NFZhbHVlGhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IgASNAoHR2V0TG9ncxISLnYxLkxvZ0RhdGFSZXF1ZXN0GhEudHlwZXMuQnl0ZXNWYWx1ZSIAMAESOAoKUnVuQ29tbWFuZBIVLnYxLlJ1bkNvbW1hbmRSZXF1ZXN0GhEudHlwZXMuSW50NjRWYWx1ZSIAEkEKDkdldERvd25sb2FkVVJMEhkudjEuR2V0RG93bmxvYWRVUkxSZXF1ZXN0GhIudHlwZXMuU3RyaW5nVmFsdWUiABJBCgxDbGVhckhpc3RvcnkSFy52MS5DbGVhckhpc3RvcnlSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IgASOwoQUGF0aEF1dG9jb21wbGV0ZRISLnR5cGVzLlN0cmluZ1ZhbHVlGhEudHlwZXMuU3RyaW5nTGlzdCIAEk0KE0dldFN1bW1hcnlEYXNoYm9hcmQSFi5nb29nbGUucHJvdG9idWYuRW1wdHkaHC52MS5TdW1tYXJ5RGFzaGJvYXJkUmVzcG9uc2UiAEIsWipnaXRodWIuY29tL2dhcmV0aGdlb3JnZS9iYWNrcmVzdC9nZW4vZ28vdjFiBnByb3RvMw", [file_v1_config, file_v1_restic, file_v1_operations, file_types_value, file_google_protobuf_empty, file_google_api_annotations]); /** * OpSelector is a message that can be used to select operations e.g. by query. @@ -77,6 +77,143 @@ export type OpSelector = Message<"v1.OpSelector"> & { export const OpSelectorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_v1_service, 0); +/** + * @generated from message v1.SetupSftpRequest + */ +export type SetupSftpRequest = Message<"v1.SetupSftpRequest"> & { + /** + * @generated from field: string host = 1; + */ + host: string; + + /** + * @generated from field: string port = 2; + */ + port: string; + + /** + * @generated from field: string username = 3; + */ + username: string; + + /** + * If not provided, we only generate the key and add host to known_hosts + * + * @generated from field: optional string password = 4; + */ + password?: string; +}; + +/** + * Describes the message v1.SetupSftpRequest. + * Use `create(SetupSftpRequestSchema)` to create a new message. + */ +export const SetupSftpRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_v1_service, 1); + +/** + * @generated from message v1.SetupSftpResponse + */ +export type SetupSftpResponse = Message<"v1.SetupSftpResponse"> & { + /** + * @generated from field: string public_key = 1; + */ + publicKey: string; + + /** + * @generated from field: string key_path = 2; + */ + keyPath: string; + + /** + * @generated from field: string known_hosts_path = 3; + */ + knownHostsPath: string; + + /** + * @generated from field: string error = 4; + */ + error: string; +}; + +/** + * Describes the message v1.SetupSftpResponse. + * Use `create(SetupSftpResponseSchema)` to create a new message. + */ +export const SetupSftpResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_v1_service, 2); + +/** + * @generated from message v1.CheckRepoExistsRequest + */ +export type CheckRepoExistsRequest = Message<"v1.CheckRepoExistsRequest"> & { + /** + * @generated from field: v1.Repo repo = 1; + */ + repo?: Repo; + + /** + * @generated from field: bool trust_sftp_host_key = 2; + */ + trustSftpHostKey: boolean; +}; + +/** + * Describes the message v1.CheckRepoExistsRequest. + * Use `create(CheckRepoExistsRequestSchema)` to create a new message. + */ +export const CheckRepoExistsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_v1_service, 3); + +/** + * @generated from message v1.CheckRepoExistsResponse + */ +export type CheckRepoExistsResponse = Message<"v1.CheckRepoExistsResponse"> & { + /** + * @generated from field: bool exists = 1; + */ + exists: boolean; + + /** + * @generated from field: string error = 2; + */ + error: string; + + /** + * @generated from field: bool host_key_untrusted = 5; + */ + hostKeyUntrusted: boolean; +}; + +/** + * Describes the message v1.CheckRepoExistsResponse. + * Use `create(CheckRepoExistsResponseSchema)` to create a new message. + */ +export const CheckRepoExistsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_v1_service, 4); + +/** + * @generated from message v1.AddRepoRequest + */ +export type AddRepoRequest = Message<"v1.AddRepoRequest"> & { + /** + * @generated from field: v1.Repo repo = 1; + */ + repo?: Repo; + + /** + * @generated from field: bool trust_sftp_host_key = 2; + */ + trustSftpHostKey: boolean; +}; + +/** + * Describes the message v1.AddRepoRequest. + * Use `create(AddRepoRequestSchema)` to create a new message. + */ +export const AddRepoRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_v1_service, 5); + /** * @generated from message v1.DoRepoTaskRequest */ @@ -97,7 +234,7 @@ export type DoRepoTaskRequest = Message<"v1.DoRepoTaskRequest"> & { * Use `create(DoRepoTaskRequestSchema)` to create a new message. */ export const DoRepoTaskRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1_service, 1); + messageDesc(file_v1_service, 6); /** * @generated from enum v1.DoRepoTaskRequest.Task @@ -138,7 +275,7 @@ export enum DoRepoTaskRequest_Task { * Describes the enum v1.DoRepoTaskRequest.Task. */ export const DoRepoTaskRequest_TaskSchema: GenEnum = /*@__PURE__*/ - enumDesc(file_v1_service, 1, 0); + enumDesc(file_v1_service, 6, 0); /** * @generated from message v1.ClearHistoryRequest @@ -160,7 +297,7 @@ export type ClearHistoryRequest = Message<"v1.ClearHistoryRequest"> & { * Use `create(ClearHistoryRequestSchema)` to create a new message. */ export const ClearHistoryRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1_service, 2); + messageDesc(file_v1_service, 7); /** * @generated from message v1.ForgetRequest @@ -187,7 +324,7 @@ export type ForgetRequest = Message<"v1.ForgetRequest"> & { * Use `create(ForgetRequestSchema)` to create a new message. */ export const ForgetRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1_service, 3); + messageDesc(file_v1_service, 8); /** * @generated from message v1.ListSnapshotsRequest @@ -209,7 +346,7 @@ export type ListSnapshotsRequest = Message<"v1.ListSnapshotsRequest"> & { * Use `create(ListSnapshotsRequestSchema)` to create a new message. */ export const ListSnapshotsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1_service, 4); + messageDesc(file_v1_service, 9); /** * @generated from message v1.GetOperationsRequest @@ -233,7 +370,7 @@ export type GetOperationsRequest = Message<"v1.GetOperationsRequest"> & { * Use `create(GetOperationsRequestSchema)` to create a new message. */ export const GetOperationsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1_service, 5); + messageDesc(file_v1_service, 10); /** * @generated from message v1.RestoreSnapshotRequest @@ -270,7 +407,7 @@ export type RestoreSnapshotRequest = Message<"v1.RestoreSnapshotRequest"> & { * Use `create(RestoreSnapshotRequestSchema)` to create a new message. */ export const RestoreSnapshotRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1_service, 6); + messageDesc(file_v1_service, 11); /** * @generated from message v1.ListSnapshotFilesRequest @@ -297,7 +434,7 @@ export type ListSnapshotFilesRequest = Message<"v1.ListSnapshotFilesRequest"> & * Use `create(ListSnapshotFilesRequestSchema)` to create a new message. */ export const ListSnapshotFilesRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1_service, 7); + messageDesc(file_v1_service, 12); /** * @generated from message v1.ListSnapshotFilesResponse @@ -319,7 +456,7 @@ export type ListSnapshotFilesResponse = Message<"v1.ListSnapshotFilesResponse"> * Use `create(ListSnapshotFilesResponseSchema)` to create a new message. */ export const ListSnapshotFilesResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1_service, 8); + messageDesc(file_v1_service, 13); /** * @generated from message v1.LogDataRequest @@ -336,7 +473,7 @@ export type LogDataRequest = Message<"v1.LogDataRequest"> & { * Use `create(LogDataRequestSchema)` to create a new message. */ export const LogDataRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1_service, 9); + messageDesc(file_v1_service, 14); /** * @generated from message v1.GetDownloadURLRequest @@ -358,7 +495,7 @@ export type GetDownloadURLRequest = Message<"v1.GetDownloadURLRequest"> & { * Use `create(GetDownloadURLRequestSchema)` to create a new message. */ export const GetDownloadURLRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1_service, 10); + messageDesc(file_v1_service, 15); /** * @generated from message v1.LsEntry @@ -420,7 +557,7 @@ export type LsEntry = Message<"v1.LsEntry"> & { * Use `create(LsEntrySchema)` to create a new message. */ export const LsEntrySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1_service, 11); + messageDesc(file_v1_service, 16); /** * @generated from message v1.RunCommandRequest @@ -442,7 +579,7 @@ export type RunCommandRequest = Message<"v1.RunCommandRequest"> & { * Use `create(RunCommandRequestSchema)` to create a new message. */ export const RunCommandRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1_service, 12); + messageDesc(file_v1_service, 17); /** * @generated from message v1.SummaryDashboardResponse @@ -474,7 +611,7 @@ export type SummaryDashboardResponse = Message<"v1.SummaryDashboardResponse"> & * Use `create(SummaryDashboardResponseSchema)` to create a new message. */ export const SummaryDashboardResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1_service, 13); + messageDesc(file_v1_service, 18); /** * @generated from message v1.SummaryDashboardResponse.Summary @@ -545,7 +682,7 @@ export type SummaryDashboardResponse_Summary = Message<"v1.SummaryDashboardRespo * Use `create(SummaryDashboardResponse_SummarySchema)` to create a new message. */ export const SummaryDashboardResponse_SummarySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1_service, 13, 0); + messageDesc(file_v1_service, 18, 0); /** * @generated from message v1.SummaryDashboardResponse.BackupChart @@ -582,7 +719,7 @@ export type SummaryDashboardResponse_BackupChart = Message<"v1.SummaryDashboardR * Use `create(SummaryDashboardResponse_BackupChartSchema)` to create a new message. */ export const SummaryDashboardResponse_BackupChartSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_v1_service, 13, 1); + messageDesc(file_v1_service, 18, 1); /** * @generated from service v1.Backrest @@ -604,20 +741,28 @@ export const Backrest: GenService<{ input: typeof ConfigSchema; output: typeof ConfigSchema; }, + /** + * @generated from rpc v1.Backrest.SetupSftp + */ + setupSftp: { + methodKind: "unary"; + input: typeof SetupSftpRequestSchema; + output: typeof SetupSftpResponseSchema; + }, /** * @generated from rpc v1.Backrest.CheckRepoExists */ checkRepoExists: { methodKind: "unary"; - input: typeof RepoSchema; - output: typeof BoolValueSchema; + input: typeof CheckRepoExistsRequestSchema; + output: typeof CheckRepoExistsResponseSchema; }, /** * @generated from rpc v1.Backrest.AddRepo */ addRepo: { methodKind: "unary"; - input: typeof RepoSchema; + input: typeof AddRepoRequestSchema; output: typeof ConfigSchema; }, /** diff --git a/webui/src/features/repositories/AddRepoModal.tsx b/webui/src/features/repositories/AddRepoModal.tsx index d5c6c7f2..4b79fff2 100644 --- a/webui/src/features/repositories/AddRepoModal.tsx +++ b/webui/src/features/repositories/AddRepoModal.tsx @@ -10,6 +10,7 @@ import { } from "@chakra-ui/react"; import { EnumSelector, EnumOption } from "../../components/common/EnumSelector"; import { Checkbox } from "../../components/ui/checkbox"; + import { AccordionItem, AccordionItemContent, @@ -27,6 +28,11 @@ import { RepoSchema, Schedule_Clock, } from "../../../gen/ts/v1/config_pb"; +import { + AddRepoRequestSchema, + CheckRepoExistsRequestSchema, + SetupSftpRequestSchema, +} from "../../../gen/ts/v1/service_pb"; import { StringValueSchema } from "../../../gen/ts/types/value_pb"; import { URIAutocomplete } from "../../components/common/URIAutocomplete"; import { alerts, formatErrorAlert } from "../../components/common/Alerts"; @@ -52,6 +58,16 @@ import { hooksListTooltipText, } from "../../components/common/HooksFormList"; import { DynamicList } from "../../components/common/DynamicList"; +import { + DialogActionTrigger, + DialogBody, + DialogCloseTrigger, + DialogContent, + DialogFooter, + DialogHeader, + DialogRoot, + DialogTitle, +} from "../../components/ui/dialog"; const repoDefaults = create(RepoSchema, { prunePolicy: { @@ -79,6 +95,191 @@ const repoDefaults = create(RepoSchema, { }, }); +interface ConfirmationState { + open: boolean; + title: string; + content: React.ReactNode; + onOk: () => void; +} + +interface SftpConfigSectionProps { + uri: string | undefined; + identityFile: string; + onChangeIdentityFile: (path: string) => void; + port: number | null; + onChangePort: (port: number | null) => void; + onChangeKnownHostsPath: (path: string) => void; + isWindows: boolean; +} + +const SftpConfigSection = ({ + uri, + identityFile, + onChangeIdentityFile, + port, + onChangePort, + onChangeKnownHostsPath, + isWindows, +}: SftpConfigSectionProps) => { + // Setup Keys state + const [sftpUsername, setSftpUsername] = useState(""); + const [sftpPassword, setSftpPassword] = useState(""); + const [setupLoading, setSetupLoading] = useState(false); + const [generatedPublicKey, setGeneratedPublicKey] = useState( + null, + ); + + if (isWindows) return null; + + const handleSetupKeys = async () => { + setSetupLoading(true); + setGeneratedPublicKey(null); + try { + if (!uri) return; + // Simple parse of URI for host/port if not fully robust + let host = ""; + let defaultPort = "22"; + const uriParts = uri.replace("sftp:", "").split("/"); + const authority = uriParts[0]; + let hostPart = authority; + if (authority.includes("@")) { + setSftpUsername(authority.split("@")[0]); + hostPart = authority.split("@")[1]; + } + + if (hostPart.includes(":")) { + host = hostPart.split(":")[0]; + defaultPort = hostPart.split(":")[1]; + } else { + host = hostPart; + } + + // Override from manual input if username is set there + const username = + sftpUsername || uri.match(/([^@]+)@/)?.[1] || ""; + + const res = await backrestService.setupSftp({ + host: host, + port: port ? port.toString() : defaultPort, + username: username, + password: sftpPassword || undefined, + }); + + if (res.error) { + throw new Error(res.error); + } + + onChangeIdentityFile(res.keyPath); + onChangeKnownHostsPath(res.knownHostsPath); + if (res.publicKey) { + setGeneratedPublicKey(res.publicKey); + } + alerts.success( + "Created SSH keypair at " + res.keyPath + " and updated known hosts file at " + res.knownHostsPath, + ); + alerts.success( + "Updated restic flags to use the SSH keypair and known hosts file." + ); + } catch (e: any) { + alerts.error(formatErrorAlert(e, "SFTP Setup Failed")); + } finally { + setSetupLoading(false); + } + }; + + return ( + + {!generatedPublicKey && !identityFile && ( + + + + Bootstrap SSH Key (Optional) + + + + + Enter your SSH credentials here. When you click "Setup Keys", + backrest will generate an SSH key pair. + + + setSftpUsername(e.target.value)} + /> + + + setSftpPassword(e.target.value)} + /> + + + + + + + )} + + {generatedPublicKey && ( + + + + Key Generated Successfully! + + + Please add the following public key to your server's{" "} + ~/.ssh/authorized_keys file: + + + + {generatedPublicKey} + + + + + + + + )} + + + onChangeIdentityFile(e.target.value)} + /> + + + + onChangePort(e.valueAsNumber)} + min={1} + max={65535} + defaultValue={"22"} + /> + + + ); +}; + export const AddRepoModal = ({ template }: { template: Repo | null }) => { const [confirmLoading, setConfirmLoading] = useState(false); const showModal = useShowModal(); @@ -91,12 +292,32 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { : toJson(RepoSchema, repoDefaults, { alwaysEmitImplicit: true }), ); + // SFTP specific state + // SFTP specific state + const [sftpIdentityFile, setSftpIdentityFile] = useState(""); + const [sftpPort, setSftpPort] = useState(null); + const [sftpKnownHostsPath, setSftpKnownHostsPath] = useState(""); + + const [confirmation, setConfirmation] = useState({ + open: false, + title: "", + content: null, + onOk: () => {}, + }); + useEffect(() => { setFormData( template ? toJson(RepoSchema, template, { alwaysEmitImplicit: true }) : toJson(RepoSchema, repoDefaults, { alwaysEmitImplicit: true }), ); + // Reset SFTP fields when template changes (or is null) + if (!template) { + + setSftpIdentityFile(""); + setSftpPort(null); + setSftpKnownHostsPath(""); + } }, [template]); const updateField = (path: string[], value: any) => { @@ -121,6 +342,64 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { return curr; }; + // Logic to update flags based on SFTP inputs + useEffect(() => { + // If we are editing, we don't touch the flags. The user can edit them manually. + if (template) { + return; + } + + const uri = getField(["uri"]); + if (!uri?.startsWith("sftp:")) { + return; + } + + const currentFlags = getField(["flags"]) || []; + const newFlags = currentFlags.filter( + (f: string) => + f && !f.includes("sftp.args") && !f.includes("sftp.command"), + ); + + let sftpArgs = "-oBatchMode=yes"; + let argsChanged = false; + + if (sftpIdentityFile) { + let cleanPath = sftpIdentityFile; + if (cleanPath.startsWith("@")) { + cleanPath = cleanPath.substring(1); + } + sftpArgs += ` -i ${cleanPath}`; + argsChanged = true; + } + + if (sftpPort && sftpPort !== 0 && sftpPort !== 22) { + sftpArgs += ` -p ${sftpPort}`; + argsChanged = true; + } + + if (sftpKnownHostsPath) { + sftpArgs += ` -oUserKnownHostsFile=${sftpKnownHostsPath}`; + argsChanged = true; + } + + if (argsChanged) { + newFlags.push(`--option=sftp.args='${sftpArgs}'`); + } + + const sortedCurrent = [...currentFlags].sort(); + const sortedNew = [...newFlags].sort(); + + if (JSON.stringify(sortedCurrent) !== JSON.stringify(sortedNew)) { + updateField(["flags"], newFlags); + } + }, [ + getField(["uri"]), + sftpIdentityFile, + sftpPort, + template, + getField(["flags"]), + ]); + if (!config) return null; const validateLocal = async () => { @@ -174,59 +453,155 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { } }; + const verifySftpHostKey = async ( + action: (trust: boolean) => Promise, + ) => { + try { + await action(false); + } catch (e: any) { + if ( + e.message && + e.message.includes("SFTP host key verification failed") + ) { + setConfirmation({ + open: true, + title: "Unknown SFTP Host Key", + content: ( + <> + The host key for this SFTP server is not known. +
+ Do you want to trust this host and add its key to your known_hosts + file? + + ), + onOk: async () => { + setConfirmation((prev) => ({ ...prev, open: false })); + setConfirmLoading(true); + try { + await action(true); + } catch (retryErr: any) { + alerts.error( + formatErrorAlert(retryErr, "Operation error: "), + ); + } finally { + setConfirmLoading(false); + } + }, + }); + } else { + throw e; + } + } + }; + const handleOk = async () => { setConfirmLoading(true); try { await validateLocal(); - const repo = fromJson(RepoSchema, formData, { - ignoreUnknownFields: true, - }); + const doSubmit = async (trust: boolean) => { + const repo = fromJson(RepoSchema, formData, { + ignoreUnknownFields: true, + }); - if (template !== null) { - setConfig(await backrestService.addRepo(repo)); - showModal(null); - alerts.success(m.add_repo_modal_success_updated({ uri: repo.uri })); - } else { - setConfig(await backrestService.addRepo(repo)); - showModal(null); - alerts.success(m.add_repo_modal_success_added({ uri: repo.uri })); - } + const req = create(AddRepoRequestSchema, { + repo: repo, + trustSftpHostKey: trust, + }); - try { - await backrestService.listSnapshots({ repoId: repo.id }); - } catch (e: any) { - alerts.error( - formatErrorAlert(e, m.add_repo_modal_error_list_snapshots()), - ); - return; - } + if (template !== null) { + setConfig(await backrestService.addRepo(req)); + showModal(null); + alerts.success(m.add_repo_modal_success_updated({ uri: repo.uri })); + } else { + setConfig(await backrestService.addRepo(req)); + showModal(null); + alerts.success(m.add_repo_modal_success_added({ uri: repo.uri })); + } + + try { + await backrestService.listSnapshots({ repoId: repo.id }); + } catch (e: any) { + alerts.error( + formatErrorAlert(e, m.add_repo_modal_error_list_snapshots()), + ); + } + }; + + await verifySftpHostKey(doSubmit); } catch (e: any) { alerts.error( formatErrorAlert(e, m.add_plan_modal_error_operation_prefix()), ); - return; } finally { setConfirmLoading(false); } }; const handleTest = async () => { + setConfirmLoading(true); try { await validateLocal(); - const repo = fromJson(RepoSchema, formData, { - ignoreUnknownFields: true, - }); - const exists = await backrestService.checkRepoExists(repo); - if (exists.value) { - alerts.success( - m.add_repo_modal_test_success_existing({ uri: repo.uri }), - ); - } else { - alerts.success(m.add_repo_modal_test_success_new({ uri: repo.uri })); - } + const doCheck = async (trust: boolean, confirm: boolean) => { + const repo = fromJson(RepoSchema, formData, { + ignoreUnknownFields: true, + }); + const req = create(CheckRepoExistsRequestSchema, { + repo: repo, + trustSftpHostKey: trust, + }); + + const response = await backrestService.checkRepoExists(req); + + if (response.hostKeyUntrusted) { + setConfirmation({ + open: true, + title: "Unknown SFTP Host Key", + content: ( + <> + The host key for this SFTP server is not known. +
+ Do you want to trust this host and add its key to your + known_hosts file? + + ), + onOk: () => { + setConfirmation((prev) => ({ ...prev, open: false })); + handleTestWrapper(true, confirm); + }, + }); + return; + } + + if (response.error) { + throw new Error(response.error); + } + + if (response.exists) { + alerts.success( + m.add_repo_modal_test_success_existing({ uri: repo.uri }), + ); + } else { + alerts.success(m.add_repo_modal_test_success_new({ uri: repo.uri })); + } + }; + + // Wrapper to handle re-entry from dialog + const handleTestWrapper = async (trust: boolean, confirm: boolean) => { + setConfirmLoading(true); + try { + await doCheck(trust, confirm); + } catch (e: any) { + alerts.error(formatErrorAlert(e, m.add_repo_modal_test_error())); + } finally { + setConfirmLoading(false); + } + }; + + await handleTestWrapper(false, false); } catch (e: any) { alerts.error(formatErrorAlert(e, m.add_repo_modal_test_error())); + setConfirmLoading(false); } }; @@ -272,388 +647,430 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { ]; return ( - showModal(null)} - title={ - template ? m.add_repo_modal_title_edit() : m.add_repo_modal_title_add() - } - size="large" - footer={ - - - {template && ( - + + setConfirmation((prev) => ({ ...prev, open: e.open })) + } + > + + + {confirmation.title} + + {confirmation.content} + + + + + + + + + + + showModal(null)} + title={ + template + ? m.add_repo_modal_title_edit() + : m.add_repo_modal_title_add() + } + size="large" + footer={ + + - - } - > - -

- {m.add_repo_modal_guide_text_p1()}{" "} - - {m.add_repo_modal_guide_link_text()} - {" "} - {m.add_repo_modal_guide_text_p2()}{" "} - - {m.add_repo_modal_guide_restic_link_text()} - {" "} - {m.add_repo_modal_guide_text_p3()} -

+ {m.add_plan_modal_button_cancel()} + + {template && ( + + {m.add_plan_modal_button_delete()} + + )} + + +
+ } + > + +

+ {m.add_repo_modal_guide_text_p1()}{" "} + + {m.add_repo_modal_guide_link_text()} + {" "} + {m.add_repo_modal_guide_text_p2()}{" "} + + {m.add_repo_modal_guide_restic_link_text()} + {" "} + {m.add_repo_modal_guide_text_p3()} +

-
- - - - r.id === getField(["id"])))) - } - errorText={ - !!getField(["id"]) && !namePattern.test(getField(["id"])) - ? m.add_plan_modal_validation_plan_name_pattern() - : m.add_repo_modal_error_repo_exists() - } - > - ) => - updateField(["id"], e.target.value) +
+ + + + - + required + invalid={ + !!getField(["id"]) && + (!namePattern.test(getField(["id"])) || + (!template && + !!config.repos.find((r) => r.id === getField(["id"])))) + } + errorText={ + !!getField(["id"]) && !namePattern.test(getField(["id"])) + ? m.add_plan_modal_validation_plan_name_pattern() + : m.add_repo_modal_error_repo_exists() + } + > + ) => + updateField(["id"], e.target.value) + } + disabled={!!template} + placeholder={"repo" + ((config?.repos?.length || 0) + 1)} + /> + - - {m.add_repo_modal_field_uri_tooltip_title()} - -
  • {m.add_repo_modal_field_uri_tooltip_local()}
  • -
  • {m.add_repo_modal_field_uri_tooltip_s3()}
  • -
  • {m.add_repo_modal_field_uri_tooltip_sftp()}
  • -
  • - {m.add_repo_modal_field_uri_tooltip_see()}{" "} - - {m.add_repo_modal_field_uri_tooltip_restic_docs()} - {" "} - {m.add_repo_modal_field_uri_tooltip_info()} -
  • -
    - - } - required - > - updateField(["uri"], val)} - /> -
    - - - {m.add_repo_modal_field_password_tooltip_intro()} + {m.add_repo_modal_field_uri_tooltip_title()} +
  • {m.add_repo_modal_field_uri_tooltip_local()}
  • +
  • {m.add_repo_modal_field_uri_tooltip_s3()}
  • +
  • {m.add_repo_modal_field_uri_tooltip_sftp()}
  • - {m.add_repo_modal_field_password_tooltip_entropy()} -
  • -
  • - {m.add_repo_modal_field_password_tooltip_env()} -
  • -
  • - {m.add_repo_modal_field_password_tooltip_generate()} + {m.add_repo_modal_field_uri_tooltip_see()}{" "} + + {m.add_repo_modal_field_uri_tooltip_restic_docs()} + {" "} + {m.add_repo_modal_field_uri_tooltip_info()}
  • - ) : undefined - } - > - - - ) => - updateField(["password"], e.target.value) - } - disabled={!!template} - /> - - {!template && ( - - )} - -
    - - - updateField(["autoUnlock"], !!e.checked)} + } + required > - {m.add_repo_modal_field_auto_unlock()} - - - {m.add_repo_modal_field_auto_unlock_tooltip()} - - -
    -
    -
    -
    - -
    - - - - updateField(["env"], items)} - tooltip={ - - - {m.add_repo_modal_field_env_vars_tooltip()} - - - - } - placeholder="KEY=VALUE" - /> - - updateField(["flags"], items)} - placeholder="--flag" - /> - - - -
    - -
    - {m.add_repo_modal_field_prune_policy_tooltip_p1()}{" "} - - {m.add_repo_modal_field_prune_policy_tooltip_link()} - {" "} - {m.add_repo_modal_field_prune_policy_tooltip_p2()} - - } - > - {m.add_repo_modal_field_prune_policy()} - - } - > - - - - - updateField( - ["prunePolicy", "maxUnusedPercent"], - e.valueAsNumber, - ) - } - /> - - updateField(["prunePolicy", "schedule"], val) - } - defaults={ScheduleDefaultsInfrequent} - /> - - - -
    - -
    - {m.add_repo_modal_field_check_policy()} - - } - > - - - - - updateField( - ["checkPolicy", "readDataSubsetPercent"], - e.valueAsNumber, - ) - } - /> - - updateField(["checkPolicy", "schedule"], val) - } - defaults={ScheduleDefaultsInfrequent} - /> - - - -
    - -
    - - - - {!isWindows && ( - - - - - updateField( - ["commandPrefix", "ioNice"], - val as string, - ) - } - placeholder={m.add_repo_modal_field_io_priority_placeholder()} - /> - - - - updateField( - ["commandPrefix", "cpuNice"], - val as string, - ) - } - placeholder={m.add_repo_modal_field_cpu_priority_placeholder()} - /> - - + updateField(["uri"], val)} + /> - )} - - updateField(["hooks"], v)} + {/* SFTP Specific Fields */} + {getField(["uri"])?.startsWith("sftp:") && !template && ( + + )} + + + {m.add_repo_modal_field_password_tooltip_intro()} + +
  • + {m.add_repo_modal_field_password_tooltip_entropy()} +
  • +
  • + {m.add_repo_modal_field_password_tooltip_env()} +
  • +
  • + {m.add_repo_modal_field_password_tooltip_generate()} +
  • +
    + + ) : undefined + } + > + + + ) => + updateField(["password"], e.target.value) + } + disabled={!!template} + /> + + {!template && ( + + )} + +
    + + + updateField(["autoUnlock"], !!e.checked)} + > + {m.add_repo_modal_field_auto_unlock()} + + + {m.add_repo_modal_field_auto_unlock_tooltip()} + + +
    +
    +
    +
    + +
    + + + + updateField(["env"], items)} + tooltip={ + + + {m.add_repo_modal_field_env_vars_tooltip()} + + + + } + placeholder="KEY=VALUE" /> - - - - -
    - {/* JSON Preview */} - - - - - {m.add_repo_modal_preview_json()} - - - - updateField(["flags"], items)} + placeholder="--flag" + /> +
    +
    +
    +
    + +
    + {m.add_repo_modal_field_prune_policy_tooltip_p1()}{" "} + + {m.add_repo_modal_field_prune_policy_tooltip_link()} + {" "} + {m.add_repo_modal_field_prune_policy_tooltip_p2()} + + } > - {JSON.stringify(formData, null, 2)} - - - - - - + {m.add_repo_modal_field_prune_policy()} + + } + > + + + + + updateField( + ["prunePolicy", "maxUnusedPercent"], + e.valueAsNumber, + ) + } + /> + + updateField(["prunePolicy", "schedule"], val) + } + defaults={ScheduleDefaultsInfrequent} + /> + + + +
    + +
    + {m.add_repo_modal_field_check_policy()} + + } + > + + + + + updateField( + ["checkPolicy", "readDataSubsetPercent"], + e.valueAsNumber, + ) + } + /> + + updateField(["checkPolicy", "schedule"], val) + } + defaults={ScheduleDefaultsInfrequent} + /> + + + +
    + +
    + + + + {!isWindows && ( + + + + + updateField( + ["commandPrefix", "ioNice"], + val as string, + ) + } + placeholder={m.add_repo_modal_field_io_priority_placeholder()} + /> + + + + updateField( + ["commandPrefix", "cpuNice"], + val as string, + ) + } + placeholder={m.add_repo_modal_field_cpu_priority_placeholder()} + /> + + + + )} + + + updateField(["hooks"], v)} + /> + + + + +
    + + {/* JSON Preview */} + + + + + {m.add_repo_modal_preview_json()} + + + + + {JSON.stringify(formData, null, 2)} + + + + +
    +
    + ); };