Skip to content

Commit 81a47cf

Browse files
Client/Server mode: sending configuration profile to a remote server (#377)
1 parent ef83fe3 commit 81a47cf

44 files changed

Lines changed: 2526 additions & 51 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/build.yml

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ on:
1212
- 'docs/**'
1313

1414
jobs:
15-
1615
build:
1716
name: Build and test
1817
runs-on: ${{ matrix.os }}
@@ -25,7 +24,6 @@ jobs:
2524
GO: ${{ matrix.go_version }}
2625

2726
steps:
28-
2927
- name: Check out code into the Go module directory
3028
uses: actions/checkout@v6
3129

@@ -45,6 +43,7 @@ jobs:
4543
- name: Test
4644
run: make test-ci
4745
env:
46+
TEST_FUSE: 1
4847
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4948

5049
- name: Build
@@ -53,8 +52,9 @@ jobs:
5352
- name: Upload code coverage to Codecov
5453
uses: codecov/codecov-action@v6
5554
with:
55+
name: code-coverage-report-${{ matrix.os }}
5656
env_vars: OS,GO
57-
files: ./coverage.out,./unit-tests.xml
57+
files: ./coverage.out
5858
flags: unittests
5959
fail_ci_if_error: false
6060
verbose: true
@@ -68,3 +68,31 @@ jobs:
6868
token: ${{ secrets.CODECOV_TOKEN }}
6969
report_type: test_results
7070
files: ./unit-tests.xml
71+
72+
test-ssh:
73+
name: Test SSH client
74+
runs-on: ubuntu-latest
75+
76+
steps:
77+
- name: Check out code into the Go module directory
78+
uses: actions/checkout@v6
79+
80+
- name: Set up Go
81+
uses: actions/setup-go@v6
82+
with:
83+
go-version: 1.26
84+
85+
- name: Run tests
86+
run: |
87+
make start-ssh-server
88+
make ssh-test
89+
make stop-ssh-server
90+
91+
- name: Upload code coverage to Codecov
92+
uses: codecov/codecov-action@v6
93+
with:
94+
name: code-coverage-ssh-report
95+
files: ./coverage-ssh.out
96+
fail_ci_if_error: false
97+
verbose: true
98+
token: ${{ secrets.CODECOV_TOKEN }}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
# test output
1717
/coverage.out
18+
/coverage-ssh.out
1819
/unit-tests.xml
1920

2021
# mock binaries

.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,6 @@
1919
"githubPullRequests.ignoredPullRequestBranches": [
2020
"master"
2121
],
22-
"makefile.configureOnOpen": false
22+
"makefile.configureOnOpen": false,
23+
"go.buildTags": "ssh"
2324
}

Makefile

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ README=README.md
2828

2929
TESTS=./...
3030
COVERAGE_FILE=coverage.out
31+
COVERAGE_SSH_FILE=coverage-ssh.out
3132
JUNIT_FILE=unit-tests.xml
3233

3334
BUILD=build/
@@ -55,6 +56,9 @@ ifeq ($(UNAME),Darwin)
5556
TMP_MOUNT=${TMP_MOUNT_DARWIN}
5657
endif
5758

