package api import ( "bytes" "context" "errors" "fmt" "io" "os" "path" "path/filepath" "runtime" "slices" "strings" "sync" "time" "connectrpc.com/connect" "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" "github.com/garethgeorge/backrest/internal/env" "github.com/garethgeorge/backrest/internal/logstore" "github.com/garethgeorge/backrest/internal/oplog" "github.com/garethgeorge/backrest/internal/orchestrator" "github.com/garethgeorge/backrest/internal/orchestrator/repo" "github.com/garethgeorge/backrest/internal/orchestrator/tasks" "github.com/garethgeorge/backrest/internal/protoutil" "github.com/garethgeorge/backrest/internal/resticinstaller" "github.com/garethgeorge/backrest/pkg/restic" "go.uber.org/zap" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/emptypb" ) type BackrestHandler struct { v1connect.UnimplementedBackrestHandler config config.ConfigStore orchestrator *orchestrator.Orchestrator oplog *oplog.OpLog logStore *logstore.LogStore peerStateManager syncapi.PeerStateManager } var _ v1connect.BackrestHandler = &BackrestHandler{} func NewBackrestHandler(config config.ConfigStore, peerStateManager syncapi.PeerStateManager, orchestrator *orchestrator.Orchestrator, oplog *oplog.OpLog, logStore *logstore.LogStore) *BackrestHandler { s := &BackrestHandler{ config: config, orchestrator: orchestrator, oplog: oplog, logStore: logStore, peerStateManager: peerStateManager, } return s } // GetConfig implements GET /v1/config func (s *BackrestHandler) GetConfig(ctx context.Context, req *connect.Request[emptypb.Empty]) (*connect.Response[v1.Config], error) { c, err := s.config.Get() if err != nil { return nil, fmt.Errorf("failed to get config: %w", err) } return connect.NewResponse(config.SanitizeForNetwork(c)), nil } // SetConfig implements POST /v1/config func (s *BackrestHandler) SetConfig(ctx context.Context, req *connect.Request[v1.Config]) (*connect.Response[v1.Config], error) { if err := s.config.Transform(func(cfg *v1.Config) (*v1.Config, error) { if cfg.Modno != req.Msg.Modno { return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("config modno mismatch, reload and try again")) } rehydrated := config.RehydrateNetworkSanitizedConfig(req.Msg, cfg) if err := config.ValidateConfig(rehydrated); err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("validation error: %w", err)) } rehydrated.Modno++ return rehydrated, nil }); err != nil { return nil, fmt.Errorf("failed to update config: %w", err) } newConfig, err := s.config.Get() if err != nil { return nil, fmt.Errorf("failed to get newly set config: %w", err) } return connect.NewResponse(config.SanitizeForNetwork(newConfig)), nil } // cloneConfigWithRepo fetches the current config, sanitizes and upserts r into a clone of it, // then locates the restic binary and builds a RepoOrchestrator for r against the cloned config. // oldRepo is the previously-existing repo with the same ID, or nil if r is new. func (s *BackrestHandler) cloneConfigWithRepo(r *v1.Repo) (cfg *v1.Config, oldRepo *v1.Repo, orch *repo.RepoOrchestrator, err error) { cfg, err = s.config.Get() if err != nil { return nil, nil, nil, fmt.Errorf("failed to get config: %w", err) } sanitizeRepoFlags(r) cfg = proto.Clone(cfg).(*v1.Config) if idx := slices.IndexFunc(cfg.Repos, func(x *v1.Repo) bool { return x.Id == r.Id }); idx != -1 { oldRepo = cfg.Repos[idx] cfg.Repos[idx] = r } else { cfg.Repos = append(cfg.Repos, r) } bin, err := resticinstaller.FindOrInstallResticBinary() if err != nil { return nil, nil, nil, fmt.Errorf("failed to find or install restic binary: %w", err) } orch, err = repo.NewRepoOrchestrator(cfg, r, bin) if err != nil { return nil, nil, nil, fmt.Errorf("failed to configure repo: %w", err) } return cfg, oldRepo, orch, nil } func (s *BackrestHandler) CheckRepoExists(ctx context.Context, req *connect.Request[v1.CheckRepoExistsRequest]) (*connect.Response[v1.CheckRepoExistsResponse], error) { if req.Msg.Repo.Guid == "" { req.Msg.Repo.Guid = cryptoutil.MustRandomID(cryptoutil.DefaultIDBits) } c, _, r, err := s.cloneConfigWithRepo(req.Msg.Repo) if err != nil { return nil, err } if err := config.ValidateConfig(c); err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("validation error: %w", err)) } ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() if err := r.Exists(ctx); err != nil { 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.Repo.Id) return connect.NewResponse(&v1.CheckRepoExistsResponse{Exists: false}), nil } // restic's sftp backend shells out to ssh; with an unknown or mismatched // host key (and BatchMode=yes) ssh prints a host-key verification failure // to stderr, which surfaces inside the wrapped restic error. Report this // to the UI so it can offer the host-key trust / Setup SSH Key flow instead // of a generic error. if isHostKeyVerificationError(err) { zap.S().Debugf("repo %q host key untrusted", req.Msg.Repo.Id) return connect.NewResponse(&v1.CheckRepoExistsResponse{HostKeyUntrusted: true}), nil } return nil, err } return connect.NewResponse(&v1.CheckRepoExistsResponse{Exists: true}), nil } // hostKeyVerificationMarkers are substrings (matched case-insensitively) that // OpenSSH emits to stderr when it refuses to connect because the server's host // key is unknown or does not match the pinned known_hosts entry. restic's sftp // backend forwards ssh's stderr into the error it returns. var hostKeyVerificationMarkers = []string{ "host key verification failed", "remote host identification has changed", } // isHostKeyVerificationError reports whether err (anywhere in its chain of // wrapped messages) looks like an ssh host-key verification failure. func isHostKeyVerificationError(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) for _, marker := range hostKeyVerificationMarkers { if strings.Contains(msg, marker) { return true } } return false } // 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.AddRepoRequest]) (*connect.Response[v1.Config], error) { newRepo := req.Msg.Repo // Perform all of the (slow) restic I/O against a candidate config BEFORE // committing any config mutation. This avoids clobbering a concurrent // SetConfig that lands during the I/O window: the actual config mutation is // applied atomically via s.config.Transform against the current config below. // // Ensure the Repo GUID is set to the correct value. // This is derived from 'restic cat config' for local repos. // For remote repos, the GUID is derived from the remote config's value for the repo. _, _, r, err := s.cloneConfigWithRepo(newRepo) if err != nil { return nil, err } if err := r.Init(ctx); err != nil { return nil, fmt.Errorf("failed to init repo: %w", err) } guid, err := r.RepoGUID() zap.S().Debugf("GUID for repo %q is %q from restic", newRepo.Id, guid) if err != nil { return nil, fmt.Errorf("failed to get repo config: %w", err) } newRepo.Guid = guid // Commit the config mutation atomically. Re-upsert the GUID-stamped repo into // the CURRENT config (not the pre-I/O snapshot) so a concurrent update during // the restic I/O above is preserved. oldRepo and instanceID are captured from // the transform-time config for correct GUID-change detection and oplog migration. var oldRepo *v1.Repo var instanceID string zap.L().Debug("updating config") if err := s.config.Transform(func(cfg *v1.Config) (*v1.Config, error) { instanceID = cfg.Instance if idx := slices.IndexFunc(cfg.Repos, func(x *v1.Repo) bool { return x.Id == newRepo.Id }); idx != -1 { oldRepo = cfg.Repos[idx] cfg.Repos[idx] = newRepo } else { oldRepo = nil cfg.Repos = append(cfg.Repos, newRepo) } if err := config.ValidateConfig(cfg); err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("validation error: %w", err)) } return cfg, nil }); err != nil { return nil, fmt.Errorf("failed to update config: %w", err) } // If the GUID has changed, and we just successfully updated the config in storage, then we need to migrate the oplog. if oldRepo != nil && newRepo.Guid != oldRepo.Guid { migratedCount := 0 q := oplog.Query{}. SetInstanceID(instanceID) // we use RepoID here to _ensure_ we consolidate all operations to the most recent GUID. // this provides some resiliancy in the case of a previous partial update. q.DeprecatedRepoID = &oldRepo.Id if err := s.oplog.Transform(q, func(op *v1.Operation) (*v1.Operation, error) { op.RepoGuid = newRepo.Guid migratedCount++ return op, nil }); err != nil { return nil, fmt.Errorf("failed to get operations for repo: %w", err) } zap.S().Infof("updated GUID for repo %q from %q to %q, migrated %d operations to reference the new GUID", newRepo.Id, oldRepo.Guid, newRepo.Guid, migratedCount) } // index snapshots for the newly added repository. zap.L().Debug("scheduling index snapshots task") s.orchestrator.ScheduleTask(tasks.NewOneoffIndexSnapshotsTask(newRepo, time.Now()), tasks.TaskPriorityInteractive+tasks.TaskPriorityIndexSnapshots) newConfig, err := s.config.Get() if err != nil { return nil, fmt.Errorf("failed to get newly set config: %w", err) } zap.L().Debug("done add repo") return connect.NewResponse(config.SanitizeForNetwork(newConfig)), nil } func (s *BackrestHandler) RemoveRepo(ctx context.Context, req *connect.Request[v1.RemoveRepoRequest]) (*connect.Response[v1.Config], error) { var instanceID string if err := s.config.Transform(func(cfg *v1.Config) (*v1.Config, error) { instanceID = cfg.Instance cfg.Repos = slices.DeleteFunc(cfg.Repos, func(r *v1.Repo) bool { return r.Id == req.Msg.RepoId }) return cfg, nil }); err != nil { return nil, fmt.Errorf("failed to update config: %w", err) } // Query for all operations for the repo q := oplog.Query{}.SetInstanceID(instanceID) q.DeprecatedRepoID = &req.Msg.RepoId var opIDs []int64 if err := s.oplog.Query(q, func(op *v1.Operation) error { opIDs = append(opIDs, op.Id) return nil }); err != nil { return nil, fmt.Errorf("failed to get operations for repo: %w", err) } // Delete operations referencing the repo from the oplog in batches for len(opIDs) > 0 { batchSize := min(256, len(opIDs)) if err := s.oplog.Delete(opIDs[:batchSize]...); err != nil { return nil, fmt.Errorf("failed to delete operations: %w", err) } opIDs = opIDs[batchSize:] } newConfig, err := s.config.Get() if err != nil { return nil, fmt.Errorf("failed to get config: %w", err) } return connect.NewResponse(config.SanitizeForNetwork(newConfig)), 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" } if runtime.GOOS == "windows" { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("automated SFTP setup is not supported on Windows")) } // 1. Generate key pair (local, always succeeds) _, pubBytes, keyPath, err := sftputil.GenerateKey(host, env.SSHDir()) if err != nil { return nil, fmt.Errorf("failed to generate key: %w", err) } // 2. Scan remote host key into known_hosts (network, non-fatal) var hostKeyWarning string if err := sftputil.AddHostKey(host, port, env.SSHDir()); err != nil { zap.S().Warnf("SFTP host key scan failed for %s: %v", host, err) hostKeyWarning = fmt.Sprintf("Could not scan host key (%v). Add the host key to known_hosts manually or ensure the host is reachable.", err) } return connect.NewResponse(&v1.SetupSftpResponse{ PublicKey: string(pubBytes), KeyPath: keyPath, KnownHostsPath: filepath.Join(env.SSHDir(), "known_hosts"), Error: hostKeyWarning, }), 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 repo, err := s.orchestrator.GetRepoOrchestrator(query.RepoId) if err != nil { return nil, withLookupCode(fmt.Errorf("failed to get repo: %w", err)) } var snapshots []*restic.Snapshot if query.PlanId != "" { var plan *v1.Plan plan, err = s.orchestrator.GetPlan(query.PlanId) if err != nil { return nil, withLookupCode(fmt.Errorf("failed to get plan %q: %w", query.PlanId, err)) } snapshots, err = repo.SnapshotsForPlan(ctx, plan) } else { snapshots, err = repo.Snapshots(ctx) } if err != nil { return nil, fmt.Errorf("failed to list snapshots: %w", err) } // Transform the snapshots and return them. var rs []*v1.ResticSnapshot for _, snapshot := range snapshots { rs = append(rs, protoutil.SnapshotToProto(snapshot)) } return connect.NewResponse(&v1.ResticSnapshotList{ Snapshots: rs, }), nil } func (s *BackrestHandler) ListSnapshotFiles(ctx context.Context, req *connect.Request[v1.ListSnapshotFilesRequest]) (*connect.Response[v1.ListSnapshotFilesResponse], error) { query := req.Msg repo, err := s.orchestrator.GetRepoOrchestrator(query.RepoId) if err != nil { return nil, withLookupCode(fmt.Errorf("failed to get repo: %w", err)) } entries, err := repo.ListSnapshotFiles(ctx, query.SnapshotId, query.Path) if err != nil { return nil, fmt.Errorf("failed to list snapshot files: %w", err) } return connect.NewResponse(&v1.ListSnapshotFilesResponse{ Path: query.Path, Entries: entries, }), nil } // GetOperationEvents implements GET /v1/events/operations func (s *BackrestHandler) GetOperationEvents(ctx context.Context, req *connect.Request[emptypb.Empty], resp *connect.ServerStream[v1.OperationEvent]) error { errChan := make(chan error, 1) events := make(chan *v1.OperationEvent, 100) timer := time.NewTicker(60 * time.Second) defer timer.Stop() callback := func(ops []*v1.Operation, eventType oplog.OperationEvent) { var event *v1.OperationEvent switch eventType { case oplog.OPERATION_ADDED: event = &v1.OperationEvent{ Event: &v1.OperationEvent_CreatedOperations{ CreatedOperations: &v1.OperationList{ Operations: ops, }, }, } case oplog.OPERATION_UPDATED: event = &v1.OperationEvent{ Event: &v1.OperationEvent_UpdatedOperations{ UpdatedOperations: &v1.OperationList{ Operations: ops, }, }, } case oplog.OPERATION_DELETED: ids := make([]int64, len(ops)) for i, o := range ops { ids[i] = o.Id } event = &v1.OperationEvent{ Event: &v1.OperationEvent_DeletedOperations{ DeletedOperations: &types.Int64List{ Values: ids, }, }, } default: zap.L().Error("Unknown event type") } select { case events <- event: default: select { case errChan <- errors.New("event buffer overflow, closing stream for client retry and catchup"): default: } } } s.oplog.Subscribe(oplog.SelectAll, &callback) defer func() { if err := s.oplog.Unsubscribe(&callback); err != nil { zap.L().Error("failed to unsubscribe from oplog", zap.Error(err)) } }() for { select { case <-timer.C: if err := resp.Send(&v1.OperationEvent{ Event: &v1.OperationEvent_KeepAlive{}, }); err != nil { return err } case err := <-errChan: return err case <-ctx.Done(): return nil case event := <-events: if err := resp.Send(event); err != nil { return err } } } } func (s *BackrestHandler) GetOperations(ctx context.Context, req *connect.Request[v1.GetOperationsRequest]) (*connect.Response[v1.OperationList], error) { q, err := protoutil.OpSelectorToQuery(req.Msg.Selector) if err != nil { return nil, err } if req.Msg.LastN != 0 { q.Reversed = true q.Limit = int(req.Msg.LastN) } var ops []*v1.Operation opCollector := func(op *v1.Operation) error { ops = append(ops, op) return nil } err = s.oplog.Query(q, opCollector) if err != nil { return nil, fmt.Errorf("failed to get operations: %w", err) } slices.SortFunc(ops, func(i, j *v1.Operation) int { if i.Id < j.Id { return -1 } return 1 }) return connect.NewResponse(&v1.OperationList{ Operations: ops, }), nil } // scheduleTaskAndWait schedules the task and blocks until it completes, returning the task's error. func (s *BackrestHandler) scheduleTaskAndWait(t tasks.Task, priority int) error { var taskErr error wait := make(chan struct{}) if _, err := s.orchestrator.ScheduleTask(t, priority, func(e error) { taskErr = e; close(wait) }); err != nil { return err } <-wait return taskErr } func (s *BackrestHandler) Backup(ctx context.Context, req *connect.Request[v1.BackupRequest]) (*connect.Response[emptypb.Empty], error) { plan, err := s.orchestrator.GetPlan(req.Msg.Value) if err != nil { return nil, withLookupCode(err) } repo, err := s.orchestrator.GetRepo(plan.Repo) if err != nil { return nil, withLookupCode(err) } if err := s.scheduleTaskAndWait(tasks.NewOneoffBackupTask(repo, plan, time.Now(), req.Msg.DryRun), tasks.TaskPriorityInteractive); err != nil { return nil, err } return connect.NewResponse(&emptypb.Empty{}), nil } func (s *BackrestHandler) Forget(ctx context.Context, req *connect.Request[v1.ForgetRequest]) (*connect.Response[v1.ScheduleTaskResponse], error) { at := time.Now() repo, err := s.orchestrator.GetRepo(req.Msg.RepoId) if err != nil { return nil, withLookupCode(err) } if req.Msg.PlanId == "" { return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("must specify repoId and planId and (optionally) snapshotId")) } var task tasks.Task if req.Msg.SnapshotId != "" { task = tasks.NewOneoffForgetSnapshotTask(repo, req.Msg.PlanId, 0, at, req.Msg.SnapshotId) } else { task = tasks.NewOneoffForgetTask(repo, req.Msg.PlanId, 0, at) } id, err := s.orchestrator.ScheduleTask(task, tasks.TaskPriorityInteractive+tasks.TaskPriorityForget) if err != nil { return nil, fmt.Errorf("failed to schedule forget task: %w", err) } return connect.NewResponse(&v1.ScheduleTaskResponse{OperationId: id}), nil } func (s BackrestHandler) DoRepoTask(ctx context.Context, req *connect.Request[v1.DoRepoTaskRequest]) (*connect.Response[v1.ScheduleTaskResponse], error) { var task tasks.Task repo, err := s.orchestrator.GetRepo(req.Msg.RepoId) if err != nil { return nil, withLookupCode(err) } priority := tasks.TaskPriorityInteractive switch req.Msg.Task { case v1.DoRepoTaskRequest_TASK_CHECK: task = tasks.NewCheckTask(repo, tasks.PlanForSystemTasks, true) case v1.DoRepoTaskRequest_TASK_PRUNE: task = tasks.NewPruneTask(repo, tasks.PlanForSystemTasks, true) priority |= tasks.TaskPriorityPrune case v1.DoRepoTaskRequest_TASK_FORGET: if repo.GetForgetPolicy() == nil { return nil, fmt.Errorf("repo %q has no forget policy configured", req.Msg.RepoId) } task = tasks.NewScheduledForgetTask(repo, tasks.PlanForSystemTasks, true) priority |= tasks.TaskPriorityForget case v1.DoRepoTaskRequest_TASK_STATS: task = tasks.NewStatsTask(repo, tasks.PlanForSystemTasks, true) priority |= tasks.TaskPriorityStats case v1.DoRepoTaskRequest_TASK_INDEX_SNAPSHOTS: task = tasks.NewOneoffIndexSnapshotsTask(repo, time.Now()) priority |= tasks.TaskPriorityIndexSnapshots case v1.DoRepoTaskRequest_TASK_UNLOCK: repo, err := s.orchestrator.GetRepoOrchestrator(req.Msg.RepoId) if err != nil { return nil, withLookupCode(err) } if err := repo.Unlock(ctx); err != nil { return nil, fmt.Errorf("failed to unlock repo %q: %w", req.Msg.RepoId, err) } return connect.NewResponse(&v1.ScheduleTaskResponse{OperationId: 0}), nil default: return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("unknown task %v", req.Msg.Task.String())) } id, err := s.orchestrator.ScheduleTask(task, priority) if err != nil { return nil, fmt.Errorf("failed to schedule task: %w", err) } return connect.NewResponse(&v1.ScheduleTaskResponse{OperationId: id}), nil } func (s *BackrestHandler) Restore(ctx context.Context, req *connect.Request[v1.RestoreSnapshotRequest]) (*connect.Response[v1.ScheduleTaskResponse], error) { req.Msg.Target = strings.TrimSpace(req.Msg.Target) req.Msg.Path = strings.TrimSpace(req.Msg.Path) if req.Msg.Target == "" { req.Msg.Target = path.Join(os.Getenv("HOME"), "Downloads", fmt.Sprintf("restic-restore-%v", time.Now().Format("2006-01-02T15-04-05"))) } if req.Msg.Path == "" { req.Msg.Path = "/" } // prevent restoring to a directory that already exists if _, err := os.Stat(req.Msg.Target); err == nil { return nil, connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("target directory %q already exists", req.Msg.Target)) } repo, err := s.orchestrator.GetRepo(req.Msg.RepoId) if err != nil { return nil, withLookupCode(err) } at := time.Now() id, err := s.orchestrator.ScheduleTask(tasks.NewOneoffRestoreTask(repo, req.Msg.PlanId, 0 /* flowID */, at, req.Msg.SnapshotId, req.Msg.Path, req.Msg.Target), tasks.TaskPriorityInteractive+tasks.TaskPriorityDefault) if err != nil { return nil, fmt.Errorf("failed to schedule restore task: %w", err) } return connect.NewResponse(&v1.ScheduleTaskResponse{OperationId: id}), nil } func (s *BackrestHandler) RunCommand(ctx context.Context, req *connect.Request[v1.RunCommandRequest]) (*connect.Response[v1.RunCommandResponse], error) { cfg, err := s.config.Get() if err != nil { return nil, fmt.Errorf("failed to get config: %w", err) } repo := config.FindRepo(cfg, req.Msg.RepoId) if repo == nil { return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("repo %q not found", req.Msg.RepoId)) } // group commands within the last 24 hours (or 256 operations) into the same flow ID var flowID int64 if s.oplog.Query(oplog.Query{}. SetInstanceID(cfg.Instance). SetRepoGUID(repo.GetGuid()). SetLimit(256). SetReversed(true), func(op *v1.Operation) error { if op.GetOperationRunCommand() != nil && time.Since(time.UnixMilli(op.UnixTimeStartMs)) < 30*time.Minute { flowID = op.FlowId } return nil }) != nil { return nil, fmt.Errorf("failed to query operations") } task := tasks.NewOneoffRunCommandTask(repo, tasks.PlanForSystemTasks, flowID, time.Now(), req.Msg.Command) st, err := s.orchestrator.CreateUnscheduledTask(task, tasks.TaskPriorityInteractive, time.Now()) if err != nil { return nil, fmt.Errorf("failed to create task: %w", err) } if err := s.orchestrator.RunTask(context.Background(), st); err != nil { return nil, fmt.Errorf("failed to run command: %w", err) } return connect.NewResponse(&v1.RunCommandResponse{OperationId: st.Op.GetId()}), nil } func (s *BackrestHandler) Cancel(ctx context.Context, req *connect.Request[v1.CancelOperationRequest]) (*connect.Response[emptypb.Empty], error) { if err := s.orchestrator.CancelOperation(req.Msg.OperationId, v1.OperationStatus_STATUS_USER_CANCELLED); err != nil { return nil, err } return connect.NewResponse(&emptypb.Empty{}), nil } func (s *BackrestHandler) ClearHistory(ctx context.Context, req *connect.Request[v1.ClearHistoryRequest]) (*connect.Response[emptypb.Empty], error) { var err error var ids []int64 opCollector := func(op *v1.Operation) error { if !req.Msg.OnlyFailed || op.Status == v1.OperationStatus_STATUS_ERROR { ids = append(ids, op.Id) } return nil } q, err := protoutil.OpSelectorToQuery(req.Msg.Selector) if err != nil { return nil, err } if err := s.oplog.Query(q, opCollector); err != nil { return nil, fmt.Errorf("failed to get operations to delete: %w", err) } if err := s.oplog.Delete(ids...); err != nil { return nil, fmt.Errorf("failed to delete operations: %w", err) } return connect.NewResponse(&emptypb.Empty{}), err } func (s *BackrestHandler) GetLogs(ctx context.Context, req *connect.Request[v1.LogDataRequest], resp *connect.ServerStream[types.BytesValue]) error { r, err := s.logStore.Open(req.Msg.Ref) if err != nil { if errors.Is(err, logstore.ErrLogNotFound) { resp.Send(&types.BytesValue{ Value: []byte(fmt.Sprintf("file associated with log %v not found, it may have expired.", req.Msg.GetRef())), }) return nil } return fmt.Errorf("get log data %v: %w", req.Msg.GetRef(), err) } go func() { <-ctx.Done() r.Close() }() var errChan = make(chan error, 1) var sendChan = make(chan []byte, 1) var buffer bytes.Buffer var bufferMu sync.Mutex go func() { data := make([]byte, 4*1024) for { n, err := r.Read(data) if n == 0 { break } else if err != nil && err != io.EOF { errChan <- fmt.Errorf("failed to read log data: %w", err) close(errChan) break } bufferMu.Lock() buffer.Write(data[:n]) if buffer.Len() > 128*1024 { sendChan <- bytes.Clone(buffer.Bytes()) buffer.Reset() } bufferMu.Unlock() } if buffer.Len() > 0 { bufferMu.Lock() sendChan <- bytes.Clone(buffer.Bytes()) buffer.Reset() bufferMu.Unlock() } close(sendChan) }() ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return nil case data, ok := <-sendChan: if !ok { return nil } if err := resp.Send(&types.BytesValue{Value: data}); err != nil { return err } case err := <-errChan: return err case <-ticker.C: bufferMu.Lock() if buffer.Len() > 0 { if err := resp.Send(&types.BytesValue{Value: bytes.Clone(buffer.Bytes())}); err != nil { bufferMu.Unlock() return err } buffer.Reset() } bufferMu.Unlock() } } } func (s *BackrestHandler) GetDownloadURL(ctx context.Context, req *connect.Request[v1.GetDownloadURLRequest]) (*connect.Response[types.StringValue], error) { op, err := s.oplog.Get(req.Msg.OpId) if err != nil { if errors.Is(err, oplog.ErrNotExist) { return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("failed to get operation %v: %w", req.Msg.OpId, err)) } return nil, fmt.Errorf("failed to get operation %v: %w", req.Msg.OpId, err) } var opType string switch op.Op.(type) { case *v1.Operation_OperationIndexSnapshot: opType = "snapshot" case *v1.Operation_OperationRestore: opType = "restore" default: return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("operation %v is not a restore or snapshot operation", req.Msg.OpId)) } token, err := signDownloadToken(DownloadTokenPayload{ OpID: op.Id, Type: opType, FilePath: req.Msg.FilePath, }) if err != nil { return nil, fmt.Errorf("failed to generate token: %w", err) } return connect.NewResponse(&types.StringValue{ Value: fmt.Sprintf("./download/%s/", token), }), nil } func (s *BackrestHandler) PathAutocomplete(ctx context.Context, path *connect.Request[types.StringValue]) (*connect.Response[types.StringList], error) { ents, err := os.ReadDir(path.Msg.Value) if errors.Is(err, os.ErrNotExist) { return connect.NewResponse(&types.StringList{}), nil } else if err != nil { return nil, err } var paths []string for _, ent := range ents { paths = append(paths, ent.Name()) } return connect.NewResponse(&types.StringList{Values: paths}), nil } func (s *BackrestHandler) GeneratePairingToken(ctx context.Context, req *connect.Request[v1.GeneratePairingTokenRequest]) (*connect.Response[v1.GeneratePairingTokenResponse], error) { // Generate the one-time secret before taking the lock. secret, err := cryptoutil.GeneratePairingSecret() if err != nil { return nil, fmt.Errorf("failed to generate pairing secret: %w", err) } now := time.Now().Unix() var expiresAt int64 if req.Msg.TtlSeconds > 0 { expiresAt = now + req.Msg.TtlSeconds } var tokenStr string if err := s.config.Transform(func(cfg *v1.Config) (*v1.Config, error) { if cfg.Instance == "" { return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("instance name must be set before generating pairing tokens")) } identity := cfg.GetMultihost().GetIdentity() if identity == nil { return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("multihost identity must be configured before generating pairing tokens")) } cfg.Multihost.PairingTokens = append(cfg.Multihost.PairingTokens, &v1.Multihost_PairingToken{ Secret: secret, Label: req.Msg.Label, CreatedAtUnix: now, ExpiresAtUnix: expiresAt, MaxUses: req.Msg.MaxUses, Uses: 0, Permissions: req.Msg.Permissions, }) cfg.Modno++ tokenStr = cryptoutil.FormatPairingToken(identity.Keyid, secret, cfg.Instance) return cfg, nil }); err != nil { return nil, fmt.Errorf("failed to save pairing token: %w", err) } return connect.NewResponse(&v1.GeneratePairingTokenResponse{ Token: tokenStr, }), nil } // withLookupCode tags err with connect.CodeNotFound when it stems from a missing // repo or plan lookup (orchestrator.ErrRepoNotFound / ErrPlanNotFound), preserving // the original message. Other errors are returned unchanged. func withLookupCode(err error) error { if err == nil { return nil } if errors.Is(err, orchestrator.ErrRepoNotFound) || errors.Is(err, orchestrator.ErrPlanNotFound) { return connect.NewError(connect.CodeNotFound, err) } return err } func sanitizeRepoFlags(repo *v1.Repo) { for i, flag := range repo.Flags { if strings.HasPrefix(flag, "--option=sftp.args=") { // Remove the "-i @" workaround since we now handle quoting properly flag = strings.ReplaceAll(flag, "-i @", "-i ") // Strip double quotes from sftp.args values that cause restic's // pflag parser to fail with "bare \" in non-quoted-field". // The UI wraps paths in double quotes (e.g. -i "/path/to/key"), // but restic's --option flag parser cannot handle them. // Paths passed as sftp.args don't need quoting since they are // passed directly to sftp as individual arguments. flag = strings.ReplaceAll(flag, "\"", "") repo.Flags[i] = flag } } }