59+
TMPDIR ?= /tmp
60+
SSH_TESTS_TMPDIR=$(shell echo "$(TMPDIR)/resticprofile-ssh-tests" | tr -s /)
61+
5862
TOC_START=<\!--ts-->
5963
TOC_END=<\!--te-->
6064
TOC_PATH=toc.md
@@ -190,20 +194,22 @@ coverage: ## Generate coverage report
190194
clean: ## Clean up the build artifacts
191195
@echo "[*] $@"
192196
$(GOCLEAN)
193-
rm -rf $(BINARY) \
194-
$(BINARY_DARWIN_AMD64) \
195-
$(BINARY_DARWIN_ARM64) \
196-
$(BINARY_LINUX_AMD64) \
197-
$(BINARY_LINUX_ARM64) \
198-
$(BINARY_PI) \
199-
$(BINARY_WINDOWS_AMD64) \
200-
$(BINARY_WINDOWS_ARM64) \
201-
$(COVERAGE_FILE) \
202-
$(JUNIT_FILE) \
203-
restic_*_linux_amd64* \
204-
${BUILD}restic* \
205-
${BUILD}rclone* \
206-
dist/*
197+
rm -rf \
198+
$(BINARY) \
199+
$(BINARY_DARWIN_AMD64) \
200+
$(BINARY_DARWIN_ARM64) \
201+
$(BINARY_LINUX_AMD64) \
202+
$(BINARY_LINUX_ARM64) \
203+
$(BINARY_PI) \
204+
$(BINARY_WINDOWS_AMD64) \
205+
$(BINARY_WINDOWS_ARM64) \
206+
$(COVERAGE_FILE) \
207+
$(COVERAGE_SSH_FILE) \
208+
$(JUNIT_FILE) \
209+
restic_*_linux_amd64* \
210+
${BUILD}restic* \
211+
${BUILD}rclone* \
212+
dist/*
207213
find . -path "*/mocks/*" -exec rm {} \;
208214
restic cache --cleanup
209215

@@ -355,3 +361,27 @@ deploy-current: build-linux build-pi
355361
rsync -avz --progress $(BINARY_PI) $$server: ; \
356362
ssh -t $$server "sudo -S install $(BINARY_PI) /usr/local/bin/resticprofile" ; \
357363
done
364+
365+
.PHONY: start-ssh-server
366+
start-ssh-server: ## Start the SSH server for testing
367+
@echo "[*] $@"
368+
@mkdir -p $(SSH_TESTS_TMPDIR) && rm -f $(SSH_TESTS_TMPDIR)/id_* || echo "Failed to create temporary directory"
369+
@ssh-keygen -t rsa -b 2048 -f $(SSH_TESTS_TMPDIR)/id_rsa -N "" -C "resticprofile@$(shell hostname)"
370+
@ssh-keygen -t ecdsa -b 521 -f $(SSH_TESTS_TMPDIR)/id_ecdsa -N "" -C "resticprofile@$(shell hostname)"
371+
@ssh-keygen -t ed25519 -f $(SSH_TESTS_TMPDIR)/id_ed25519 -N "" -C "resticprofile@$(shell hostname)"
372+
@cd ./ssh/test && \
373+
USER_ID=$(shell id -u) GROUP_ID=$(shell id -g) SSH_TESTS_TMPDIR=$(SSH_TESTS_TMPDIR) \
374+
docker compose up -d --force-recreate
375+
@sleep 1
376+
@ssh-keyscan -p 2222 -H localhost > $(SSH_TESTS_TMPDIR)/known_hosts
377+
378+
.PHONY: stop-ssh-server
379+
stop-ssh-server: ## Stop the SSH server and clean up temporary files
380+
@echo "[*] $@"
381+
cd ./ssh/test && SSH_TESTS_TMPDIR=$(SSH_TESTS_TMPDIR) docker compose down --remove-orphans
382+
@test -d "$(SSH_TESTS_TMPDIR)" && rm -rf "$(SSH_TESTS_TMPDIR)" || echo "temporary directory not found, nothing to remove"
383+
384+
.PHONY: ssh-test
385+
ssh-test: ## Run SSH client tests
386+
@echo "[*] $@"
387+
@go test -run TestSSHClient -v -tags ssh -coverprofile='$(COVERAGE_SSH_FILE)' ./ssh

codecov.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ ignore:
1818

1919
codecov:
2020
notify:
21-
after_n_builds: 3
21+
after_n_builds: 4
2222

2323
comment:
24-
after_n_builds: 3
24+
after_n_builds: 4
2525

2626
coverage:
2727
round: nearest

commands.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,24 @@ func getOwnCommands() []ownCommand {
168168
needConfiguration: false,
169169
hide: true,
170170
},
171+
{
172+
name: "send",
173+
description: "send a configuration profile to a remote client and execute a command",
174+
action: sendProfileCommand,
175+
needConfiguration: true,
176+
noProfile: true,
177+
hide: true,
178+
experimental: true,
179+
},
180+
{
181+
name: "serve",
182+
description: "serve configuration profiles to remote clients",
183+
action: serveCommand,
184+
needConfiguration: true,
185+
noProfile: true,
186+
hide: true,
187+
experimental: true,
188+
},
171189
}
172190
}
173191

config/config.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,22 @@ func (c *Config) getProfilePath(key string) string {
711711
return c.flatKey(constants.SectionConfigurationProfiles, key)
712712
}
713713

714+
// HasRemote returns true if the remote exists in the configuration
715+
func (c *Config) HasRemote(remoteName string) bool {
716+
return c.IsSet(c.flatKey(constants.SectionConfigurationRemotes, remoteName))
717+
}
718+
719+
func (c *Config) GetRemote(remoteName string) (*Remote, error) {
720+
// we don't need to check the file version: the remotes can be in a separate configuration file
721+
722+
remote := NewRemote(c, remoteName)
723+
err := c.unmarshalKey(c.flatKey(constants.SectionConfigurationRemotes, remoteName), remote)
724+
725+
rootPath := filepath.Dir(c.GetConfigFile())
726+
remote.SetRootPath(rootPath)
727+
return remote, err
728+
}
729+
714730
// unmarshalConfig returns the decoder config options depending on the configuration version and format
715731
func (c *Config) unmarshalConfig() viper.DecoderConfigOption {
716732
if c.GetVersion() == Version01 {

config/config_test.go

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -367,15 +367,6 @@ x=0
367367
}
368368

369369
func TestIncludes(t *testing.T) {
370-
files := []string{}
371-
cleanFiles := func() {
372-
for _, file := range files {
373-
os.Remove(file)
374-
}
375-
files = files[:0]
376-
}
377-
defer cleanFiles()
378-
379370
createFile := func(t *testing.T, suffix, content string) string {
380371
t.Helper()
381372
name := ""
@@ -384,7 +375,9 @@ func TestIncludes(t *testing.T) {
384375
defer file.Close()
385376
_, err = file.WriteString(content)
386377
name = file.Name()
387-
files = append(files, name)
378+
t.Cleanup(func() {
379+
_ = os.Remove(name)
380+
})
388381
}
389382
require.NoError(t, err)
390383
return name
@@ -400,7 +393,6 @@ func TestIncludes(t *testing.T) {
400393
testID := fmt.Sprintf("%d", time.Now().Unix())
401394

402395
t.Run("multiple-includes", func(t *testing.T) {
403-
defer cleanFiles()
404396
content := fmt.Sprintf(`includes=['*%[1]s.inc.toml','*%[1]s.inc.yaml','*%[1]s.inc.json']`, testID)
405397

406398
configFile := createFile(t, "profiles.conf", content)
@@ -416,8 +408,6 @@ func TestIncludes(t *testing.T) {
416408
})
417409

418410
t.Run("overrides", func(t *testing.T) {
419-
defer cleanFiles()
420-
421411
configFile := createFile(t, "profiles.conf", `
422412
includes = "*`+testID+`.inc.toml"
423413
[default]
@@ -436,8 +426,6 @@ repository = "overridden-repo"`)
436426
})
437427

438428
t.Run("mixins", func(t *testing.T) {
439-
defer cleanFiles()
440-
441429
configFile := createFile(t, "profiles.conf", `
442430
version = 2
443431
includes = "*`+testID+`.inc.toml"
@@ -464,8 +452,6 @@ use = "another-run-before2"`)
464452
})
465453

466454
t.Run("hcl-includes-only-hcl", func(t *testing.T) {
467-
defer cleanFiles()
468-
469455
configFile := createFile(t, "profiles.hcl", `includes = "*`+testID+`.inc.*"`)
470456
createFile(t, "pass-"+testID+".inc.hcl", `one { }`)
471457

@@ -474,13 +460,11 @@ use = "another-run-before2"`)
474460

475461
createFile(t, "fail-"+testID+".inc.toml", `[two]`)
476462
_, err := LoadFile(configFile, "")
477-
assert.Error(t, err)
463+
require.Error(t, err)
478464
assert.Regexp(t, ".+ is in hcl format, includes must use the same format", err.Error())
479465
})
480466

481467
t.Run("non-hcl-include-no-hcl", func(t *testing.T) {
482-
defer cleanFiles()
483-
484468
configFile := createFile(t, "profiles.toml", `includes = "*`+testID+`.inc.*"`)
485469
createFile(t, "pass-"+testID+".inc.toml", "[one]\nk='v'")
486470

@@ -489,12 +473,11 @@ use = "another-run-before2"`)
489473

490474
createFile(t, "fail-"+testID+".inc.hcl", `one { }`)
491475
_, err := LoadFile(configFile, "")
492-
assert.Error(t, err)
476+
require.Error(t, err)
493477
assert.Regexp(t, "hcl format .+ cannot be used in includes from toml", err.Error())
494478
})
495479

496480
t.Run("cannot-load-different-versions", func(t *testing.T) {
497-
defer cleanFiles()
498481
content := fmt.Sprintf(`includes=['*%s.inc.json']`, testID)
499482

500483
configFile := createFile(t, "profiles.conf", content)
@@ -506,7 +489,6 @@ use = "another-run-before2"`)
506489
})
507490

508491
t.Run("cannot-load-different-versions", func(t *testing.T) {
509-
defer cleanFiles()
510492
content := fmt.Sprintf(`{"version": 2, "includes":["*%s.inc.json"]}`, testID)
511493

512494
configFile := createFile(t, "profiles.json", content)

config/error.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ package config
33
import "errors"
44

55
var (
6-
ErrNotFound = errors.New("not found")
6+
ErrNotFound = errors.New("not found")
7+
ErrNotSupportedInVersion1 = errors.New("not supported in configuration version 1")
78
)

config/remote.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package config
2+
3+
type Remote struct {
4+
name string
5+
config *Config
6+
Connection string `mapstructure:"connection" default:"ssh" enum:"ssh;openssh" description:"Connection type to use to connect to the remote client"`
7+
Host string `mapstructure:"host" description:"Address of the remote client (without port)."`
8+
Port int `mapstructure:"port" description:"Port to connect to on the remote client. If not specified, the default SSH port (22) will be used."`
9+
Username string `mapstructure:"username" description:"User to connect to the remote client"`
10+
PrivateKeyPaths []string `mapstructure:"private-keys" description:"Path to the private key(s) to use for authentication"`
11+
KnownHostsPath string `mapstructure:"known-hosts" description:"Path to the known hosts file"`
12+
BinaryPath string `mapstructure:"binary-path" description:"Path to the resticprofile binary to use on the remote client"`
13+
ConfigurationFile string `mapstructure:"configuration-file" description:"Path to the configuration file to transfer to the remote client"`
14+
ProfileName string `mapstructure:"profile-name" description:"Name of the profile to use on the remote client"`
15+
SendFiles []string `mapstructure:"send-files" description:"Other configuration files to transfer to the remote client"`
16+
SSHConfig string `mapstructure:"ssh-config" description:"Path to the OpenSSH config file to use for the connection"`
17+
}
18+
19+
func NewRemote(config *Config, name string) *Remote {
20+
remote := &Remote{
21+
name: name,
22+
config: config,
23+
}
24+
return remote
25+
}
26+
27+
// SetRootPath changes the path of all the relative paths and files in the configuration
28+
func (r *Remote) SetRootPath(rootPath string) {
29+
for i := range r.PrivateKeyPaths {
30+
r.PrivateKeyPaths[i] = fixPath(r.PrivateKeyPaths[i], expandEnv, absolutePrefix(rootPath))
31+
}
32+
r.KnownHostsPath = fixPath(r.KnownHostsPath, expandEnv, absolutePrefix(rootPath))
33+
r.ConfigurationFile = fixPath(r.ConfigurationFile, expandEnv, absolutePrefix(rootPath))
34+
r.SSHConfig = fixPath(r.SSHConfig, expandEnv, absolutePrefix(rootPath))
35+
36+
for i := range r.SendFiles {
37+
r.SendFiles[i] = fixPath(r.SendFiles[i], expandEnv, absolutePrefix(rootPath))
38+
}
39+
}

0 commit comments

Comments
 (0)