├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ ├── build.yml
│ └── code-analysis.yaml
├── .gitignore
├── LICENSE
├── Makefile
├── README.md
├── cmd
├── macvz-guestagent
│ ├── daemon_linux.go
│ ├── install_systemd_linux.go
│ ├── macvz-guestagent.TEMPLATE.service
│ └── main_linux.go
└── macvz
│ ├── completion.go
│ ├── main.go
│ ├── shell.go
│ ├── start.go
│ ├── stop.go
│ └── vz.go
├── examples
└── docker.yaml
├── go.mod
├── go.sum
├── pkg
├── cidata
│ ├── cidata.TEMPLATE.d
│ │ ├── boot.sh
│ │ ├── boot
│ │ │ ├── 00-modprobe.sh
│ │ │ ├── 07-etc-environment.sh
│ │ │ ├── 09-host-dns-setup.sh
│ │ │ ├── 20-rootless-base.sh
│ │ │ ├── 25-guestagent-base.sh
│ │ │ └── 30-install-packages.sh
│ │ ├── etc_environment
│ │ ├── macvz.env
│ │ ├── meta-data
│ │ └── user-data
│ ├── cidata.go
│ └── template.go
├── downloader
│ ├── downloader.go
│ └── downloader_test.go
├── guestagent
│ ├── api
│ │ └── api.go
│ ├── guestagent.go
│ ├── guestagent_linux.go
│ ├── guestdns
│ │ └── dns.go
│ ├── iptables
│ │ ├── iptables.go
│ │ └── iptables_test.go
│ ├── procnettcp
│ │ ├── procnettcp.go
│ │ ├── procnettcp_linux.go
│ │ └── procnettcp_test.go
│ └── timesync
│ │ └── timesync_linux.go
├── hostagent
│ ├── dns
│ │ └── dns.go
│ ├── events
│ │ ├── events.go
│ │ └── watcher.go
│ ├── hostagent.go
│ ├── port.go
│ ├── port_darwin.go
│ ├── port_others.go
│ └── requirements.go
├── iso9660util
│ └── iso9660util.go
├── lockutil
│ └── lockutil.go
├── logrusutil
│ └── logrusutil.go
├── osutil
│ ├── ip.go
│ ├── osutil_linux.go
│ ├── osutil_others.go
│ └── user.go
├── socket
│ └── io.go
├── sshutil
│ ├── sshutil.go
│ ├── sshutil_darwin.go
│ ├── sshutil_linux.go
│ ├── sshutil_others.go
│ └── sshutil_test.go
├── start
│ └── start.go
├── store
│ ├── dirnames
│ │ └── dirnames.go
│ ├── filenames
│ │ └── filenames.go
│ ├── instance.go
│ └── store.go
├── templateutil
│ └── templateutil.go
├── types
│ └── types.go
├── version
│ └── version.go
├── vzrun
│ ├── disk.go
│ └── main.go
└── yaml
│ ├── default.yaml
│ ├── defaults.go
│ ├── load.go
│ ├── template.go
│ ├── validate.go
│ └── yaml.go
└── vz.entitlements
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Versions (please complete the following information):**
27 | - macOS: [e.g. 12.0]
28 | - macvz [e.g. 1.0]
29 |
30 | **Additional context**
31 | Add any other context about the problem here.
32 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build and Test
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 | build:
11 | runs-on: macos-12
12 | steps:
13 | - uses: actions/checkout@v3
14 |
15 | - name: Set up Go
16 | uses: actions/setup-go@v3
17 | with:
18 | go-version: 1.18
19 |
20 | - name: Build
21 | run: make all
22 |
23 | - name: Unit Tests
24 | run: go test -v ./... -race -covermode=atomic -coverprofile=coverage.out -coverpkg=./...
25 |
26 | - name: Run codacy-coverage-reporter
27 | uses: codacy/codacy-coverage-reporter-action@v1
28 | with:
29 | project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
30 | force-coverage-parser: go
31 | coverage-reports: coverage.out
32 |
--------------------------------------------------------------------------------
/.github/workflows/code-analysis.yaml:
--------------------------------------------------------------------------------
1 | name: Code Analysis
2 |
3 | on:
4 | push:
5 | branches: [ "master", "main" ]
6 | pull_request:
7 | branches: [ "master", "main" ]
8 |
9 | jobs:
10 | codeql-scan:
11 | name: Codeql Security Scan
12 | runs-on: ubuntu-latest
13 | permissions:
14 | actions: read
15 | contents: read
16 | security-events: write
17 |
18 | steps:
19 | - name: Checkout repository
20 | uses: actions/checkout@v2
21 |
22 | - name: Initialize CodeQL
23 | uses: github/codeql-action/init@v1
24 | with:
25 | languages: go
26 |
27 | - name: Autobuild
28 | uses: github/codeql-action/autobuild@v1
29 |
30 | - name: Perform CodeQL Analysis
31 | uses: github/codeql-action/analyze@v1
32 |
33 | codacy-scan:
34 | name: Codacy Security Scan
35 | runs-on: ubuntu-latest
36 | steps:
37 | - name: Checkout code
38 | uses: actions/checkout@main
39 |
40 | - name: Run Codacy Analysis CLI
41 | uses: codacy/codacy-analysis-cli-action@4.0.2
42 | with:
43 | output: results.sarif
44 | format: sarif
45 | project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
46 |
47 | # Adjust severity of non-security issues
48 | gh-code-scanning-compat: false
49 | max-allowed-issues: 2147483647
50 | upload: true
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | _output
2 | examples/docker1.yaml
3 |
4 | results.sarif
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Balaji Vijayakumar
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # Files are installed under $(DESTDIR)/$(PREFIX)
2 | PREFIX ?= /usr/local
3 | DEST := $(shell echo "$(DESTDIR)/$(PREFIX)" | sed 's:///*:/:g; s://*$$::')
4 |
5 | GO ?= go
6 |
7 | TAR ?= tar
8 |
9 | PACKAGE := github.com/mac-vz/macvz
10 |
11 | VERSION=1.0.0
12 | VERSION_TRIMMED := 1.0.0
13 |
14 | GO_BUILD := $(GO) build -ldflags="-s -w -X $(PACKAGE)/pkg/version.Version=$(VERSION)"
15 |
16 | .PHONY: all
17 | all: binaries codesign
18 |
19 | .PHONY: binaries
20 | binaries: \
21 | _output/bin/macvz \
22 | _output/share/macvz/macvz-guestagent.Linux-x86_64 \
23 | _output/share/macvz/macvz-guestagent.Linux-aarch64
24 |
25 | .PHONY: _output/bin/macvz
26 | _output/bin/macvz:
27 | # The hostagent must be compiled with CGO_ENABLED=1 so that net.LookupIP() in the DNS server
28 | # calls the native resolver library and not the simplistic version in the Go library.
29 | CGO_ENABLED=1 $(GO_BUILD) -o $@ ./cmd/macvz
30 |
31 | .PHONY: codesign
32 | codesign:
33 | codesign --entitlements vz.entitlements -s - ./_output/bin/macvz
34 |
35 | .PHONY: install
36 | install:
37 | mkdir -p "$(DEST)"
38 | cp -av _output/* "$(DEST)"
39 |
40 | .PHONY: uninstall
41 | uninstall:
42 | @test -f "$(DEST)/bin/macvz" || (echo "macvz not found in $(DEST) prefix"; exit 1)
43 | rm -rf \
44 | "$(DEST)/bin/macvz" \
45 | "$(DEST)/bin/vfcli"
46 |
47 | .PHONY: lint
48 | lint:
49 | golangci-lint run ./...
50 | yamllint .
51 | find . -name '*.sh' | xargs shellcheck
52 | find . -name '*.sh' | xargs shfmt -s -d
53 |
54 | .PHONY: clean
55 | clean:
56 | rm -rf _output
57 |
58 | .PHONY: artifacts-darwin
59 | artifacts-darwin:
60 | mkdir -p _artifacts
61 | GOOS=darwin GOARCH=amd64 make clean binaries codesign
62 | $(TAR) -C _output/ -czvf _artifacts/macvz-$(VERSION_TRIMMED)-Darwin-x86_64.tar.gz ./
63 | GOOS=darwin GOARCH=arm64 make clean binaries codesign
64 | $(TAR) -C _output -czvf _artifacts/macvz-$(VERSION_TRIMMED)-Darwin-arm64.tar.gz ./
65 |
66 | .PHONY: _output/share/macvz/macvz-guestagent.Linux-x86_64
67 | _output/share/macvz/macvz-guestagent.Linux-x86_64:
68 | GOOS=linux GOARCH=amd64 CGO_ENABLED=0 $(GO_BUILD) -o $@ ./cmd/macvz-guestagent
69 | chmod 644 $@
70 |
71 | .PHONY: _output/share/macvz/macvz-guestagent.Linux-aarch64
72 | _output/share/macvz/macvz-guestagent.Linux-aarch64:
73 | GOOS=linux GOARCH=arm64 CGO_ENABLED=0 $(GO_BUILD) -o $@ ./cmd/macvz-guestagent
74 | chmod 644 $@
75 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://goreportcard.com/report/github.com/mac-vz/macvz)
2 | [](https://www.codacy.com/gh/mac-vz/macvz/dashboard?utm_source=github.com&utm_medium=referral&utm_content=mac-vz/macvz&utm_campaign=Badge_Grade)
3 | [](https://github.com/mac-vz/macvz/blob/main/LICENSE)
4 |
5 | # MACVZ
6 |
7 | This project is inspired by and a rewrite of lima-vm.
8 |
9 | The major difference is macvz uses macOS new [Virtualization API](https://developer.apple.com/documentation/virtualization?language=objc) instead of QEMU for spinning up VM's.
10 |
11 | # Requirements
12 | - Higher or equal to macOS monterey (12.2)
13 | - Golang
14 |
15 | # Getting Started
16 | ## Installation via Homebrew
17 | - Run `brew install mac-vz/tap/macvz` to install macvz
18 |
19 | ## Installation via source
20 | - Run `make all` to compile and build binary
21 | - Run `make install` to install the binary to /usr/local
22 |
23 | ## Using macvz as a alternate for Docker Desktop
24 | To start a Docker VM, run the following command
25 | ```
26 | macvz start https://raw.githubusercontent.com/mac-vz/macvz/main/examples/docker.yaml
27 | ```
28 |
29 | Execute the following command in macOS host to update docker.sock location
30 | ```
31 | export DOCKER_HOST=unix://${HOME}/.macvz/docker/sock/docker.sock
32 | ```
33 |
34 | That's it !!
35 |
36 |
37 | ## Other macvz commands
38 |
39 | To get shell access to a running VM,
40 | ```
41 | macvz shell docker
42 | ```
43 |
44 | To stop a running VM,
45 | ```
46 | macvz stop docker
47 | ```
48 |
49 | # Features
50 | - Ability to start, stop and shell access
51 | - Filesystem mounting using virtfs (See the performance report below)
52 | - Automatic Port forwarding
53 | - Custom DNS Resolution (like host.docker.internal)
54 |
55 | # Planned
56 | - Support for commands like list, delete, pause, resume
57 | - Support for different linux distros
58 |
59 | # Performance Summary
60 |
61 | ## Summary of filesystem performance with colima
62 |
63 | The following table contains result summary of some different workloads tested against macvz and colima
64 |
65 | ### Summary for IOPS (Input Ouput Per Second)
66 | | Workload | Summary | macvz | colima |
67 | |---------------------|------------------------------------|---------|--------|
68 | | Sequential Reads | macvz handles 8x higher operations | 620K | 77K |
69 | | Random Reads | macvz handles 3x higher operations | 82K | 25K |
70 | | Random Reads/Writes | macvz handles 3x higher operations | 37K/12K | 14K/4K |
71 | | Sequential writes | macvz performs almost equally | 37K | 38K |
72 | | Random writes | macvz performs almost equally | 22K | 30K |
73 |
74 | ### Summary for Bandwidth (Maximum amount of data transmitted)
75 | | Workload | Summary | macvz | colima |
76 | |---------------------|-------------------------------|------------|-----------|
77 | | Sequential Reads | macvz handles 8x more data | 2500MB | 306MB |
78 | | Random Reads | macvz handles 3x more data | 320MB | 98MB |
79 | | Random Reads/Writes | macvz handles 3x more data | 140MB/50MB | 60MB/20MB |
80 | | Sequential writes | macvz performs almost equally | 145MB | 150MB |
81 | | Random writes | macvz performs almost equally | 90MB | 110MB |
82 |
83 | Check out Wiki page [Why use virtualization framework](https://github.com/mac-vz/macvz/wiki/Why-use-macOS-virtualization-framework-%3F) for detailed information
84 |
85 | # Project Status
86 | ⚠️ The project is still in early stage development and may introduce breaking changes.
87 |
88 | # Supporters
89 |
90 | [
](https://macstadium.com)
91 |
--------------------------------------------------------------------------------
/cmd/macvz-guestagent/daemon_linux.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "errors"
5 | "github.com/hashicorp/yamux"
6 | "github.com/mac-vz/macvz/pkg/guestagent"
7 | "github.com/mdlayher/vsock"
8 | "os"
9 | "time"
10 |
11 | "github.com/sirupsen/logrus"
12 | "github.com/spf13/cobra"
13 | )
14 |
15 | func newDaemonCommand() *cobra.Command {
16 | daemonCommand := &cobra.Command{
17 | Use: "daemon",
18 | Short: "run the daemon",
19 | RunE: daemonAction,
20 | }
21 | daemonCommand.Flags().Duration("tick", 3*time.Second, "tick for polling events")
22 | return daemonCommand
23 | }
24 |
25 | func daemonAction(cmd *cobra.Command, args []string) error {
26 | tick, err := cmd.Flags().GetDuration("tick")
27 | if err != nil {
28 | return err
29 | }
30 | if tick == 0 {
31 | return errors.New("tick must be specified")
32 | }
33 | if os.Geteuid() != 0 {
34 | return errors.New("must run as the root")
35 | }
36 | logrus.Infof("event tick: %v", tick)
37 |
38 | yamuxListener, err := vsock.Dial(vsock.Host, 47, &vsock.Config{})
39 |
40 | newTicker := func() (<-chan time.Time, func()) {
41 | // TODO: use an equivalent of `bpftrace -e 'tracepoint:syscalls:sys_*_bind { printf("tick\n"); }')`,
42 | // without depending on `bpftrace` binary.
43 | // The agent binary will need CAP_BPF file cap.
44 | ticker := time.NewTicker(tick)
45 | return ticker.C, ticker.Stop
46 | }
47 |
48 | cfg := yamux.DefaultConfig()
49 |
50 | sess, err := yamux.Server(yamuxListener, cfg)
51 |
52 | agent, err := guestagent.New(newTicker, sess, tick*20)
53 | if err != nil {
54 | return err
55 | }
56 | logrus.Println("Serving at vsock...")
57 | logrus.Println("Publishing info...")
58 | agent.PublishInfo()
59 | logrus.Println("Published info...")
60 | logrus.Println("Sending Events...")
61 | agent.StartDNS()
62 | agent.ListenAndSendEvents()
63 | logrus.Println("Stopped sending events")
64 | return nil
65 | }
66 |
--------------------------------------------------------------------------------
/cmd/macvz-guestagent/install_systemd_linux.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | _ "embed"
5 | "errors"
6 | "os"
7 | "os/exec"
8 | "path/filepath"
9 | "strings"
10 |
11 | "github.com/mac-vz/macvz/pkg/templateutil"
12 | "github.com/sirupsen/logrus"
13 | "github.com/spf13/cobra"
14 | )
15 |
16 | func newInstallSystemdCommand() *cobra.Command {
17 | var installSystemdCommand = &cobra.Command{
18 | Use: "install-systemd",
19 | Short: "install a systemd unit (user)",
20 | RunE: installSystemdAction,
21 | }
22 | return installSystemdCommand
23 | }
24 |
25 | func installSystemdAction(cmd *cobra.Command, args []string) error {
26 | unit, err := generateSystemdUnit()
27 | if err != nil {
28 | return err
29 | }
30 | unitPath := "/etc/systemd/system/macvz-guestagent.service"
31 | if _, err := os.Stat(unitPath); !errors.Is(err, os.ErrNotExist) {
32 | logrus.Infof("File %q already exists, overwriting", unitPath)
33 | } else {
34 | unitDir := filepath.Dir(unitPath)
35 | if err := os.MkdirAll(unitDir, 0755); err != nil {
36 | return err
37 | }
38 | }
39 | if err := os.WriteFile(unitPath, unit, 0644); err != nil {
40 | return err
41 | }
42 | logrus.Infof("Written file %q", unitPath)
43 | argss := [][]string{
44 | {"daemon-reload"},
45 | {"enable", "--now", "macvz-guestagent.service"},
46 | }
47 | for _, args := range argss {
48 | cmd := exec.Command("systemctl", append([]string{"--system"}, args...)...)
49 | cmd.Stdout = os.Stdout
50 | cmd.Stderr = os.Stderr
51 | logrus.Infof("Executing: %s", strings.Join(cmd.Args, " "))
52 | if err := cmd.Run(); err != nil {
53 | return err
54 | }
55 | }
56 | logrus.Info("Done")
57 | return nil
58 | }
59 |
60 | //go:embed macvz-guestagent.TEMPLATE.service
61 | var systemdUnitTemplate string
62 |
63 | func generateSystemdUnit() ([]byte, error) {
64 | selfExeAbs, err := os.Executable()
65 | if err != nil {
66 | return nil, err
67 | }
68 | m := map[string]string{
69 | "Binary": selfExeAbs,
70 | }
71 | return templateutil.Execute(systemdUnitTemplate, m)
72 | }
73 |
--------------------------------------------------------------------------------
/cmd/macvz-guestagent/macvz-guestagent.TEMPLATE.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=macvz-guestagent
3 |
4 | [Service]
5 | ExecStart={{.Binary}} daemon
6 | Type=simple
7 | Restart=on-failure
8 |
9 | [Install]
10 | WantedBy=multi-user.target
11 |
--------------------------------------------------------------------------------
/cmd/macvz-guestagent/main_linux.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/mac-vz/macvz/pkg/version"
7 | "github.com/sirupsen/logrus"
8 | "github.com/spf13/cobra"
9 | )
10 |
11 | func main() {
12 | if err := newApp().Execute(); err != nil {
13 | logrus.Fatal(err)
14 | }
15 | }
16 |
17 | func newApp() *cobra.Command {
18 | var rootCmd = &cobra.Command{
19 | Use: "macvz-guestagent",
20 | Short: "Do not launch manually",
21 | Version: strings.TrimPrefix(version.Version, "v"),
22 | }
23 | rootCmd.PersistentFlags().Bool("debug", false, "debug mode")
24 | rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
25 | debug, _ := cmd.Flags().GetBool("debug")
26 | if debug {
27 | logrus.SetLevel(logrus.DebugLevel)
28 | }
29 | return nil
30 | }
31 | rootCmd.AddCommand(
32 | newDaemonCommand(),
33 | newInstallSystemdCommand(),
34 | )
35 | return rootCmd
36 | }
37 |
--------------------------------------------------------------------------------
/cmd/macvz/completion.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/mac-vz/macvz/pkg/store"
5 | "github.com/spf13/cobra"
6 | )
7 |
8 | func bashCompleteInstanceNames(cmd *cobra.Command) ([]string, cobra.ShellCompDirective) {
9 | instances, err := store.Instances()
10 | if err != nil {
11 | return nil, cobra.ShellCompDirectiveDefault
12 | }
13 | return instances, cobra.ShellCompDirectiveNoFileComp
14 | }
15 |
--------------------------------------------------------------------------------
/cmd/macvz/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "github.com/mac-vz/macvz/pkg/store/dirnames"
7 | "github.com/mac-vz/macvz/pkg/version"
8 | "os"
9 | "strings"
10 |
11 | "github.com/sirupsen/logrus"
12 | "github.com/spf13/cobra"
13 | )
14 |
15 | const (
16 | DefaultInstanceName = "default"
17 | )
18 |
19 | func main() {
20 | if err := newApp().Execute(); err != nil {
21 | handleExitCoder(err)
22 | logrus.Fatal(err)
23 | }
24 | }
25 |
26 | func newApp() *cobra.Command {
27 | var rootCmd = &cobra.Command{
28 | Use: "macvz",
29 | Short: "Mac Virtualization",
30 | Version: strings.TrimPrefix(version.Version, "v"),
31 | Example: fmt.Sprintf(` Start the default instance:
32 | $ macvz start
33 |
34 | Stop the default instance:
35 | $ macvz stop`),
36 | SilenceUsage: true,
37 | SilenceErrors: true,
38 | }
39 | rootCmd.PersistentFlags().Bool("debug", false, "debug mode")
40 | rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
41 | debug, _ := cmd.Flags().GetBool("debug")
42 | if debug {
43 | logrus.SetLevel(logrus.DebugLevel)
44 | }
45 | if os.Geteuid() == 0 {
46 | return errors.New("must not run as the root")
47 | }
48 | // Make sure either $HOME or $MACVZ_HOME is defined, so we don't need
49 | // to check for errors later
50 | if _, err := dirnames.MacVZDir(); err != nil {
51 | return err
52 | }
53 | return nil
54 | }
55 | rootCmd.AddCommand(
56 | newStartCommand(),
57 | newVZCommand(),
58 | newShellCommand(),
59 | newStopCommand(),
60 | )
61 | return rootCmd
62 | }
63 |
64 | type ExitCoder interface {
65 | error
66 | ExitCode() int
67 | }
68 |
69 | func handleExitCoder(err error) {
70 | if err == nil {
71 | return
72 | }
73 |
74 | if exitErr, ok := err.(ExitCoder); ok {
75 | os.Exit(exitErr.ExitCode())
76 | return
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/cmd/macvz/shell.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "os"
7 | "os/exec"
8 | "strings"
9 |
10 | "github.com/alessio/shellescape"
11 | "github.com/mac-vz/macvz/pkg/sshutil"
12 | "github.com/mac-vz/macvz/pkg/store"
13 | "github.com/mattn/go-isatty"
14 | "github.com/sirupsen/logrus"
15 | "github.com/spf13/cobra"
16 | )
17 |
18 | var shellHelp = `Execute shell in MacVZ`
19 |
20 | func newShellCommand() *cobra.Command {
21 | var shellCmd = &cobra.Command{
22 | Use: "shell INSTANCE [COMMAND...]",
23 | Short: "Execute shell in MacVZ",
24 | Long: shellHelp,
25 | Args: cobra.MinimumNArgs(1),
26 | RunE: shellAction,
27 | ValidArgsFunction: shellBashComplete,
28 | SilenceErrors: true,
29 | }
30 |
31 | shellCmd.Flags().SetInterspersed(false)
32 |
33 | shellCmd.Flags().String("workdir", "", "working directory")
34 | return shellCmd
35 | }
36 |
37 | func shellAction(cmd *cobra.Command, args []string) error {
38 | // simulate the behavior of double dash
39 | newArg := []string{}
40 | if len(args) >= 2 && args[1] == "--" {
41 | newArg = append(newArg, args[:1]...)
42 | newArg = append(newArg, args[2:]...)
43 | args = newArg
44 | }
45 | instName := args[0]
46 |
47 | if len(args) >= 2 {
48 | switch args[1] {
49 | case "start", "delete", "shell":
50 | logrus.Warnf("Perhaps you meant `macvz %s`?", strings.Join(args[1:], " "))
51 | }
52 | }
53 |
54 | inst, err := store.Inspect(instName)
55 | if err != nil {
56 | if errors.Is(err, os.ErrNotExist) {
57 | return fmt.Errorf("instance %q does not exist, run `macvz start %s` to create a new instance", instName, instName)
58 | }
59 | return err
60 | }
61 | if inst.Status == store.StatusStopped {
62 | return fmt.Errorf("instance %q is stopped, run `macvz start %s` to start the instance", instName, instName)
63 | }
64 | y, err := inst.LoadYAML()
65 | if err != nil {
66 | return err
67 | }
68 |
69 | // When workDir is explicitly set, the shell MUST have workDir as the cwd, or exit with an error.
70 | //
71 | // changeDirCmd := "cd workDir || exit 1" if workDir != ""
72 | // := "cd hostCurrentDir || cd hostHomeDir" if workDir == ""
73 | var changeDirCmd string
74 | workDir, err := cmd.Flags().GetString("workdir")
75 | if err != nil {
76 | return err
77 | }
78 | if workDir != "" {
79 | changeDirCmd = fmt.Sprintf("cd %q || exit 1", workDir)
80 | // FIXME: check whether y.Mounts contains the home, not just len > 0
81 | } else if len(y.Mounts) > 0 {
82 | hostCurrentDir, err := os.Getwd()
83 | if err == nil {
84 | changeDirCmd = fmt.Sprintf("cd %q", hostCurrentDir)
85 | } else {
86 | changeDirCmd = "false"
87 | logrus.WithError(err).Warn("failed to get the current directory")
88 | }
89 | hostHomeDir, err := os.UserHomeDir()
90 | if err == nil {
91 | changeDirCmd = fmt.Sprintf("%s || cd %q", changeDirCmd, hostHomeDir)
92 | } else {
93 | logrus.WithError(err).Warn("failed to get the home directory")
94 | }
95 | } else {
96 | logrus.Debug("the host home does not seem mounted, so the guest shell will have a different cwd")
97 | }
98 |
99 | if changeDirCmd == "" {
100 | changeDirCmd = "false"
101 | }
102 | logrus.Debugf("changeDirCmd=%q", changeDirCmd)
103 |
104 | script := fmt.Sprintf("%s ; exec bash --login", changeDirCmd)
105 | if len(args) > 1 {
106 | script += fmt.Sprintf(" -c %q", shellescape.QuoteCommand(args[1:]))
107 | }
108 |
109 | arg0, err := exec.LookPath("ssh")
110 | if err != nil {
111 | return err
112 | }
113 |
114 | sshOpts, err := sshutil.SSHOpts(inst.Dir, true, false)
115 | if err != nil {
116 | return err
117 | }
118 |
119 | sshArgs := sshutil.SSHArgsFromOpts(sshOpts)
120 | if isatty.IsTerminal(os.Stdout.Fd()) {
121 | // required for showing the shell prompt: https://stackoverflow.com/a/626574
122 | sshArgs = append(sshArgs, "-t")
123 | }
124 | if _, present := os.LookupEnv("COLORTERM"); present {
125 | // SendEnv config is cumulative, with already existing options in ssh_config
126 | sshArgs = append(sshArgs, "-o", "SendEnv=\"COLORTERM\"")
127 | }
128 | sshArgs = append(sshArgs, []string{
129 | "-q",
130 | fmt.Sprintf("%s", sshutil.SSHRemoteUser(*y.MACAddress)),
131 | "--",
132 | script,
133 | }...)
134 | sshCmd := exec.Command(arg0, sshArgs...)
135 | sshCmd.Stdin = os.Stdin
136 | sshCmd.Stdout = os.Stdout
137 | sshCmd.Stderr = os.Stderr
138 | logrus.Debugf("executing ssh (may take a long)): %+v", sshCmd.Args)
139 |
140 | return sshCmd.Run()
141 | }
142 |
143 | func shellBashComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
144 | return bashCompleteInstanceNames(cmd)
145 | }
146 |
--------------------------------------------------------------------------------
/cmd/macvz/start.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "github.com/mac-vz/macvz/pkg/osutil"
7 | "io"
8 | "net/http"
9 | "net/url"
10 | "os"
11 | "path"
12 | "path/filepath"
13 | "strings"
14 |
15 | "github.com/mac-vz/macvz/pkg/start"
16 | "github.com/mac-vz/macvz/pkg/store"
17 | "github.com/mac-vz/macvz/pkg/store/filenames"
18 | "github.com/mac-vz/macvz/pkg/yaml"
19 | "github.com/sirupsen/logrus"
20 | "github.com/spf13/cobra"
21 | yaml2 "gopkg.in/yaml.v2"
22 | )
23 |
24 | func newStartCommand() *cobra.Command {
25 | var startCommand = &cobra.Command{
26 | Use: "start NAME|FILE.yaml",
27 | Short: fmt.Sprintf("Start a new instance with given configuration or starts a existing instance"),
28 | Args: cobra.MaximumNArgs(1),
29 | ValidArgsFunction: startBashComplete,
30 | RunE: startAction,
31 | }
32 |
33 | return startCommand
34 | }
35 |
36 | func loadOrCreateInstance(cmd *cobra.Command, args []string) (*store.Instance, error) {
37 | var arg string
38 | if len(args) == 0 {
39 | arg = DefaultInstanceName
40 | } else {
41 | arg = args[0]
42 | }
43 |
44 | var (
45 | instName string
46 | yBytes = yaml.DefaultTemplate
47 | err error
48 | )
49 |
50 | const yBytesLimit = 4 * 1024 * 1024 // 4MiB
51 |
52 | if argSeemsHTTPURL(arg) {
53 | instName, err = instNameFromURL(arg)
54 | if err != nil {
55 | return nil, err
56 | }
57 | logrus.Debugf("interpreting argument %q as a http url for instance %q", arg, instName)
58 | resp, err := http.Get(arg)
59 | if err != nil {
60 | return nil, err
61 | }
62 | defer resp.Body.Close()
63 | yBytes, err = readAtMaximum(resp.Body, yBytesLimit)
64 | if err != nil {
65 | return nil, err
66 | }
67 | } else if argSeemsFileURL(arg) {
68 | instName, err = instNameFromURL(arg)
69 | if err != nil {
70 | return nil, err
71 | }
72 | logrus.Debugf("interpreting argument %q as a file url for instance %q", arg, instName)
73 | r, err := os.Open(strings.TrimPrefix(arg, "file://"))
74 | if err != nil {
75 | return nil, err
76 | }
77 | defer r.Close()
78 | yBytes, err = readAtMaximum(r, yBytesLimit)
79 | if err != nil {
80 | return nil, err
81 | }
82 | } else if argSeemsYAMLPath(arg) {
83 | instName, err = instNameFromYAMLPath(arg)
84 | if err != nil {
85 | return nil, err
86 | }
87 | logrus.Debugf("interpreting argument %q as a file path for instance %q", arg, instName)
88 | r, err := os.Open(arg)
89 | if err != nil {
90 | return nil, err
91 | }
92 | defer r.Close()
93 | yBytes, err = readAtMaximum(r, yBytesLimit)
94 | if err != nil {
95 | return nil, err
96 | }
97 | } else {
98 | instName = arg
99 | logrus.Debugf("interpreting argument %q as an instance name %q", arg, instName)
100 | if inst, err := store.Inspect(instName); err == nil {
101 | logrus.Infof("Using the existing instance %q", instName)
102 |
103 | return inst, nil
104 | } else {
105 | if !errors.Is(err, os.ErrNotExist) {
106 | return nil, err
107 | }
108 | }
109 | }
110 | // create a new instance from the template
111 | instDir, err := store.InstanceDir(instName)
112 | if err != nil {
113 | return nil, err
114 | }
115 |
116 | if _, err := os.Stat(instDir); !errors.Is(err, os.ErrNotExist) {
117 | return nil, fmt.Errorf("instance %q already exists (%q)", instName, instDir)
118 | }
119 |
120 | if err != nil {
121 | return nil, err
122 | }
123 |
124 | filePath := filepath.Join(instDir, filenames.MacVZYAML)
125 | y, err := yaml.Load(yBytes, filePath)
126 | if err != nil {
127 | return nil, err
128 | }
129 | if err := yaml.Validate(*y, true); err != nil {
130 | rejectedYAML := "macvz.REJECTED.yaml"
131 | if writeErr := os.WriteFile(rejectedYAML, yBytes, 0644); writeErr != nil {
132 | return nil, fmt.Errorf("the YAML is invalid, attempted to save the buffer as %q but failed: %v: %w", rejectedYAML, writeErr, err)
133 | }
134 | return nil, fmt.Errorf("the YAML is invalid, saved the buffer as %q: %w", rejectedYAML, err)
135 | }
136 | if err := os.MkdirAll(instDir, 0700); err != nil {
137 | return nil, err
138 | }
139 |
140 | bytes, err := yaml2.Marshal(&y)
141 | if err := os.WriteFile(filePath, bytes, 0644); err != nil {
142 | return nil, err
143 | }
144 | return store.Inspect(instName)
145 | }
146 |
147 | func startAction(cmd *cobra.Command, args []string) error {
148 | inst, err := loadOrCreateInstance(cmd, args)
149 | if err != nil {
150 | return err
151 | }
152 | if len(inst.Errors) > 0 {
153 | return fmt.Errorf("errors inspecting instance: %+v", inst.Errors)
154 | }
155 |
156 | // the full path of the socket name must be less than UNIX_PATH_MAX chars.
157 | maxSockName := filepath.Join(inst.Dir, filenames.LongestSock)
158 | if len(maxSockName) >= osutil.UnixPathMax {
159 | return fmt.Errorf("instance name %q too long: %q must be less than UNIX_PATH_MAX=%d characters, but is %d",
160 | inst.Name, maxSockName, osutil.UnixPathMax, len(maxSockName))
161 | }
162 |
163 | switch inst.Status {
164 | case store.StatusRunning:
165 | logrus.Infof("The instance %q is already running. Run `%s` to open the shell.",
166 | inst.Name, inst.Name)
167 | // Not an error
168 | return nil
169 | case store.StatusStopped:
170 | // NOP
171 | default:
172 | logrus.Warnf("expected status %q, got %q", store.StatusStopped, inst.Status)
173 | }
174 | ctx := cmd.Context()
175 | if err != nil {
176 | return err
177 | }
178 | return start.Start(ctx, inst)
179 | }
180 |
181 | func argSeemsHTTPURL(arg string) bool {
182 | u, err := url.Parse(arg)
183 | if err != nil {
184 | return false
185 | }
186 | if u.Scheme != "http" && u.Scheme != "https" {
187 | return false
188 | }
189 | return true
190 | }
191 |
192 | func argSeemsFileURL(arg string) bool {
193 | u, err := url.Parse(arg)
194 | if err != nil {
195 | return false
196 | }
197 | return u.Scheme == "file"
198 | }
199 |
200 | func argSeemsYAMLPath(arg string) bool {
201 | if strings.Contains(arg, "/") {
202 | return true
203 | }
204 | lower := strings.ToLower(arg)
205 | return strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml")
206 | }
207 |
208 | func instNameFromURL(urlStr string) (string, error) {
209 | u, err := url.Parse(urlStr)
210 | if err != nil {
211 | return "", err
212 | }
213 | return instNameFromYAMLPath(path.Base(u.Path))
214 | }
215 |
216 | func instNameFromYAMLPath(yamlPath string) (string, error) {
217 | s := strings.ToLower(filepath.Base(yamlPath))
218 | s = strings.TrimSuffix(strings.TrimSuffix(s, ".yml"), ".yaml")
219 | s = strings.ReplaceAll(s, ".", "-")
220 | return s, nil
221 | }
222 |
223 | func startBashComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
224 | instances, _ := bashCompleteInstanceNames(cmd)
225 | return instances, cobra.ShellCompDirectiveDefault
226 | }
227 |
228 | func readAtMaximum(r io.Reader, n int64) ([]byte, error) {
229 | lr := &io.LimitedReader{
230 | R: r,
231 | N: n,
232 | }
233 | b, err := io.ReadAll(lr)
234 | if err != nil {
235 | if errors.Is(err, io.EOF) && lr.N <= 0 {
236 | err = fmt.Errorf("exceeded the limit (%d bytes): %w", n, err)
237 | }
238 | }
239 | return b, err
240 | }
241 |
--------------------------------------------------------------------------------
/cmd/macvz/stop.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "github.com/mac-vz/macvz/pkg/store"
6 | "github.com/sirupsen/logrus"
7 | "github.com/spf13/cobra"
8 | "os"
9 | "path/filepath"
10 | "strings"
11 | "syscall"
12 | )
13 |
14 | func newStopCommand() *cobra.Command {
15 | var stopCmd = &cobra.Command{
16 | Use: "stop NAME",
17 | Short: fmt.Sprintf("Stop a VM based on the name of macvz instance"),
18 | Args: cobra.MaximumNArgs(1),
19 | ValidArgsFunction: stopBashComplete,
20 | RunE: stopAction,
21 | }
22 |
23 | stopCmd.Flags().BoolP("force", "f", false, "force stop the instance")
24 | return stopCmd
25 | }
26 |
27 | func stopAction(cmd *cobra.Command, args []string) error {
28 | instName := DefaultInstanceName
29 | if len(args) > 0 {
30 | instName = args[0]
31 | }
32 |
33 | inst, err := store.Inspect(instName)
34 | if err != nil {
35 | return err
36 | }
37 |
38 | force, err := cmd.Flags().GetBool("force")
39 | if err != nil {
40 | return err
41 | }
42 | if force {
43 | stopInstanceForcibly(inst)
44 | } else {
45 | err = stopInstanceGracefully(inst)
46 | }
47 | return err
48 | }
49 |
50 | func stopInstanceGracefully(inst *store.Instance) error {
51 | if inst.Status != store.StatusRunning {
52 | return fmt.Errorf("expected status %q, got %q (maybe use `macvz stop -f`?)", store.StatusRunning, inst.Status)
53 | }
54 |
55 | logrus.Infof("Sending SIGINT to vz process %d", inst.VZPid)
56 | if err := syscall.Kill(inst.VZPid, syscall.SIGINT); err != nil {
57 | logrus.Error(err)
58 | }
59 | //TODO - Check the log for termination
60 | return nil
61 | }
62 |
63 | func stopInstanceForcibly(inst *store.Instance) {
64 | if inst.VZPid > 0 {
65 | logrus.Infof("Sending SIGKILL to the vz process %d", inst.VZPid)
66 | if err := syscall.Kill(inst.VZPid, syscall.SIGKILL); err != nil {
67 | logrus.Error(err)
68 | }
69 | } else {
70 | logrus.Info("The host agent process seems already stopped")
71 | }
72 |
73 | logrus.Infof("Removing *.pid *.sock under %q", inst.Dir)
74 | fi, err := os.ReadDir(inst.Dir)
75 | if err != nil {
76 | logrus.Error(err)
77 | return
78 | }
79 | for _, f := range fi {
80 | path := filepath.Join(inst.Dir, f.Name())
81 | if strings.HasSuffix(path, ".pid") || strings.HasSuffix(path, ".sock") {
82 | logrus.Infof("Removing %q", path)
83 | if err := os.Remove(path); err != nil {
84 | logrus.Error(err)
85 | }
86 | }
87 | }
88 | }
89 |
90 | func stopBashComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
91 | instances, _ := bashCompleteInstanceNames(cmd)
92 | return instances, cobra.ShellCompDirectiveDefault
93 | }
94 |
--------------------------------------------------------------------------------
/cmd/macvz/vz.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "github.com/mac-vz/macvz/pkg/hostagent"
6 | "github.com/sirupsen/logrus"
7 | "github.com/spf13/cobra"
8 | "io"
9 | "os"
10 | "os/signal"
11 | )
12 |
13 | func newVZCommand() *cobra.Command {
14 | var vzCommand = &cobra.Command{
15 | Use: "vz NAME",
16 | Short: fmt.Sprintf("Start a VM based on the name of macvz instance"),
17 | Args: cobra.MaximumNArgs(1),
18 | ValidArgsFunction: vzBashComplete,
19 | RunE: vzAction,
20 | }
21 |
22 | return vzCommand
23 | }
24 |
25 | func vzBashComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
26 | instances, _ := bashCompleteInstanceNames(cmd)
27 | return instances, cobra.ShellCompDirectiveDefault
28 | }
29 |
30 | func vzAction(cmd *cobra.Command, args []string) error {
31 | instName := args[0]
32 | stderr := &syncWriter{w: cmd.ErrOrStderr()}
33 | initLogrus(stderr)
34 |
35 | sigintCh := make(chan os.Signal, 1)
36 | signal.Notify(sigintCh, os.Interrupt)
37 | agent, err := hostagent.New(instName, sigintCh)
38 | if err != nil {
39 | return err
40 | }
41 | ctx := cmd.Context()
42 | if err != nil {
43 | return err
44 | }
45 | return agent.Run(ctx)
46 | }
47 |
48 | // syncer is implemented by *os.File
49 | type syncer interface {
50 | Sync() error
51 | }
52 |
53 | type syncWriter struct {
54 | w io.Writer
55 | }
56 |
57 | func (w *syncWriter) Write(p []byte) (int, error) {
58 | written, err := w.w.Write(p)
59 | if err == nil {
60 | if s, ok := w.w.(syncer); ok {
61 | _ = s.Sync()
62 | }
63 | }
64 | return written, err
65 | }
66 |
67 | func initLogrus(stderr io.Writer) {
68 | logrus.SetOutput(stderr)
69 | // JSON logs are parsed in pkg/hostagent/events.Watcher()
70 | logrus.SetFormatter(new(logrus.JSONFormatter))
71 | logrus.SetLevel(logrus.DebugLevel)
72 | }
73 |
--------------------------------------------------------------------------------
/examples/docker.yaml:
--------------------------------------------------------------------------------
1 | images:
2 | - kernel: "https://cloud-images.ubuntu.com/releases/focal/release/unpacked/ubuntu-20.04-server-cloudimg-amd64-vmlinuz-generic"
3 | initram: "https://cloud-images.ubuntu.com/releases/focal/release/unpacked/ubuntu-20.04-server-cloudimg-amd64-initrd-generic"
4 | base: "https://cloud-images.ubuntu.com/releases/focal/release/ubuntu-20.04-server-cloudimg-amd64.tar.gz"
5 | arch: "x86_64"
6 | - kernel: "https://cloud-images.ubuntu.com/releases/focal/release/unpacked/ubuntu-20.04-server-cloudimg-arm64-vmlinuz-generic"
7 | initram: "https://cloud-images.ubuntu.com/releases/focal/release/unpacked/ubuntu-20.04-server-cloudimg-arm64-initrd-generic"
8 | base: "https://cloud-images.ubuntu.com/releases/focal/release/ubuntu-20.04-server-cloudimg-arm64.tar.gz"
9 | arch: "aarch64"
10 | mounts:
11 | - location: "~"
12 | writable: true
13 | disk: 20GB
14 | portForwards:
15 | - guestSocket: "/var/run/docker.sock"
16 | hostSocket: "{{.Dir}}/sock/docker.sock"
17 | - guestPortRange:
18 | - 1
19 | - 65535
20 | hostIP: 0.0.0.0
21 | provision:
22 | - mode: system
23 | script: |
24 | #!/bin/bash
25 | set -eux -o pipefail
26 | command -v docker >/dev/null 2>&1 && exit 0
27 | export DEBIAN_FRONTEND=noninteractive
28 | curl -fsSL https://get.docker.com | sh
29 | usermod --append --groups docker "${MACVZ_CIDATA_USER}"
30 | systemctl enable docker
31 | newgrp docker
32 | probes:
33 | - script: |
34 | #!/bin/bash
35 | set -eux -o pipefail
36 | if ! timeout 30s bash -c "until command -v docker >/dev/null 2>&1; do sleep 3; done"; then
37 | echo >&2 "docker is not installed yet"
38 | exit 1
39 | fi
40 | hint: See "/var/log/cloud-init-output.log". in the guest
41 | hostResolver:
42 | enabled: true
43 | hosts:
44 | host.docker.internal: host.macvz.internal
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/mac-vz/macvz
2 |
3 | go 1.18
4 |
5 | require (
6 | github.com/Code-Hex/vz/v2 v2.2.0
7 | github.com/alessio/shellescape v1.4.1
8 | github.com/cheggaaa/pb/v3 v3.0.8
9 | github.com/containerd/continuity v0.2.2
10 | github.com/coreos/go-semver v0.3.0
11 | github.com/diskfs/go-diskfs v1.2.0
12 | github.com/docker/go-units v0.4.0
13 | github.com/elastic/go-libaudit/v2 v2.2.0
14 | github.com/fxamacker/cbor/v2 v2.4.0
15 | github.com/h2non/filetype v1.1.3
16 | github.com/hashicorp/go-multierror v1.1.1
17 | github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87
18 | github.com/joho/godotenv v1.4.0
19 | github.com/lima-vm/sshocker v0.2.2
20 | github.com/mattn/go-isatty v0.0.14
21 | github.com/mdlayher/vsock v1.1.1
22 | github.com/miekg/dns v1.1.47
23 | github.com/mitchellh/go-homedir v1.1.0
24 | github.com/mitchellh/mapstructure v1.5.0
25 | github.com/norouter/norouter v0.6.4
26 | github.com/nxadm/tail v1.4.8
27 | github.com/opencontainers/go-digest v1.0.0
28 | github.com/sirupsen/logrus v1.8.1
29 | github.com/spf13/cobra v1.4.0
30 | github.com/xorcare/pointer v1.1.0
31 | github.com/yalue/native_endian v1.0.2
32 | golang.org/x/sys v0.0.0-20220808155132-1c4a2a72c664
33 | gopkg.in/yaml.v2 v2.4.0
34 | gotest.tools/v3 v3.1.0
35 | )
36 |
37 | require (
38 | github.com/Microsoft/go-winio v0.5.2 // indirect
39 | github.com/VividCortex/ewma v1.1.1 // indirect
40 | github.com/fatih/color v1.13.0 // indirect
41 | github.com/fsnotify/fsnotify v1.5.1 // indirect
42 | github.com/google/go-cmp v0.5.7 // indirect
43 | github.com/hashicorp/errwrap v1.1.0 // indirect
44 | github.com/inconshreveable/mousetrap v1.0.0 // indirect
45 | github.com/kr/text v0.2.0 // indirect
46 | github.com/mattn/go-colorable v0.1.12 // indirect
47 | github.com/mattn/go-runewidth v0.0.12 // indirect
48 | github.com/mdlayher/socket v0.2.0 // indirect
49 | github.com/pkg/errors v0.9.1 // indirect
50 | github.com/rivo/uniseg v0.2.0 // indirect
51 | github.com/rs/xid v1.4.0 // indirect
52 | github.com/spf13/pflag v1.0.5 // indirect
53 | github.com/stretchr/testify v1.7.0 // indirect
54 | github.com/x448/float16 v0.8.4 // indirect
55 | golang.org/x/mod v0.5.0 // indirect
56 | golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
57 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
58 | golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2 // indirect
59 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
60 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
61 | gopkg.in/djherbis/times.v1 v1.2.0 // indirect
62 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
63 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
64 | )
65 |
--------------------------------------------------------------------------------
/pkg/cidata/cidata.TEMPLATE.d/boot.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -eu
3 |
4 | INFO() {
5 | echo "MACVZ| $*"
6 | }
7 |
8 | WARNING() {
9 | echo "MACVZ| WARNING: $*"
10 | }
11 |
12 | whoami
13 | INFO "Resizing"
14 | resize2fs /dev/vda
15 |
16 | # shellcheck disable=SC2163
17 | while read -r line; do export "$line"; done <"${MACVZ_CIDATA_MNT}"/macvz.env
18 |
19 | CODE=0
20 |
21 | # Don't make any changes to /etc or /var/lib until boot/05-persistent-data-volume.sh
22 | # has run because it might move the directories to /mnt/data on first boot. In that
23 | # case changes made on restart would be lost.
24 |
25 | for f in "${MACVZ_CIDATA_MNT}"/boot/*; do
26 | INFO "Executing $f"
27 | if ! "$f"; then
28 | WARNING "Failed to execute $f"
29 | CODE=1
30 | fi
31 | done
32 |
33 | if [ -d "${MACVZ_CIDATA_MNT}"/provision.system ]; then
34 | for f in "${MACVZ_CIDATA_MNT}"/provision.system/*; do
35 | INFO "Executing $f"
36 | if ! "$f"; then
37 | WARNING "Failed to execute $f"
38 | CODE=1
39 | fi
40 | done
41 | fi
42 |
43 | USER_SCRIPT="/home/${MACVZ_CIDATA_USER}.linux/.macvz-user-script"
44 | if [ -d "${MACVZ_CIDATA_MNT}"/provision.user ]; then
45 | if [ ! -f /sbin/openrc-init ]; then
46 | until [ -e "/run/user/${MACVZ_CIDATA_UID}/systemd/private" ]; do sleep 3; done
47 | fi
48 | for f in "${MACVZ_CIDATA_MNT}"/provision.user/*; do
49 | INFO "Executing $f (as user ${MACVZ_CIDATA_USER})"
50 | cp "$f" "${USER_SCRIPT}"
51 | chown "${MACVZ_CIDATA_USER}" "${USER_SCRIPT}"
52 | chmod 755 "${USER_SCRIPT}"
53 | if ! sudo -iu "${MACVZ_CIDATA_USER}" "XDG_RUNTIME_DIR=/run/user/${MACVZ_CIDATA_UID}" "${USER_SCRIPT}"; then
54 | WARNING "Failed to execute $f (as user ${MACVZ_CIDATA_USER})"
55 | CODE=1
56 | fi
57 | rm "${USER_SCRIPT}"
58 | done
59 | fi
60 |
61 | # Signal that provisioning is done. The instance-id in the meta-data file changes on every boot,
62 | # so any copy from a previous boot cycle will have different content.
63 | cp "${MACVZ_CIDATA_MNT}"/meta-data /run/macvz-boot-done
64 |
65 | INFO "Exiting with code $CODE"
66 | exit "$CODE"
67 |
--------------------------------------------------------------------------------
/pkg/cidata/cidata.TEMPLATE.d/boot/00-modprobe.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | # Load modules as soon as the cloud-init starts up.
3 | # Because Arch Linux removes kernel module files when the kernel package was updated during running cloud-init :(
4 |
5 | set -eu
6 | for f in \
7 | fuse \
8 | tun tap \
9 | bridge veth \
10 | ip_tables ip6_tables iptable_nat ip6table_nat iptable_filter ip6table_filter \
11 | nf_tables \
12 | x_tables xt_MASQUERADE xt_addrtype xt_comment xt_conntrack xt_mark xt_multiport xt_nat xt_tcpudp \
13 | overlay; do
14 | echo "Loading kernel module \"$f\""
15 | if ! modprobe "$f"; then
16 | echo >&2 "Faild to load \"$f\" (negligible if it is built-in the kernel)"
17 | fi
18 | done
19 |
--------------------------------------------------------------------------------
/pkg/cidata/cidata.TEMPLATE.d/boot/07-etc-environment.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -eux
3 |
4 | # /etc/environment must be written after 05-persistent-data-volume.sh has run to
5 | # make sure the changes on a restart are applied to the persisted version.
6 |
7 | if [ -e /etc/environment ]; then
8 | sed -i '/#MACVZ-START/,/#MACVZ-END/d' /etc/environment
9 | fi
10 | cat "${MACVZ_CIDATA_MNT}/etc_environment" >>/etc/environment
11 |
12 | # It is possible that a requirements script has started an ssh session before
13 | # /etc/environment was updated, so we need to kill it to make sure it will
14 | # restart with the updated environment before "linger" is being enabled.
15 |
16 | if command -v loginctl >/dev/null 2>&1; then
17 | loginctl terminate-user "${MACVZ_CIDATA_USER}" || true
18 | fi
19 |
20 | # Make sure the guestagent socket from a previous boot is removed before we open the "macvz-ssh-ready" gate.
21 | rm -f /run/macvz-guest-agent.sock
22 |
23 | # Signal that provisioning is done. The instance-id in the meta-data file changes on every boot,
24 | # so any copy from a previous boot cycle will have different content.
25 | cp "${MACVZ_CIDATA_MNT}"/meta-data /run/macvz-ssh-ready
26 |
--------------------------------------------------------------------------------
/pkg/cidata/cidata.TEMPLATE.d/boot/09-host-dns-setup.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -eux
3 |
4 | export CURRENT_IPADDR=$(hostname -I | awk '{print $1}')
5 | export GATEWAY_IPADDR=$(ip route | grep default | awk '{print $3}')
6 |
7 | # Wait until iptables has been installed; 30-install-packages.sh will call this script again
8 | if command -v iptables >/dev/null 2>&1; then
9 | if [ -n "${MACVZ_CIDATA_UDP_DNS_LOCAL_PORT}" ] && [ "${MACVZ_CIDATA_UDP_DNS_LOCAL_PORT}" -ne 0 ]; then
10 | # Only add the rule once
11 | if ! iptables-save | grep "udp.*${CURRENT_IPADDR}:${MACVZ_CIDATA_UDP_DNS_LOCAL_PORT}"; then
12 | iptables -t nat -A PREROUTING -d ${GATEWAY_IPADDR}/32 -p udp --dport 53 -j DNAT \
13 | --to-destination "${CURRENT_IPADDR}:${MACVZ_CIDATA_UDP_DNS_LOCAL_PORT}"
14 | iptables -t nat -A OUTPUT -d ${GATEWAY_IPADDR}/32 -p udp --dport 53 -j DNAT \
15 | --to-destination "${CURRENT_IPADDR}:${MACVZ_CIDATA_UDP_DNS_LOCAL_PORT}"
16 | fi
17 | fi
18 | if [ -n "${MACVZ_CIDATA_TCP_DNS_LOCAL_PORT}" ] && [ "${MACVZ_CIDATA_TCP_DNS_LOCAL_PORT}" -ne 0 ]; then
19 | # Only add the rule once
20 | if ! iptables-save | grep "tcp.*${CURRENT_IPADDR}:${MACVZ_CIDATA_TCP_DNS_LOCAL_PORT}"; then
21 | iptables -t nat -A PREROUTING -d ${GATEWAY_IPADDR}/32 -p tcp --dport 53 -j DNAT \
22 | --to-destination "${CURRENT_IPADDR}:${MACVZ_CIDATA_TCP_DNS_LOCAL_PORT}"
23 | iptables -t nat -A OUTPUT -d ${GATEWAY_IPADDR}/32 -p tcp --dport 53 -j DNAT \
24 | --to-destination "${CURRENT_IPADDR}:${MACVZ_CIDATA_TCP_DNS_LOCAL_PORT}"
25 | fi
26 | fi
27 | fi
28 |
--------------------------------------------------------------------------------
/pkg/cidata/cidata.TEMPLATE.d/boot/20-rootless-base.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -eux
3 |
4 | # This script does not work unless systemd is available
5 | command -v systemctl >/dev/null 2>&1 || exit 0
6 |
7 | # Set up env
8 | for f in .profile .bashrc; do
9 | if ! grep -q "# Macvz BEGIN" "/home/${MACVZ_CIDATA_USER}.linux/$f"; then
10 | cat >>"/home/${MACVZ_CIDATA_USER}.linux/$f" <>"/home/${MACVZ_CIDATA_USER}.linux/$f" <"/etc/systemd/system/user@.service.d/macvz.conf" <>"${sysctl_conf}"
37 | fi
38 | echo "net.ipv4.ping_group_range = 0 2147483647" >>"${sysctl_conf}"
39 | echo "net.ipv4.ip_unprivileged_port_start=0" >>"${sysctl_conf}"
40 | sysctl --system
41 | fi
42 |
43 | # Set up subuid
44 | for f in /etc/subuid /etc/subgid; do
45 | grep -qw "${MACVZ_CIDATA_USER}" $f || echo "${MACVZ_CIDATA_USER}:100000:65536" >>$f
46 | done
47 |
48 | # Start systemd session
49 | systemctl start systemd-logind.service
50 | loginctl enable-linger "${MACVZ_CIDATA_USER}"
51 |
--------------------------------------------------------------------------------
/pkg/cidata/cidata.TEMPLATE.d/boot/25-guestagent-base.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -eux
4 |
5 | # Install or update the guestagent binary
6 | install -m 755 "${MACVZ_CIDATA_MNT}"/macvz-guestagent /usr/local/bin/macvz-guestagent
7 |
8 | export CURRENT_IPADDR=$(hostname -I | awk '{print $1}')
9 | export GATEWAY_IPADDR=$(ip route | grep default | awk '{print $3}')
10 |
11 | echo "CURRENT_IPADDR=${CURRENT_IPADDR}" >/etc/macvz_hosts
12 | echo "GATEWAY_IPADDR=${GATEWAY_IPADDR}" >>/etc/macvz_hosts
13 |
14 | # Launch the guestagent service
15 | if [ -f /sbin/openrc-init ]; then
16 | # Install the openrc macvz-guestagent service script
17 | cat >/etc/init.d/macvz-guestagent <<'EOF'
18 | #!/sbin/openrc-run
19 | supervisor=supervise-daemon
20 |
21 | name="macvz-guestagent"
22 | description="Forward ports to the macvz-hostagent"
23 |
24 | command=/usr/local/bin/macvz-guestagent
25 | command_args="daemon"
26 | command_background=true
27 | pidfile="/run/macvz-guestagent.pid"
28 | EOF
29 | chmod 755 /etc/init.d/macvz-guestagent
30 |
31 | rc-update add macvz-guestagent default
32 | rc-service macvz-guestagent start
33 | else
34 | # Remove legacy systemd service
35 | rm -f "/home/${MACVZ_CIDATA_USER}.linux/.config/systemd/user/macvz-guestagent.service"
36 |
37 | sudo /usr/local/bin/macvz-guestagent install-systemd
38 | fi
39 |
--------------------------------------------------------------------------------
/pkg/cidata/cidata.TEMPLATE.d/boot/30-install-packages.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -eux
3 |
4 | INSTALL_IPTABLES=0
5 | if [ "${MACVZ_CIDATA_UDP_DNS_LOCAL_PORT}" -ne 0 ] || [ "${MACVZ_CIDATA_TCP_DNS_LOCAL_PORT}" -ne 0 ]; then
6 | INSTALL_IPTABLES=1
7 | fi
8 |
9 | # Install minimum dependencies
10 | if command -v apt-get >/dev/null 2>&1; then
11 | pkgs=""
12 | if [ "${INSTALL_IPTABLES}" = 1 ] && [ ! -e /usr/sbin/iptables ]; then
13 | pkgs="${pkgs} iptables"
14 | fi
15 | if [ -n "${pkgs}" ]; then
16 | DEBIAN_FRONTEND=noninteractive
17 | export DEBIAN_FRONTEND
18 | apt-get update
19 | # shellcheck disable=SC2086
20 | apt-get install -y --no-upgrade --no-install-recommends -q ${pkgs}
21 | fi
22 | elif command -v dnf >/dev/null 2>&1; then
23 | pkgs=""
24 | if ! command -v tar >/dev/null 2>&1; then
25 | pkgs="${pkgs} tar"
26 | fi
27 | if [ "${INSTALL_IPTABLES}" = 1 ] && [ ! -e /usr/sbin/iptables ]; then
28 | pkgs="${pkgs} iptables"
29 | fi
30 | if [ -n "${pkgs}" ]; then
31 | dnf_install_flags="-y --setopt=install_weak_deps=False"
32 | if grep -q "Oracle Linux Server release 8" /etc/system-release; then
33 | # repo flag instead of enable repo to reduce metadata syncing on slow Oracle repos
34 | dnf_install_flags="${dnf_install_flags} --repo ol8_baseos_latest --repo ol8_codeready_builder"
35 | elif grep -q "release 8" /etc/system-release; then
36 | dnf_install_flags="${dnf_install_flags} --enablerepo powertools"
37 | fi
38 | # shellcheck disable=SC2086
39 | dnf install ${dnf_install_flags} ${pkgs}
40 | fi
41 | elif command -v apk >/dev/null 2>&1; then
42 | pkgs=""
43 | if [ "${INSTALL_IPTABLES}" = 1 ] && ! command -v iptables >/dev/null 2>&1; then
44 | pkgs="${pkgs} iptables"
45 | fi
46 | if [ -n "${pkgs}" ]; then
47 | apk update
48 | # shellcheck disable=SC2086
49 | apk add ${pkgs}
50 | fi
51 | fi
52 |
53 | SETUP_DNS=0
54 | if [ -n "${MACVZ_CIDATA_UDP_DNS_LOCAL_PORT}" ] && [ "${MACVZ_CIDATA_UDP_DNS_LOCAL_PORT}" -ne 0 ]; then
55 | SETUP_DNS=1
56 | fi
57 | if [ -n "${MACVZ_CIDATA_TCP_DNS_LOCAL_PORT}" ] && [ "${MACVZ_CIDATA_TCP_DNS_LOCAL_PORT}" -ne 0 ]; then
58 | SETUP_DNS=1
59 | fi
60 | if [ "${SETUP_DNS}" = 1 ]; then
61 | # Try to setup iptables rule again, in case we just installed iptables
62 | "${MACVZ_CIDATA_MNT}/boot/09-host-dns-setup.sh"
63 | fi
--------------------------------------------------------------------------------
/pkg/cidata/cidata.TEMPLATE.d/etc_environment:
--------------------------------------------------------------------------------
1 | #MACVZ-START
2 | {{- range $key, $val := .Env}}
3 | {{$key}}={{$val}}
4 | {{- end}}
5 | #MACVZ-END
6 |
--------------------------------------------------------------------------------
/pkg/cidata/cidata.TEMPLATE.d/macvz.env:
--------------------------------------------------------------------------------
1 | MACVZ_CIDATA_NAME={{ .Name }}
2 | MACVZ_CIDATA_USER={{ .User }}
3 | MACVZ_CIDATA_UID={{ .UID }}
4 | MACVZ_CIDATA_UDP_DNS_LOCAL_PORT={{ .UDPDNSLocalPort }}
5 | MACVZ_CIDATA_TCP_DNS_LOCAL_PORT={{ .TCPDNSLocalPort }}
6 |
--------------------------------------------------------------------------------
/pkg/cidata/cidata.TEMPLATE.d/meta-data:
--------------------------------------------------------------------------------
1 | instance-id: {{.IID}}
2 | local-hostname: macvz-{{.Name}}
3 |
--------------------------------------------------------------------------------
/pkg/cidata/cidata.TEMPLATE.d/user-data:
--------------------------------------------------------------------------------
1 | #cloud-config
2 | # vim:syntax=yaml
3 | bootcmd:
4 | - apt remove -y irqbalance
5 | users:
6 | - name: "{{.User}}"
7 | uid: "{{.UID}}"
8 | homedir: "/home/{{.User}}.linux"
9 | shell: /bin/bash
10 | sudo: ALL=(ALL) NOPASSWD:ALL
11 | lock_passwd: true
12 | ssh-authorized-keys:
13 | {{- range $val := .SSHPubKeys}}
14 | - "{{$val}}"
15 | {{- end}}
16 | write_files:
17 | - content: |
18 | #!/bin/sh
19 | set -eux
20 | MACVZ_CIDATA_MNT="/mnt/cidata"
21 | MACVZ_CIDATA_DEV="/dev/disk/by-label/cidata"
22 | mkdir -p -m 700 "${MACVZ_CIDATA_MNT}"
23 | mount -o ro,mode=0700,dmode=0700,overriderockperm,exec,uid=0 "${MACVZ_CIDATA_DEV}" "${MACVZ_CIDATA_MNT}"
24 | export MACVZ_CIDATA_MNT
25 | exec "${MACVZ_CIDATA_MNT}"/boot.sh
26 | owner: root:root
27 | path: /var/lib/cloud/scripts/per-boot/00-macvz.boot.sh
28 | permissions: '0755'
29 | network:
30 | version: 2
31 | renderer: networkd
32 | ethernets:
33 | enp0s1:
34 | dhcp4: true
--------------------------------------------------------------------------------
/pkg/cidata/cidata.go:
--------------------------------------------------------------------------------
1 | package cidata
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "github.com/mac-vz/macvz/pkg/sshutil"
7 | "github.com/mac-vz/macvz/pkg/yaml"
8 | "github.com/mitchellh/go-homedir"
9 | "io"
10 | "io/fs"
11 | "os"
12 | "path/filepath"
13 | "strconv"
14 | "strings"
15 | "time"
16 |
17 | "github.com/mac-vz/macvz/pkg/iso9660util"
18 | "github.com/mac-vz/macvz/pkg/osutil"
19 | "github.com/mac-vz/macvz/pkg/store/filenames"
20 | )
21 |
22 | func GenerateISO9660(instDir, name string, y *yaml.MacVZYaml) error {
23 | if err := yaml.Validate(*y, false); err != nil {
24 | return err
25 | }
26 | u, err := osutil.MacVZUser(true)
27 | if err != nil {
28 | return err
29 | }
30 | uid, err := strconv.Atoi(u.Uid)
31 | if err != nil {
32 | return err
33 | }
34 | args := TemplateArgs{
35 | Name: name,
36 | User: u.Username,
37 | UID: uid,
38 | }
39 |
40 | // change instance id on every boot so network config will be processed again
41 | args.IID = fmt.Sprintf("iid-%d", time.Now().Unix())
42 |
43 | pubKeys, err := sshutil.DefaultPubKeys(true)
44 | if err != nil {
45 | return err
46 | }
47 | if len(pubKeys) == 0 {
48 | return errors.New("no SSH key was found, run `ssh-keygen`")
49 | }
50 | for _, f := range pubKeys {
51 | args.SSHPubKeys = append(args.SSHPubKeys, f.Content)
52 | }
53 |
54 | if *y.HostResolver.Enabled {
55 | //TODO - Random post assignment in guest or replace port 53
56 | args.UDPDNSLocalPort = 23
57 | args.TCPDNSLocalPort = 24
58 | }
59 |
60 | if err := ValidateTemplateArgs(args); err != nil {
61 | return err
62 | }
63 |
64 | layout, err := ExecuteTemplate(args)
65 | if err != nil {
66 | return err
67 | }
68 |
69 | var sb strings.Builder
70 | for _, mount := range y.Mounts {
71 | expand, _ := homedir.Expand(mount.Location)
72 | sb.WriteString(fmt.Sprintf("sudo mkdir -p %s\n", expand))
73 | sb.WriteString(fmt.Sprintf("sudo mount -t virtiofs %s %s", expand, expand))
74 | }
75 | layout = append(layout, iso9660util.Entry{
76 | Path: fmt.Sprintf("provision.%s/%08d", yaml.ProvisionModeSystem, 0),
77 | Reader: strings.NewReader(sb.String()),
78 | })
79 |
80 | for i, f := range y.Provision {
81 | switch f.Mode {
82 | case yaml.ProvisionModeSystem, yaml.ProvisionModeUser:
83 | layout = append(layout, iso9660util.Entry{
84 | Path: fmt.Sprintf("provision.%s/%08d", f.Mode, i+1),
85 | Reader: strings.NewReader(f.Script),
86 | })
87 | default:
88 | return fmt.Errorf("unknown provision mode %q", f.Mode)
89 | }
90 | }
91 | arch := yaml.ResolveArch()
92 | if guestAgentBinary, err := GuestAgentBinary(arch); err != nil {
93 | return err
94 | } else {
95 | defer guestAgentBinary.Close()
96 | layout = append(layout, iso9660util.Entry{
97 | Path: "macvz-guestagent",
98 | Reader: guestAgentBinary,
99 | })
100 | }
101 |
102 | return iso9660util.Write(filepath.Join(instDir, filenames.CIDataISO), "cidata", layout)
103 | }
104 |
105 | func GuestAgentBinary(arch string) (io.ReadCloser, error) {
106 | if arch == "" {
107 | return nil, errors.New("arch must be set")
108 | }
109 | self, err := os.Executable()
110 | if err != nil {
111 | return nil, err
112 | }
113 | selfSt, err := os.Stat(self)
114 | if err != nil {
115 | return nil, err
116 | }
117 | if selfSt.Mode()&fs.ModeSymlink != 0 {
118 | self, err = os.Readlink(self)
119 | if err != nil {
120 | return nil, err
121 | }
122 | }
123 |
124 | // self: /usr/local/bin/limactl
125 | selfDir := filepath.Dir(self)
126 | selfDirDir := filepath.Dir(selfDir)
127 | candidates := []string{
128 | // candidate 0:
129 | // - self: /Applications/Lima.app/Contents/MacOS/limactl
130 | // - agent: /Applications/Lima.app/Contents/MacOS/lima-guestagent.Linux-x86_64
131 | filepath.Join(selfDir, "macvz-guestagent.Linux-"+arch),
132 | // candidate 1:
133 | // - self: /usr/local/bin/limactl
134 | // - agent: /usr/local/share/lima/lima-guestagent.Linux-x86_64
135 | filepath.Join(selfDirDir, "share/macvz/macvz-guestagent.Linux-"+arch),
136 | }
137 | for _, candidate := range candidates {
138 | if f, err := os.Open(candidate); err == nil {
139 | return f, nil
140 | } else if !errors.Is(err, os.ErrNotExist) {
141 | return nil, err
142 | }
143 | }
144 |
145 | return nil, fmt.Errorf("failed to find \"macvz-guestagent.Linux-%s\" binary for %q, attempted %v",
146 | arch, self, candidates)
147 | }
148 |
--------------------------------------------------------------------------------
/pkg/cidata/template.go:
--------------------------------------------------------------------------------
1 | package cidata
2 |
3 | import (
4 | "bytes"
5 | "embed"
6 | "errors"
7 | "fmt"
8 | "github.com/mac-vz/macvz/pkg/iso9660util"
9 | "io/fs"
10 |
11 | "github.com/mac-vz/macvz/pkg/templateutil"
12 | )
13 |
14 | //go:embed cidata.TEMPLATE.d
15 | var templateFS embed.FS
16 |
17 | const templateFSRoot = "cidata.TEMPLATE.d"
18 |
19 | type Network struct {
20 | MACAddress string
21 | Interface string
22 | }
23 | type TemplateArgs struct {
24 | Name string // instance name
25 | IID string // instance id
26 | User string // user name
27 | UID int
28 | SSHPubKeys []string
29 | UDPDNSLocalPort int
30 | TCPDNSLocalPort int
31 | Env map[string]string
32 | }
33 |
34 | func ValidateTemplateArgs(args TemplateArgs) error {
35 | if args.User == "root" {
36 | return errors.New("field User must not be \"root\"")
37 | }
38 | if args.UID == 0 {
39 | return errors.New("field UID must not be 0")
40 | }
41 | if len(args.SSHPubKeys) == 0 {
42 | return errors.New("field SSHPubKeys must be set")
43 | }
44 | return nil
45 | }
46 |
47 | func ExecuteTemplate(args TemplateArgs) ([]iso9660util.Entry, error) {
48 | if err := ValidateTemplateArgs(args); err != nil {
49 | return nil, err
50 | }
51 |
52 | fsys, err := fs.Sub(templateFS, templateFSRoot)
53 | if err != nil {
54 | return nil, err
55 | }
56 |
57 | var layout []iso9660util.Entry
58 | walkFn := func(path string, d fs.DirEntry, walkErr error) error {
59 | if walkErr != nil {
60 | return walkErr
61 | }
62 | if d.IsDir() {
63 | return nil
64 | }
65 | if !d.Type().IsRegular() {
66 | return fmt.Errorf("got non-regular file %q", path)
67 | }
68 | templateB, err := fs.ReadFile(fsys, path)
69 | if err != nil {
70 | return err
71 | }
72 | b, err := templateutil.Execute(string(templateB), args)
73 | if err != nil {
74 | return err
75 | }
76 | layout = append(layout, iso9660util.Entry{
77 | Path: path,
78 | Reader: bytes.NewReader(b),
79 | })
80 | return nil
81 | }
82 |
83 | if err := fs.WalkDir(fsys, ".", walkFn); err != nil {
84 | return nil, err
85 | }
86 |
87 | return layout, nil
88 | }
89 |
--------------------------------------------------------------------------------
/pkg/downloader/downloader.go:
--------------------------------------------------------------------------------
1 | package downloader
2 |
3 | import (
4 | "crypto/sha256"
5 | "errors"
6 | "fmt"
7 | "github.com/mitchellh/go-homedir"
8 | "io"
9 | "net/http"
10 | "os"
11 | "path/filepath"
12 | "strings"
13 | "time"
14 |
15 | "github.com/cheggaaa/pb/v3"
16 | "github.com/containerd/continuity/fs"
17 | "github.com/mattn/go-isatty"
18 | "github.com/opencontainers/go-digest"
19 | "github.com/sirupsen/logrus"
20 | )
21 |
22 | type Status = string
23 |
24 | const (
25 | StatusUnknown Status = ""
26 | StatusDownloaded Status = "downloaded"
27 | StatusSkipped Status = "skipped"
28 | StatusUsedCache Status = "used-cache"
29 | )
30 |
31 | type Result struct {
32 | Status Status
33 | CachePath string // "/Users/foo/Library/Caches/lima/download/by-url-sha256//data"
34 | ValidatedDigest bool
35 | }
36 |
37 | type options struct {
38 | cacheDir string // default: empty (disables caching)
39 | expectedDigest digest.Digest
40 | }
41 |
42 | type Opt func(*options) error
43 |
44 | // WithCache enables caching using filepath.Join(os.UserCacheDir(), "lima") as the cache dir.
45 | func WithCache() Opt {
46 | return func(o *options) error {
47 | ucd, err := os.UserCacheDir()
48 | if err != nil {
49 | return err
50 | }
51 | cacheDir := filepath.Join(ucd, "lima")
52 | return WithCacheDir(cacheDir)(o)
53 | }
54 | }
55 |
56 | // WithCacheDir enables caching using the specified dir.
57 | // Empty value disables caching.
58 | func WithCacheDir(cacheDir string) Opt {
59 | return func(o *options) error {
60 | o.cacheDir = cacheDir
61 | return nil
62 | }
63 | }
64 |
65 | // WithExpectedDigest is used to validate the downloaded file against the expected digest.
66 | //
67 | // The digest is not verified in the following cases:
68 | // - The digest was not specified.
69 | // - The file already exists in the local target path.
70 | //
71 | // When the `data` file exists in the cache dir with `digest.` file,
72 | // the digest is verified by comparing the content of `digest.` with the expected
73 | // digest string. So, the actual digest of the `data` file is not computed.
74 | func WithExpectedDigest(expectedDigest digest.Digest) Opt {
75 | return func(o *options) error {
76 | if expectedDigest != "" {
77 | if !expectedDigest.Algorithm().Available() {
78 | return fmt.Errorf("expected digest algorithm %q is not available", expectedDigest.Algorithm())
79 | }
80 | if err := expectedDigest.Validate(); err != nil {
81 | return err
82 | }
83 | }
84 |
85 | o.expectedDigest = expectedDigest
86 | return nil
87 | }
88 | }
89 |
90 | // Download downloads the remote resource into the local path.
91 | //
92 | // Download caches the remote resource if WithCache or WithCacheDir option is specified.
93 | // Local files are not cached.
94 | //
95 | // When the local path already exists, Download returns Result with StatusSkipped.
96 | // (So, the local path cannot be set to /dev/null for "caching only" mode.)
97 | //
98 | // The local path can be an empty string for "caching only" mode.
99 | func Download(local, remote string, opts ...Opt) (*Result, error) {
100 | var o options
101 | for _, f := range opts {
102 | if err := f(&o); err != nil {
103 | return nil, err
104 | }
105 | }
106 | var localPath string
107 | if local == "" {
108 | if o.cacheDir == "" {
109 | return nil, fmt.Errorf("caching-only mode requires the cache directory to be specified")
110 | }
111 | } else {
112 | var err error
113 | localPath, err = canonicalLocalPath(local)
114 | if err != nil {
115 | return nil, err
116 | }
117 | if _, err := os.Stat(localPath); err == nil {
118 | logrus.Debugf("file %q already exists, skipping downloading from %q (and skipping digest validation)", localPath, remote)
119 | res := &Result{
120 | Status: StatusSkipped,
121 | ValidatedDigest: false,
122 | }
123 | return res, nil
124 | } else if !errors.Is(err, os.ErrNotExist) {
125 | return nil, err
126 | }
127 |
128 | localPathDir := filepath.Dir(localPath)
129 | if err := os.MkdirAll(localPathDir, 0755); err != nil {
130 | return nil, err
131 | }
132 | }
133 |
134 | if IsLocal(remote) {
135 | if err := copyLocal(localPath, remote, o.expectedDigest); err != nil {
136 | return nil, err
137 | }
138 | res := &Result{
139 | Status: StatusDownloaded,
140 | ValidatedDigest: o.expectedDigest != "",
141 | }
142 | return res, nil
143 | }
144 |
145 | if o.cacheDir == "" {
146 | if err := downloadHTTP(localPath, remote, o.expectedDigest); err != nil {
147 | return nil, err
148 | }
149 | res := &Result{
150 | Status: StatusDownloaded,
151 | ValidatedDigest: o.expectedDigest != "",
152 | }
153 | return res, nil
154 | }
155 |
156 | shad := filepath.Join(o.cacheDir, "download", "by-url-sha256", fmt.Sprintf("%x", sha256.Sum256([]byte(remote))))
157 | shadData := filepath.Join(shad, "data")
158 | shadDigest := ""
159 | if o.expectedDigest != "" {
160 | algo := o.expectedDigest.Algorithm().String()
161 | if strings.Contains(algo, "/") || strings.Contains(algo, "\\") {
162 | return nil, fmt.Errorf("invalid digest algorithm %q", algo)
163 | }
164 | shadDigest = filepath.Join(shad, algo+".digest")
165 | }
166 | if _, err := os.Stat(shadData); err == nil {
167 | logrus.Debugf("file %q is cached as %q", localPath, shadData)
168 | if shadDigestB, err := os.ReadFile(shadDigest); err == nil {
169 | logrus.Debugf("Comparing digest %q with the cached digest file %q, not computing the actual digest of %q",
170 | o.expectedDigest, shadDigest, shadData)
171 | shadDigestS := strings.TrimSpace(string(shadDigestB))
172 | if o.expectedDigest.String() != shadDigestS {
173 | return nil, fmt.Errorf("expected digest %q does not match the cached digest %q", o.expectedDigest.String(), shadDigestS)
174 | }
175 | if err := copyLocal(localPath, shadData, ""); err != nil {
176 | return nil, err
177 | }
178 | } else {
179 | if err := copyLocal(localPath, shadData, o.expectedDigest); err != nil {
180 | return nil, err
181 | }
182 | }
183 | res := &Result{
184 | Status: StatusUsedCache,
185 | CachePath: shadData,
186 | ValidatedDigest: o.expectedDigest != "",
187 | }
188 | return res, nil
189 | }
190 | if err := os.RemoveAll(shad); err != nil {
191 | return nil, err
192 | }
193 | if err := os.MkdirAll(shad, 0700); err != nil {
194 | return nil, err
195 | }
196 | shadURL := filepath.Join(shad, "url")
197 | if err := os.WriteFile(shadURL, []byte(remote), 0644); err != nil {
198 | return nil, err
199 | }
200 | if err := downloadHTTP(shadData, remote, o.expectedDigest); err != nil {
201 | return nil, err
202 | }
203 | // no need to pass the digest to copyLocal(), as we already verified the digest
204 | if err := copyLocal(localPath, shadData, ""); err != nil {
205 | return nil, err
206 | }
207 | if shadDigest != "" && o.expectedDigest != "" {
208 | if err := os.WriteFile(shadDigest, []byte(o.expectedDigest.String()), 0644); err != nil {
209 | return nil, err
210 | }
211 | }
212 | res := &Result{
213 | Status: StatusDownloaded,
214 | CachePath: shadData,
215 | ValidatedDigest: o.expectedDigest != "",
216 | }
217 | return res, nil
218 | }
219 |
220 | func IsLocal(s string) bool {
221 | return !strings.Contains(s, "://") || strings.HasPrefix(s, "file://")
222 | }
223 |
224 | // canonicalLocalPath canonicalizes the local path string.
225 | // - Make sure the file has no scheme, or the `file://` scheme
226 | // - If it has the `file://` scheme, strip the scheme and make sure the filename is absolute
227 | // - Expand a leading `~`, or convert relative to absolute name
228 | func canonicalLocalPath(s string) (string, error) {
229 | if s == "" {
230 | return "", fmt.Errorf("got empty path")
231 | }
232 | if !IsLocal(s) {
233 | return "", fmt.Errorf("got non-local path: %q", s)
234 | }
235 | if strings.HasPrefix(s, "file://") {
236 | res := strings.TrimPrefix(s, "file://")
237 | if !filepath.IsAbs(res) {
238 | return "", fmt.Errorf("got non-absolute path %q", res)
239 | }
240 | return res, nil
241 | }
242 | return homedir.Expand(s)
243 | }
244 |
245 | func copyLocal(dst, src string, expectedDigest digest.Digest) error {
246 | if err := validateLocalFileDigest(src, expectedDigest); err != nil {
247 | return err
248 | }
249 | srcPath, err := canonicalLocalPath(src)
250 | if err != nil {
251 | return err
252 | }
253 | if dst == "" {
254 | // empty dst means caching-only mode
255 | return nil
256 | }
257 | dstPath, err := canonicalLocalPath(dst)
258 | if err != nil {
259 | return err
260 | }
261 | return fs.CopyFile(dstPath, srcPath)
262 | }
263 |
264 | func validateLocalFileDigest(localPath string, expectedDigest digest.Digest) error {
265 | if localPath == "" {
266 | return fmt.Errorf("validateLocalFileDigest: got empty localPath")
267 | }
268 | if expectedDigest == "" {
269 | return nil
270 | }
271 | logrus.Debugf("verifying digest of local file %q (%s)", localPath, expectedDigest)
272 | algo := expectedDigest.Algorithm()
273 | if !algo.Available() {
274 | return fmt.Errorf("expected digest algorithm %q is not available", algo)
275 | }
276 | r, err := os.Open(localPath)
277 | if err != nil {
278 | return err
279 | }
280 | defer r.Close()
281 | actualDigest, err := algo.FromReader(r)
282 | if err != nil {
283 | return err
284 | }
285 | if actualDigest != expectedDigest {
286 | return fmt.Errorf("expected digest %q, got %q", expectedDigest, actualDigest)
287 | }
288 | return nil
289 | }
290 |
291 | func createBar(size int64) (*pb.ProgressBar, error) {
292 | bar := pb.New64(size)
293 |
294 | bar.Set(pb.Bytes, true)
295 | if isatty.IsTerminal(os.Stdout.Fd()) {
296 | bar.SetTemplateString(`{{counters . }} {{bar . | green }} {{percent .}} {{speed . "%s/s"}}`)
297 | bar.SetRefreshRate(200 * time.Millisecond)
298 | } else {
299 | bar.Set(pb.Terminal, false)
300 | bar.Set(pb.ReturnSymbol, "\n")
301 | bar.SetTemplateString(`{{counters . }} ({{percent .}}) {{speed . "%s/s"}}`)
302 | bar.SetRefreshRate(5 * time.Second)
303 | }
304 | bar.SetWidth(80)
305 | if err := bar.Err(); err != nil {
306 | return nil, err
307 | }
308 |
309 | return bar, nil
310 | }
311 |
312 | func downloadHTTP(localPath, url string, expectedDigest digest.Digest) error {
313 | if localPath == "" {
314 | return fmt.Errorf("downloadHTTP: got empty localPath")
315 | }
316 | logrus.Debugf("downloading %q into %q", url, localPath)
317 | localPathTmp := localPath + ".tmp"
318 | if err := os.RemoveAll(localPathTmp); err != nil {
319 | return err
320 | }
321 | fileWriter, err := os.Create(localPathTmp)
322 | if err != nil {
323 | return err
324 | }
325 | defer fileWriter.Close()
326 |
327 | resp, err := http.Get(url)
328 | if err != nil {
329 | return err
330 | }
331 | defer resp.Body.Close()
332 | if resp.StatusCode != http.StatusOK {
333 | return fmt.Errorf("expected HTTP status %d, got %s", http.StatusOK, resp.Status)
334 | }
335 | bar, err := createBar(resp.ContentLength)
336 | if err != nil {
337 | return err
338 | }
339 |
340 | writers := []io.Writer{fileWriter}
341 | var digester digest.Digester
342 | if expectedDigest != "" {
343 | algo := expectedDigest.Algorithm()
344 | if !algo.Available() {
345 | return fmt.Errorf("unsupported digest algorithm %q", algo)
346 | }
347 | digester = algo.Digester()
348 | hasher := digester.Hash()
349 | writers = append(writers, hasher)
350 | }
351 | multiWriter := io.MultiWriter(writers...)
352 |
353 | bar.Start()
354 | if _, err := io.Copy(multiWriter, bar.NewProxyReader(resp.Body)); err != nil {
355 | return err
356 | }
357 | bar.Finish()
358 |
359 | if digester != nil {
360 | actualDigest := digester.Digest()
361 | if actualDigest != expectedDigest {
362 | return fmt.Errorf("expected digest %q, got %q", expectedDigest, actualDigest)
363 | }
364 | }
365 |
366 | if err := fileWriter.Sync(); err != nil {
367 | return err
368 | }
369 | if err := fileWriter.Close(); err != nil {
370 | return err
371 | }
372 | if err := os.RemoveAll(localPath); err != nil {
373 | return err
374 | }
375 | if err := os.Rename(localPathTmp, localPath); err != nil {
376 | return err
377 | }
378 |
379 | return nil
380 | }
381 |
--------------------------------------------------------------------------------
/pkg/downloader/downloader_test.go:
--------------------------------------------------------------------------------
1 | package downloader
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "path/filepath"
7 | "testing"
8 |
9 | "github.com/opencontainers/go-digest"
10 | "gotest.tools/v3/assert"
11 | )
12 |
13 | const (
14 | dummyRemoteFileDigest = "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" //sha256 for test
15 | )
16 |
17 | func startDummyServer() *httptest.Server {
18 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19 | _, err := w.Write([]byte("test"))
20 | if err != nil {
21 | return
22 | }
23 | }))
24 | return svr
25 | }
26 |
27 | func TestDownloadRemote(t *testing.T) {
28 | server := startDummyServer()
29 |
30 | dummyRemoteFileURL := server.URL
31 | if testing.Short() {
32 | t.Skip()
33 | }
34 | t.Run("without cache", func(t *testing.T) {
35 | t.Run("without digest", func(t *testing.T) {
36 | localPath := filepath.Join(t.TempDir(), t.Name())
37 | r, err := Download(localPath, dummyRemoteFileURL)
38 | assert.NilError(t, err)
39 | assert.Equal(t, StatusDownloaded, r.Status)
40 |
41 | // download again, make sure StatusSkippedIsReturned
42 | r, err = Download(localPath, dummyRemoteFileURL)
43 | assert.NilError(t, err)
44 | assert.Equal(t, StatusSkipped, r.Status)
45 | })
46 | t.Run("with digest", func(t *testing.T) {
47 | wrongDigest := digest.Digest("sha256:8313944efb4f38570c689813f288058b674ea6c487017a5a4738dc674b65f9d9")
48 | localPath := filepath.Join(t.TempDir(), t.Name())
49 | _, err := Download(localPath, dummyRemoteFileURL, WithExpectedDigest(wrongDigest))
50 | assert.ErrorContains(t, err, "expected digest")
51 |
52 | r, err := Download(localPath, dummyRemoteFileURL, WithExpectedDigest(dummyRemoteFileDigest))
53 | assert.NilError(t, err)
54 | assert.Equal(t, StatusDownloaded, r.Status)
55 |
56 | r, err = Download(localPath, dummyRemoteFileURL, WithExpectedDigest(dummyRemoteFileDigest))
57 | assert.NilError(t, err)
58 | assert.Equal(t, StatusSkipped, r.Status)
59 | })
60 | })
61 | t.Run("with cache", func(t *testing.T) {
62 | cacheDir := filepath.Join(t.TempDir(), "cache")
63 | localPath := filepath.Join(t.TempDir(), t.Name())
64 | r, err := Download(localPath, dummyRemoteFileURL, WithExpectedDigest(dummyRemoteFileDigest), WithCacheDir(cacheDir))
65 | assert.NilError(t, err)
66 | assert.Equal(t, StatusDownloaded, r.Status)
67 |
68 | r, err = Download(localPath, dummyRemoteFileURL, WithExpectedDigest(dummyRemoteFileDigest), WithCacheDir(cacheDir))
69 | assert.NilError(t, err)
70 | assert.Equal(t, StatusSkipped, r.Status)
71 |
72 | localPath2 := localPath + "-2"
73 | r, err = Download(localPath2, dummyRemoteFileURL, WithExpectedDigest(dummyRemoteFileDigest), WithCacheDir(cacheDir))
74 | assert.NilError(t, err)
75 | assert.Equal(t, StatusUsedCache, r.Status)
76 | })
77 | t.Run("caching-only mode", func(t *testing.T) {
78 | _, err := Download("", dummyRemoteFileURL, WithExpectedDigest(dummyRemoteFileDigest))
79 | assert.ErrorContains(t, err, "cache directory to be specified")
80 |
81 | cacheDir := filepath.Join(t.TempDir(), "cache")
82 | r, err := Download("", dummyRemoteFileURL, WithExpectedDigest(dummyRemoteFileDigest), WithCacheDir(cacheDir))
83 | assert.NilError(t, err)
84 | assert.Equal(t, StatusDownloaded, r.Status)
85 |
86 | r, err = Download("", dummyRemoteFileURL, WithExpectedDigest(dummyRemoteFileDigest), WithCacheDir(cacheDir))
87 | assert.NilError(t, err)
88 | assert.Equal(t, StatusUsedCache, r.Status)
89 |
90 | localPath := filepath.Join(t.TempDir(), t.Name())
91 | r, err = Download(localPath, dummyRemoteFileURL, WithExpectedDigest(dummyRemoteFileDigest), WithCacheDir(cacheDir))
92 | assert.NilError(t, err)
93 | assert.Equal(t, StatusUsedCache, r.Status)
94 | })
95 | }
96 |
--------------------------------------------------------------------------------
/pkg/guestagent/api/api.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "net"
5 | )
6 |
7 | var (
8 | IPv4loopback1 = net.IPv4(127, 0, 0, 1)
9 | )
10 |
--------------------------------------------------------------------------------
/pkg/guestagent/guestagent.go:
--------------------------------------------------------------------------------
1 | package guestagent
2 |
3 | type Agent interface {
4 | PublishInfo()
5 | StartDNS()
6 | ListenAndSendEvents()
7 | }
8 |
--------------------------------------------------------------------------------
/pkg/guestagent/guestagent_linux.go:
--------------------------------------------------------------------------------
1 | package guestagent
2 |
3 | import (
4 | "encoding/binary"
5 | "errors"
6 | "github.com/hashicorp/yamux"
7 | "github.com/joho/godotenv"
8 | "github.com/mac-vz/macvz/pkg/guestagent/guestdns"
9 | "github.com/mac-vz/macvz/pkg/socket"
10 | "github.com/mac-vz/macvz/pkg/types"
11 | "reflect"
12 | "sync"
13 | "time"
14 |
15 | "github.com/elastic/go-libaudit/v2"
16 | "github.com/elastic/go-libaudit/v2/auparse"
17 | "github.com/mac-vz/macvz/pkg/guestagent/iptables"
18 | "github.com/mac-vz/macvz/pkg/guestagent/procnettcp"
19 | "github.com/mac-vz/macvz/pkg/guestagent/timesync"
20 | "github.com/sirupsen/logrus"
21 | "github.com/yalue/native_endian"
22 | )
23 |
24 | // New creates guest agent that takes care of guest to host communication
25 | func New(newTicker func() (<-chan time.Time, func()), sess *yamux.Session, iptablesIdle time.Duration) (Agent, error) {
26 | a := &agent{
27 | newTicker: newTicker,
28 | sess: sess,
29 | }
30 | go a.fixSystemTimeSkew()
31 |
32 | auditClient, err := libaudit.NewMulticastAuditClient(nil)
33 | if err != nil {
34 | return nil, err
35 | }
36 | auditStatus, err := auditClient.GetStatus()
37 | if err != nil {
38 | return nil, err
39 | }
40 | if auditStatus.Enabled == 0 {
41 | if err = auditClient.SetEnabled(true, libaudit.WaitForReply); err != nil {
42 | return nil, err
43 | }
44 | }
45 |
46 | go a.setWorthCheckingIPTablesRoutine(auditClient, iptablesIdle)
47 | return a, nil
48 | }
49 |
50 | type agent struct {
51 | // Ticker is like time.Ticker.
52 | // We can't use inotify for /proc/net/tcp, so we need this ticker to
53 | // reload /proc/net/tcp.
54 | newTicker func() (<-chan time.Time, func())
55 | sess *yamux.Session
56 |
57 | worthCheckingIPTables bool
58 | worthCheckingIPTablesMu sync.RWMutex
59 | latestIPTables []iptables.Entry
60 | latestIPTablesMu sync.RWMutex
61 | }
62 |
63 | // setWorthCheckingIPTablesRoutine sets worthCheckingIPTables to be true
64 | // when received NETFILTER_CFG audit message.
65 | //
66 | // setWorthCheckingIPTablesRoutine sets worthCheckingIPTables to be false
67 | // when no NETFILTER_CFG audit message was received for the iptablesIdle time.
68 | func (a *agent) setWorthCheckingIPTablesRoutine(auditClient *libaudit.AuditClient, iptablesIdle time.Duration) {
69 | var latestTrue time.Time
70 | go func() {
71 | for {
72 | time.Sleep(iptablesIdle)
73 | a.worthCheckingIPTablesMu.Lock()
74 | // time is monotonic, see https://pkg.go.dev/time#hdr-Monotonic_Clocks
75 | elapsedSinceLastTrue := time.Since(latestTrue)
76 | if elapsedSinceLastTrue >= iptablesIdle {
77 | logrus.Debug("setWorthCheckingIPTablesRoutine(): setting to false")
78 | a.worthCheckingIPTables = false
79 | }
80 | a.worthCheckingIPTablesMu.Unlock()
81 | }
82 | }()
83 | for {
84 | msg, err := auditClient.Receive(false)
85 | if err != nil {
86 | logrus.Error(err)
87 | continue
88 | }
89 | switch msg.Type {
90 | case auparse.AUDIT_NETFILTER_CFG:
91 | a.worthCheckingIPTablesMu.Lock()
92 | logrus.Debug("setWorthCheckingIPTablesRoutine(): setting to true")
93 | a.worthCheckingIPTables = true
94 | latestTrue = time.Now()
95 | a.worthCheckingIPTablesMu.Unlock()
96 | }
97 | }
98 | }
99 |
100 | type eventState struct {
101 | ports []types.IPPort
102 | }
103 |
104 | func comparePorts(old, neww []types.IPPort) (added, removed []types.IPPort) {
105 | mRaw := make(map[string]types.IPPort, len(old))
106 | mStillExist := make(map[string]bool, len(old))
107 |
108 | for _, f := range old {
109 | k := f.String()
110 | mRaw[k] = f
111 | mStillExist[k] = false
112 | }
113 | for _, f := range neww {
114 | k := f.String()
115 | if _, ok := mRaw[k]; !ok {
116 | added = append(added, f)
117 | }
118 | mStillExist[k] = true
119 | }
120 |
121 | for k, stillExist := range mStillExist {
122 | if !stillExist {
123 | if x, ok := mRaw[k]; ok {
124 | removed = append(removed, x)
125 | }
126 | }
127 | }
128 | return
129 | }
130 |
131 | func (a *agent) collectEvent(st eventState) (types.PortEvent, eventState) {
132 | var (
133 | ev types.PortEvent
134 | err error
135 | )
136 | newSt := st
137 | newSt.ports, err = a.localPorts()
138 | ev.Kind = types.PortMessage
139 | if err != nil {
140 | ev.Errors = append(ev.Errors, err.Error())
141 | ev.Time = time.Now().Format(time.RFC3339)
142 | return ev, newSt
143 | }
144 | ev.LocalPortsAdded, ev.LocalPortsRemoved = comparePorts(st.ports, newSt.ports)
145 | ev.Time = time.Now().Format(time.RFC3339)
146 | return ev, newSt
147 | }
148 |
149 | func isEventEmpty(ev types.PortEvent) bool {
150 | var empty types.PortEvent
151 | empty.Kind = types.PortMessage
152 | // ignore ev.Time
153 | copied := ev
154 | copied.Time = empty.Time
155 | return reflect.DeepEqual(empty, copied)
156 | }
157 |
158 | func (a *agent) StartDNS() {
159 | dnsServer, _ := guestdns.Start(23, 24, a.sess)
160 | defer dnsServer.Shutdown()
161 | }
162 |
163 | func (a *agent) ListenAndSendEvents() {
164 | tickerCh, tickerClose := a.newTicker()
165 |
166 | defer tickerClose()
167 | var st eventState
168 | for {
169 | var ev types.PortEvent
170 | ev, st = a.collectEvent(st)
171 | if !isEventEmpty(ev) {
172 | encoder, _ := socket.GetIO(a.sess)
173 | if encoder != nil {
174 | socket.Write(encoder, ev)
175 | }
176 | }
177 | select {
178 | case _, ok := <-tickerCh:
179 | if !ok {
180 | return
181 | }
182 | logrus.Debug("tick!")
183 | }
184 | }
185 | }
186 |
187 | func (a *agent) localPorts() ([]types.IPPort, error) {
188 | if native_endian.NativeEndian() == binary.BigEndian {
189 | return nil, errors.New("big endian architecture is unsupported, because I don't know how /proc/net/tcp looks like on big endian hosts")
190 | }
191 | var res []types.IPPort
192 | tcpParsed, err := procnettcp.ParseFiles()
193 | if err != nil {
194 | return res, err
195 | }
196 |
197 | for _, f := range tcpParsed {
198 | switch f.Kind {
199 | case procnettcp.TCP, procnettcp.TCP6:
200 | default:
201 | continue
202 | }
203 | if f.State == procnettcp.TCPListen {
204 | res = append(res,
205 | types.IPPort{
206 | IP: f.IP,
207 | Port: int(f.Port),
208 | })
209 | }
210 | }
211 |
212 | a.worthCheckingIPTablesMu.RLock()
213 | worthCheckingIPTables := a.worthCheckingIPTables
214 | a.worthCheckingIPTablesMu.RUnlock()
215 | logrus.Debugf("LocalPorts(): worthCheckingIPTables=%v", worthCheckingIPTables)
216 |
217 | var ipts []iptables.Entry
218 | if a.worthCheckingIPTables {
219 | ipts, err = iptables.GetPorts()
220 | if err != nil {
221 | return res, err
222 | }
223 | a.latestIPTablesMu.Lock()
224 | a.latestIPTables = ipts
225 | a.latestIPTablesMu.Unlock()
226 | } else {
227 | a.latestIPTablesMu.RLock()
228 | ipts = a.latestIPTables
229 | a.latestIPTablesMu.RUnlock()
230 | }
231 |
232 | for _, ipt := range ipts {
233 | // Make sure the port isn't already listed from procnettcp
234 | found := false
235 | for _, re := range res {
236 | if re.Port == ipt.Port {
237 | found = true
238 | }
239 | }
240 | if !found {
241 | res = append(res,
242 | types.IPPort{
243 | IP: ipt.IP,
244 | Port: ipt.Port,
245 | })
246 | }
247 | }
248 |
249 | return res, nil
250 | }
251 |
252 | func (a *agent) PublishInfo() {
253 | var (
254 | info types.InfoEvent
255 | err error
256 | )
257 | ips, err := godotenv.Read("/etc/macvz_hosts")
258 | if err != nil {
259 | logrus.Error("Unable to fetch predefined hosts")
260 | }
261 |
262 | info.GatewayIP = ips["GATEWAY_IPADDR"]
263 | info.LocalPorts, err = a.localPorts()
264 | if err != nil {
265 | logrus.Error("Error getting local ports", err)
266 | }
267 | encoder, _ := socket.GetIO(a.sess)
268 | if encoder != nil {
269 | info.Kind = types.InfoMessage
270 | socket.Write(encoder, info)
271 | }
272 | }
273 |
274 | const deltaLimit = 2 * time.Second
275 |
276 | func (a *agent) fixSystemTimeSkew() {
277 | for {
278 | ticker := time.NewTicker(10 * time.Second)
279 | for now := range ticker.C {
280 | rtc, err := timesync.GetRTCTime()
281 | if err != nil {
282 | logrus.Warnf("fixSystemTimeSkew: lookup error: %s", err.Error())
283 | continue
284 | }
285 | d := rtc.Sub(now)
286 | logrus.Debugf("fixSystemTimeSkew: rtc=%s systime=%s delta=%s",
287 | rtc.Format(time.RFC3339), now.Format(time.RFC3339), d)
288 | if d > deltaLimit || d < -deltaLimit {
289 | err = timesync.SetSystemTime(rtc)
290 | if err != nil {
291 | logrus.Warnf("fixSystemTimeSkew: set system clock error: %s", err.Error())
292 | continue
293 | }
294 | logrus.Infof("fixSystemTimeSkew: system time synchronized with rtc")
295 | break
296 | }
297 | }
298 | ticker.Stop()
299 | }
300 | }
301 |
--------------------------------------------------------------------------------
/pkg/guestagent/guestdns/dns.go:
--------------------------------------------------------------------------------
1 | // This file has been adapted from https://github.com/norouter/norouter/blob/v0.6.4/pkg/agent/dns/dns.go
2 |
3 | package guestdns
4 |
5 | import (
6 | "fmt"
7 | "github.com/hashicorp/yamux"
8 | "github.com/mac-vz/macvz/pkg/socket"
9 | "github.com/mac-vz/macvz/pkg/types"
10 | "github.com/miekg/dns"
11 | )
12 |
13 | type handler struct {
14 | yamux *yamux.Session
15 | }
16 |
17 | //Server Custom DNSServer instance holds udp and tcp servers
18 | type Server struct {
19 | udp *dns.Server
20 | tcp *dns.Server
21 | }
22 |
23 | //Shutdown stops DNS servers
24 | func (s *Server) Shutdown() {
25 | if s.udp != nil {
26 | _ = s.udp.Shutdown()
27 | }
28 | if s.tcp != nil {
29 | _ = s.tcp.Shutdown()
30 | }
31 | }
32 |
33 | func newHandler(yamux *yamux.Session) (dns.Handler, error) {
34 | h := &handler{
35 | yamux: yamux,
36 | }
37 | return h, nil
38 | }
39 |
40 | //ServeDNS forwards the DNS request to host
41 | func (h *handler) ServeDNS(w dns.ResponseWriter, req *dns.Msg) {
42 | encoder, decoder := socket.GetIO(h.yamux)
43 | if encoder != nil && decoder != nil {
44 | //Construct DNSEvent and send request to host
45 | event := types.DNSEvent{}
46 | event.Kind = types.DNSMessage
47 | pack, _ := req.Pack()
48 | event.Msg = pack
49 | socket.Write(encoder, &event)
50 |
51 | //Read DNS response from host
52 | var reply dns.Msg
53 | dnsRes := types.DNSEventResponse{}
54 | socket.Read(decoder, &dnsRes)
55 | _ = reply.Unpack(dnsRes.Msg)
56 |
57 | //Write the response back to dns writer
58 | _ = w.WriteMsg(&reply)
59 | }
60 | }
61 |
62 | //Start initialise DNS server
63 | func Start(udpLocalPort, tcpLocalPort int, yamux *yamux.Session) (*Server, error) {
64 | h, err := newHandler(yamux)
65 | if err != nil {
66 | return nil, err
67 | }
68 | server := &Server{}
69 | if udpLocalPort > 0 {
70 | addr := fmt.Sprintf("0.0.0.0:%d", udpLocalPort)
71 | s := &dns.Server{Net: "udp", Addr: addr, Handler: h}
72 | server.udp = s
73 | go func() {
74 | if e := s.ListenAndServe(); e != nil {
75 | panic(e)
76 | }
77 | }()
78 | }
79 | if tcpLocalPort > 0 {
80 | addr := fmt.Sprintf("0.0.0.0:%d", tcpLocalPort)
81 | s := &dns.Server{Net: "tcp", Addr: addr, Handler: h}
82 | server.tcp = s
83 | go func() {
84 | if e := s.ListenAndServe(); e != nil {
85 | panic(e)
86 | }
87 | }()
88 | }
89 | return server, nil
90 | }
91 |
--------------------------------------------------------------------------------
/pkg/guestagent/iptables/iptables.go:
--------------------------------------------------------------------------------
1 | package iptables
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "net"
7 | "os/exec"
8 | "regexp"
9 | "strconv"
10 | "strings"
11 | "time"
12 | )
13 |
14 | type Entry struct {
15 | TCP bool
16 | IP net.IP
17 | Port int
18 | }
19 |
20 | // This regex can detect a line in the iptables added by portmap to do the
21 | // forwarding. The following two are examples of lines (notice that one has the
22 | // destination IP and the other does not):
23 | // -A CNI-DN-2e2f8d5b91929ef9fc152 -d 127.0.0.1/32 -p tcp -m tcp --dport 8081 -j DNAT --to-destination 10.4.0.7:80
24 | // -A CNI-DN-04579c7bb67f4c3f6cca0 -p tcp -m tcp --dport 8082 -j DNAT --to-destination 10.4.0.10:80
25 | // The -A on the front is to amend the rule that was already created. portmap
26 | // ensures the rule is created before creating this line so it is always -A.
27 | // CNI-DN- is the prefix used for rule for an individual container.
28 | // -d is followed by the IP address. The regular expression looks for a valid
29 | // ipv4 IP address. We need to detect this IP.
30 | // --dport is the destination port. We need to detect this port
31 | // -j DNAT this tells us it's the line doing the port forwarding.
32 | var findPortRegex = regexp.MustCompile(`-A\s+CNI-DN-\w*\s+(?:-d ((?:\b25[0-5]|\b2[0-4][0-9]|\b[01]?[0-9][0-9]?)(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}))?(?:/32\s+)?-p (tcp)?.*--dport (\d+) -j DNAT`)
33 |
34 | func GetPorts() ([]Entry, error) {
35 | // TODO: add support for ipv6
36 |
37 | // Detect the location of iptables. If it is not installed skip the lookup
38 | // and return no results. The lookup is performed on each run so that the
39 | // agent does not need to be started to detect if iptables was installed
40 | // after the agent is already running.
41 | pth, err := exec.LookPath("iptables")
42 | if err != nil {
43 | if errors.Is(err, exec.ErrNotFound) {
44 | return nil, nil
45 | }
46 |
47 | return nil, err
48 | }
49 |
50 | res, err := listNATRules(pth)
51 | if err != nil {
52 | return nil, err
53 | }
54 |
55 | pts, err := parsePortsFromRules(res)
56 | if err != nil {
57 | return nil, err
58 | }
59 |
60 | return checkPortsOpen(pts)
61 | }
62 |
63 | func parsePortsFromRules(rules []string) ([]Entry, error) {
64 | var entries []Entry
65 | for _, rule := range rules {
66 | if found := findPortRegex.FindStringSubmatch(rule); found != nil {
67 | if len(found) == 4 {
68 | port, err := strconv.Atoi(found[3])
69 | if err != nil {
70 | return nil, err
71 | }
72 |
73 | istcp := false
74 | if found[2] == "tcp" {
75 | istcp = true
76 | }
77 |
78 | // When no IP is present the rule applies to all interfaces.
79 | ip := found[1]
80 | if ip == "" {
81 | ip = "0.0.0.0"
82 | }
83 | ent := Entry{
84 | IP: net.ParseIP(ip),
85 | Port: port,
86 | TCP: istcp,
87 | }
88 | entries = append(entries, ent)
89 | }
90 | }
91 | }
92 |
93 | return entries, nil
94 | }
95 |
96 | // listNATRules performs the lookup with iptables and returns the raw rules
97 | // Note, this does not use github.com/coreos/go-iptables (a transitive dependency
98 | // of lima) because that package would require multiple calls to iptables. This
99 | // function does everything in a single call.
100 | func listNATRules(pth string) ([]string, error) {
101 | args := []string{pth, "-t", "nat", "-S"}
102 |
103 | var stdout bytes.Buffer
104 | var stderr bytes.Buffer
105 | cmd := exec.Cmd{
106 | Path: pth,
107 | Args: args,
108 | Stdout: &stdout,
109 | Stderr: &stderr,
110 | }
111 | if err := cmd.Run(); err != nil {
112 | return nil, err
113 | }
114 |
115 | // turn the output into a rule per line.
116 | rules := strings.Split(stdout.String(), "\n")
117 | if len(rules) > 0 && rules[len(rules)-1] == "" {
118 | rules = rules[:len(rules)-1]
119 | }
120 |
121 | return rules, nil
122 | }
123 |
124 | func checkPortsOpen(pts []Entry) ([]Entry, error) {
125 | var entries []Entry
126 | for _, pt := range pts {
127 | if pt.TCP {
128 | conn, err := net.DialTimeout("tcp", net.JoinHostPort(pt.IP.String(), strconv.Itoa(pt.Port)), time.Second)
129 | if err == nil && conn != nil {
130 | conn.Close()
131 | entries = append(entries, pt)
132 | }
133 | } else {
134 | entries = append(entries, pt)
135 | }
136 | }
137 |
138 | return entries, nil
139 | }
140 |
--------------------------------------------------------------------------------
/pkg/guestagent/iptables/iptables_test.go:
--------------------------------------------------------------------------------
1 | package iptables
2 |
3 | import (
4 | "strings"
5 | "testing"
6 | )
7 |
8 | // data is from a run of `iptables -t nat -S` with two containers running (started
9 | // with sudo nerdctl) and have exposed ports 8081 and 8082.
10 | const data = `# Warning: iptables-legacy tables present, use iptables-legacy to see them
11 | -P PREROUTING ACCEPT
12 | -P INPUT ACCEPT
13 | -P OUTPUT ACCEPT
14 | -P POSTROUTING ACCEPT
15 | -N CNI-04579c7bb67f4c3f6cca0185
16 | -N CNI-28e04aad9bf52e38b43f8700
17 | -N CNI-2d72aeb202429907277c53c5
18 | -N CNI-2e2f8d5b91929ef9fc152e75
19 | -N CNI-3cbb832b23c724bdddedd7e4
20 | -N CNI-5033e3bad9f1265a2b04037f
21 | -N CNI-DN-04579c7bb67f4c3f6cca0
22 | -N CNI-DN-2d72aeb202429907277c5
23 | -N CNI-DN-2e2f8d5b91929ef9fc152
24 | -N CNI-HOSTPORT-DNAT
25 | -N CNI-HOSTPORT-MASQ
26 | -N CNI-HOSTPORT-SETMARK
27 | -N CNI-cb0db077a14ecd8d4a843636
28 | -N CNI-f1ca917e7b9939c7d8457d68
29 | -A PREROUTING -m addrtype --dst-type LOCAL -j CNI-HOSTPORT-DNAT
30 | -A OUTPUT -m addrtype --dst-type LOCAL -j CNI-HOSTPORT-DNAT
31 | -A POSTROUTING -m comment --comment "CNI portfwd requiring masquerade" -j CNI-HOSTPORT-MASQ
32 | -A POSTROUTING -s 10.4.0.3/32 -m comment --comment "name: \"bridge\" id: \"default-44540a2b2cc6c1154d2a21aec473d6987ec4d6bc339e89ee295a6db433ad623e\"" -j CNI-5033e3bad9f1265a2b04037f
33 | -A POSTROUTING -s 10.4.0.4/32 -m comment --comment "name: \"bridge\" id: \"default-cf12b94944785a4c8937e237a0a277d893cbadebd50409ed5d4b8ca3f90fedf3\"" -j CNI-28e04aad9bf52e38b43f8700
34 | -A POSTROUTING -s 10.4.0.5/32 -m comment --comment "name: \"bridge\" id: \"default-e9d499901490e6a66277688ba8d71cca35a6d1ca6261bc5a7e11e45e80aa3ea3\"" -j CNI-3cbb832b23c724bdddedd7e4
35 | -A POSTROUTING -s 10.4.0.6/32 -m comment --comment "name: \"bridge\" id: \"default-a65e32cc21f9da99b4aa826914873e343f8f09f910657450be551aa24d676e51\"" -j CNI-f1ca917e7b9939c7d8457d68
36 | -A POSTROUTING -s 10.4.0.7/32 -m comment --comment "name: \"bridge\" id: \"default-c93e2a3a2264f98647f0d33dc80d88de81c0710bf30ea822e2ed19213f9c53b5\"" -j CNI-2e2f8d5b91929ef9fc152e75
37 | -A POSTROUTING -s 10.4.0.8/32 -m comment --comment "name: \"bridge\" id: \"default-a8df9868a5f7ee2468118331dd6185e5655f7ff8e77f067408b7ff40e9457860\"" -j CNI-cb0db077a14ecd8d4a843636
38 | -A POSTROUTING -s 10.4.0.9/32 -m comment --comment "name: \"bridge\" id: \"default-393bd750d06186633a02b44487765ce038b7df434bfb16027ca1903bf5f3dc31\"" -j CNI-2d72aeb202429907277c53c5
39 | -A POSTROUTING -s 10.4.0.10/32 -m comment --comment "name: \"bridge\" id: \"default-3d263c6a1c710edc1362764464c073ca834ec9adc0766411772f2b7a3dd1de0f\"" -j CNI-04579c7bb67f4c3f6cca0185
40 | -A CNI-04579c7bb67f4c3f6cca0185 -d 10.4.0.0/24 -m comment --comment "name: \"bridge\" id: \"default-3d263c6a1c710edc1362764464c073ca834ec9adc0766411772f2b7a3dd1de0f\"" -j ACCEPT
41 | -A CNI-04579c7bb67f4c3f6cca0185 ! -d 224.0.0.0/4 -m comment --comment "name: \"bridge\" id: \"default-3d263c6a1c710edc1362764464c073ca834ec9adc0766411772f2b7a3dd1de0f\"" -j MASQUERADE
42 | -A CNI-28e04aad9bf52e38b43f8700 -d 10.4.0.0/24 -m comment --comment "name: \"bridge\" id: \"default-cf12b94944785a4c8937e237a0a277d893cbadebd50409ed5d4b8ca3f90fedf3\"" -j ACCEPT
43 | -A CNI-28e04aad9bf52e38b43f8700 ! -d 224.0.0.0/4 -m comment --comment "name: \"bridge\" id: \"default-cf12b94944785a4c8937e237a0a277d893cbadebd50409ed5d4b8ca3f90fedf3\"" -j MASQUERADE
44 | -A CNI-2d72aeb202429907277c53c5 -d 10.4.0.0/24 -m comment --comment "name: \"bridge\" id: \"default-393bd750d06186633a02b44487765ce038b7df434bfb16027ca1903bf5f3dc31\"" -j ACCEPT
45 | -A CNI-2d72aeb202429907277c53c5 ! -d 224.0.0.0/4 -m comment --comment "name: \"bridge\" id: \"default-393bd750d06186633a02b44487765ce038b7df434bfb16027ca1903bf5f3dc31\"" -j MASQUERADE
46 | -A CNI-2e2f8d5b91929ef9fc152e75 -d 10.4.0.0/24 -m comment --comment "name: \"bridge\" id: \"default-c93e2a3a2264f98647f0d33dc80d88de81c0710bf30ea822e2ed19213f9c53b5\"" -j ACCEPT
47 | -A CNI-2e2f8d5b91929ef9fc152e75 ! -d 224.0.0.0/4 -m comment --comment "name: \"bridge\" id: \"default-c93e2a3a2264f98647f0d33dc80d88de81c0710bf30ea822e2ed19213f9c53b5\"" -j MASQUERADE
48 | -A CNI-3cbb832b23c724bdddedd7e4 -d 10.4.0.0/24 -m comment --comment "name: \"bridge\" id: \"default-e9d499901490e6a66277688ba8d71cca35a6d1ca6261bc5a7e11e45e80aa3ea3\"" -j ACCEPT
49 | -A CNI-3cbb832b23c724bdddedd7e4 ! -d 224.0.0.0/4 -m comment --comment "name: \"bridge\" id: \"default-e9d499901490e6a66277688ba8d71cca35a6d1ca6261bc5a7e11e45e80aa3ea3\"" -j MASQUERADE
50 | -A CNI-5033e3bad9f1265a2b04037f -d 10.4.0.0/24 -m comment --comment "name: \"bridge\" id: \"default-44540a2b2cc6c1154d2a21aec473d6987ec4d6bc339e89ee295a6db433ad623e\"" -j ACCEPT
51 | -A CNI-5033e3bad9f1265a2b04037f ! -d 224.0.0.0/4 -m comment --comment "name: \"bridge\" id: \"default-44540a2b2cc6c1154d2a21aec473d6987ec4d6bc339e89ee295a6db433ad623e\"" -j MASQUERADE
52 | -A CNI-DN-04579c7bb67f4c3f6cca0 -s 10.4.0.0/24 -p tcp -m tcp --dport 8082 -j CNI-HOSTPORT-SETMARK
53 | -A CNI-DN-04579c7bb67f4c3f6cca0 -s 127.0.0.1/32 -p tcp -m tcp --dport 8082 -j CNI-HOSTPORT-SETMARK
54 | -A CNI-DN-04579c7bb67f4c3f6cca0 -p tcp -m tcp --dport 8082 -j DNAT --to-destination 10.4.0.10:80
55 | -A CNI-DN-2e2f8d5b91929ef9fc152 -s 10.4.0.0/24 -d 127.0.0.1/32 -p tcp -m tcp --dport 8081 -j CNI-HOSTPORT-SETMARK
56 | -A CNI-DN-2e2f8d5b91929ef9fc152 -s 127.0.0.1/32 -d 127.0.0.1/32 -p tcp -m tcp --dport 8081 -j CNI-HOSTPORT-SETMARK
57 | -A CNI-DN-2e2f8d5b91929ef9fc152 -d 127.0.0.1/32 -p tcp -m tcp --dport 8081 -j DNAT --to-destination 10.4.0.7:80
58 | -A CNI-HOSTPORT-DNAT -p tcp -m comment --comment "dnat name: \"bridge\" id: \"default-c93e2a3a2264f98647f0d33dc80d88de81c0710bf30ea822e2ed19213f9c53b5\"" -m multiport --dports 8081 -j CNI-DN-2e2f8d5b91929ef9fc152
59 | -A CNI-HOSTPORT-DNAT -p tcp -m comment --comment "dnat name: \"bridge\" id: \"default-393bd750d06186633a02b44487765ce038b7df434bfb16027ca1903bf5f3dc31\"" -m multiport --dports 8082 -j CNI-DN-2d72aeb202429907277c5
60 | -A CNI-HOSTPORT-DNAT -p tcp -m comment --comment "dnat name: \"bridge\" id: \"default-3d263c6a1c710edc1362764464c073ca834ec9adc0766411772f2b7a3dd1de0f\"" -m multiport --dports 8082 -j CNI-DN-04579c7bb67f4c3f6cca0
61 | -A CNI-HOSTPORT-MASQ -m mark --mark 0x2000/0x2000 -j MASQUERADE
62 | -A CNI-HOSTPORT-SETMARK -m comment --comment "CNI portfwd masquerade mark" -j MARK --set-xmark 0x2000/0x2000
63 | -A CNI-cb0db077a14ecd8d4a843636 -d 10.4.0.0/24 -m comment --comment "name: \"bridge\" id: \"default-a8df9868a5f7ee2468118331dd6185e5655f7ff8e77f067408b7ff40e9457860\"" -j ACCEPT
64 | -A CNI-cb0db077a14ecd8d4a843636 ! -d 224.0.0.0/4 -m comment --comment "name: \"bridge\" id: \"default-a8df9868a5f7ee2468118331dd6185e5655f7ff8e77f067408b7ff40e9457860\"" -j MASQUERADE
65 | -A CNI-f1ca917e7b9939c7d8457d68 -d 10.4.0.0/24 -m comment --comment "name: \"bridge\" id: \"default-a65e32cc21f9da99b4aa826914873e343f8f09f910657450be551aa24d676e51\"" -j ACCEPT
66 | -A CNI-f1ca917e7b9939c7d8457d68 ! -d 224.0.0.0/4 -m comment --comment "name: \"bridge\" id: \"default-a65e32cc21f9da99b4aa826914873e343f8f09f910657450be551aa24d676e51\"" -j MASQUERADE
67 | `
68 |
69 | func TestParsePortsFromRules(t *testing.T) {
70 |
71 | // Turn the string into individual lines
72 | rules := strings.Split(data, "\n")
73 | if len(rules) > 0 && rules[len(rules)-1] == "" {
74 | rules = rules[:len(rules)-1]
75 | }
76 |
77 | res, err := parsePortsFromRules(rules)
78 | if err != nil {
79 | t.Errorf("parsing iptables ports failed with error: %s", err)
80 | }
81 |
82 | l := len(res)
83 | if l != 2 {
84 | t.Fatalf("expected 2 ports parsed from iptables but parsed %d", l)
85 | }
86 |
87 | if res[0].IP.String() != "0.0.0.0" || res[0].Port != 8082 || res[0].TCP != true {
88 | t.Errorf("expected port 8082 on IP 0.0.0.0 with TCP true but got port %d on IP %s with TCP %t", res[0].Port, res[0].IP.String(), res[0].TCP)
89 | }
90 | if res[1].IP.String() != "127.0.0.1" || res[1].Port != 8081 || res[1].TCP != true {
91 | t.Errorf("expected port 8081 on IP 127.0.0.1 with TCP true but go port %d on IP %s with TCP %t", res[1].Port, res[1].IP.String(), res[1].TCP)
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/pkg/guestagent/procnettcp/procnettcp.go:
--------------------------------------------------------------------------------
1 | package procnettcp
2 |
3 | import (
4 | "bufio"
5 | "encoding/hex"
6 | "fmt"
7 | "io"
8 | "net"
9 | "strconv"
10 | "strings"
11 | )
12 |
13 | type Kind = string
14 |
15 | const (
16 | TCP Kind = "tcp"
17 | TCP6 Kind = "tcp6"
18 | // TODO: "udp", "udp6", "udplite", "udplite6"
19 | )
20 |
21 | type State = int
22 |
23 | const (
24 | TCPEstablished State = 0x1
25 | TCPListen State = 0xA
26 | )
27 |
28 | type Entry struct {
29 | Kind Kind `json:"kind"`
30 | IP net.IP `json:"ip"`
31 | Port uint16 `json:"port"`
32 | State State `json:"state"`
33 | }
34 |
35 | func Parse(r io.Reader, kind Kind) ([]Entry, error) {
36 | switch kind {
37 | case TCP, TCP6:
38 | default:
39 | return nil, fmt.Errorf("unexpected kind %q", kind)
40 | }
41 |
42 | var entries []Entry
43 | sc := bufio.NewScanner(r)
44 |
45 | // As of kernel 5.11, ["local_address"] = 1
46 | fieldNames := make(map[string]int)
47 | for i := 0; sc.Scan(); i++ {
48 | line := strings.TrimSpace(sc.Text())
49 | if line == "" {
50 | continue
51 | }
52 | fields := strings.Fields(line)
53 | switch i {
54 | case 0:
55 | for j := 0; j < len(fields); j++ {
56 | fieldNames[fields[j]] = j
57 | }
58 | if _, ok := fieldNames["local_address"]; !ok {
59 | return nil, fmt.Errorf("field \"local_address\" not found")
60 | }
61 | if _, ok := fieldNames["st"]; !ok {
62 | return nil, fmt.Errorf("field \"st\" not found")
63 | }
64 |
65 | default:
66 | // localAddress is like "0100007F:053A"
67 | localAddress := fields[fieldNames["local_address"]]
68 | ip, port, err := ParseAddress(localAddress)
69 | if err != nil {
70 | return entries, err
71 | }
72 |
73 | stStr := fields[fieldNames["st"]]
74 | st, err := strconv.ParseUint(stStr, 16, 8)
75 | if err != nil {
76 | return entries, err
77 | }
78 |
79 | ent := Entry{
80 | Kind: kind,
81 | IP: ip,
82 | Port: port,
83 | State: int(st),
84 | }
85 | entries = append(entries, ent)
86 | }
87 | }
88 |
89 | if err := sc.Err(); err != nil {
90 | return entries, err
91 | }
92 | return entries, nil
93 | }
94 |
95 | // ParseAddress parses a string, e.g.,
96 | // "0100007F:0050" (127.0.0.1:80)
97 | // "000080FE00000000FF57A6705DC771FE:0050" ([fe80::70a6:57ff:fe71:c75d]:80)
98 | // "00000000000000000000000000000000:0050" (0.0.0.0:80)
99 | //
100 | // See https://serverfault.com/questions/592574/why-does-proc-net-tcp6-represents-1-as-1000
101 | //
102 | // ParseAddress is expected to be used for /proc/net/{tcp,tcp6} entries on
103 | // little endian machines.
104 | // Not sure how those entries look like on big endian machines.
105 | func ParseAddress(s string) (net.IP, uint16, error) {
106 | split := strings.SplitN(s, ":", 2)
107 | if len(split) != 2 {
108 | return nil, 0, fmt.Errorf("unparsable address %q", s)
109 | }
110 | switch l := len(split[0]); l {
111 | case 8, 32:
112 | default:
113 | return nil, 0, fmt.Errorf("unparsable address %q, expected length of %q to be 8 or 32, got %d",
114 | s, split[0], l)
115 | }
116 |
117 | ipBytes := make([]byte, len(split[0])/2) // 4 bytes (8 chars) or 16 bytes (32 chars)
118 | for i := 0; i < len(split[0])/8; i++ {
119 | quartet := split[0][8*i : 8*(i+1)]
120 | quartetLE, err := hex.DecodeString(quartet) // surprisingly little endian, per 4 bytes
121 | if err != nil {
122 | return nil, 0, fmt.Errorf("unparsable address %q: unparsable quartet %q: %w", s, quartet, err)
123 | }
124 | for j := 0; j < len(quartetLE); j++ {
125 | ipBytes[4*i+len(quartetLE)-1-j] = quartetLE[j]
126 | }
127 | }
128 | ip := net.IP(ipBytes)
129 |
130 | port64, err := strconv.ParseUint(split[1], 16, 16)
131 | if err != nil {
132 | return nil, 0, fmt.Errorf("unparsable address %q: unparsable port %q", s, split[1])
133 | }
134 | port := uint16(port64)
135 |
136 | return ip, port, nil
137 | }
138 |
--------------------------------------------------------------------------------
/pkg/guestagent/procnettcp/procnettcp_linux.go:
--------------------------------------------------------------------------------
1 | package procnettcp
2 |
3 | import (
4 | "errors"
5 | "os"
6 | )
7 |
8 | // ParseFiles parses /proc/net/{tcp, tcp6}
9 | func ParseFiles() ([]Entry, error) {
10 | var res []Entry
11 | files := map[string]Kind{
12 | "/proc/net/tcp": TCP,
13 | "/proc/net/tcp6": TCP6,
14 | }
15 | for file, kind := range files {
16 | r, err := os.Open(file)
17 | if err != nil {
18 | if errors.Is(err, os.ErrNotExist) {
19 | continue
20 | }
21 | return res, err
22 | }
23 | defer r.Close()
24 | parsed, err := Parse(r, kind)
25 | if err != nil {
26 | return res, err
27 | }
28 | res = append(res, parsed...)
29 | }
30 | return res, nil
31 | }
32 |
--------------------------------------------------------------------------------
/pkg/guestagent/procnettcp/procnettcp_test.go:
--------------------------------------------------------------------------------
1 | package procnettcp
2 |
3 | import (
4 | "net"
5 | "strings"
6 | "testing"
7 |
8 | "gotest.tools/v3/assert"
9 | )
10 |
11 | func TestParseTCP(t *testing.T) {
12 | procNetTCP := ` sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
13 | 0: 0100007F:8AEF 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 28152 1 0000000000000000 100 0 0 10 0
14 | 1: 0103000A:0035 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 31474 1 0000000000000000 100 0 0 10 5
15 | 2: 3500007F:0035 00000000:0000 0A 00000000:00000000 00:00000000 00000000 102 0 30955 1 0000000000000000 100 0 0 10 0
16 | 3: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 32910 1 0000000000000000 100 0 0 10 0
17 | 4: 0100007F:053A 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 31430 1 0000000000000000 100 0 0 10 0
18 | 5: 0B3CA8C0:0016 690AA8C0:F705 01 00000000:00000000 02:00028D8B 00000000 0 0 32989 4 0000000000000000 20 4 31 10 19
19 | `
20 | entries, err := Parse(strings.NewReader(procNetTCP), TCP)
21 | assert.NilError(t, err)
22 | t.Log(entries)
23 |
24 | assert.Check(t, net.ParseIP("127.0.0.1").Equal(entries[0].IP))
25 | assert.Equal(t, uint16(35567), entries[0].Port)
26 | assert.Equal(t, TCPListen, entries[0].State)
27 |
28 | assert.Check(t, net.ParseIP("192.168.60.11").Equal(entries[5].IP))
29 | assert.Equal(t, uint16(22), entries[5].Port)
30 | assert.Equal(t, TCPEstablished, entries[5].State)
31 | }
32 |
33 | func TestParseTCP6(t *testing.T) {
34 | procNetTCP := ` sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
35 | 0: 000080FE00000000FF57A6705DC771FE:0050 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 850222 1 0000000000000000 100 0 0 10 0`
36 | entries, err := Parse(strings.NewReader(procNetTCP), TCP6)
37 | assert.NilError(t, err)
38 | t.Log(entries)
39 |
40 | assert.Check(t, net.ParseIP("fe80::70a6:57ff:fe71:c75d").Equal(entries[0].IP))
41 | assert.Equal(t, uint16(80), entries[0].Port)
42 | assert.Equal(t, TCPListen, entries[0].State)
43 | }
44 |
45 | func TestParseTCP6Zero(t *testing.T) {
46 | procNetTCP := ` sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
47 | 0: 00000000000000000000000000000000:0016 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 33825 1 0000000000000000 100 0 0 10 0
48 | 1: 00000000000000000000000000000000:006F 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 26772 1 0000000000000000 100 0 0 10 0
49 | 2: 00000000000000000000000000000000:0050 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1210901 1 0000000000000000 100 0 0 10 0
50 | `
51 | entries, err := Parse(strings.NewReader(procNetTCP), TCP6)
52 | assert.NilError(t, err)
53 | t.Log(entries)
54 |
55 | assert.Check(t, net.IPv6zero.Equal(entries[0].IP))
56 | assert.Equal(t, uint16(22), entries[0].Port)
57 | assert.Equal(t, TCPListen, entries[0].State)
58 | }
59 |
--------------------------------------------------------------------------------
/pkg/guestagent/timesync/timesync_linux.go:
--------------------------------------------------------------------------------
1 | package timesync
2 |
3 | import (
4 | "os"
5 | "time"
6 |
7 | "golang.org/x/sys/unix"
8 | )
9 |
10 | const rtc = "/dev/rtc"
11 |
12 | func GetRTCTime() (t time.Time, err error) {
13 | f, err := os.Open(rtc)
14 | if err != nil {
15 | return
16 | }
17 | defer f.Close()
18 | obj, err := unix.IoctlGetRTCTime(int(f.Fd()))
19 | if err != nil {
20 | return
21 | }
22 | t = time.Date(int(obj.Year+1900), time.Month(obj.Mon+1), int(obj.Mday), int(obj.Hour), int(obj.Min), int(obj.Sec), 0, time.UTC)
23 | return t, nil
24 | }
25 |
26 | func SetSystemTime(t time.Time) error {
27 | v := unix.NsecToTimeval(t.UnixNano())
28 | return unix.Settimeofday(&v)
29 | }
30 |
--------------------------------------------------------------------------------
/pkg/hostagent/dns/dns.go:
--------------------------------------------------------------------------------
1 | // This file has been adapted from https://github.com/norouter/norouter/blob/v0.6.4/pkg/agent/dns/dns.go
2 |
3 | package dns
4 |
5 | import (
6 | "fmt"
7 | "github.com/mac-vz/macvz/pkg/yaml"
8 | "github.com/miekg/dns"
9 | "github.com/sirupsen/logrus"
10 | "net"
11 | "strings"
12 | )
13 |
14 | // Truncate for avoiding "Parse error" from `busybox nslookup`
15 | // https://github.com/lima-vm/lima/issues/380
16 | const truncateSize = 512
17 |
18 | type Handler struct {
19 | clientConfig *dns.ClientConfig
20 | clients []*dns.Client
21 | IPv6 bool
22 | cname map[string]string
23 | ip map[string]net.IP
24 | }
25 |
26 | func newStaticClientConfig(ips []net.IP) (*dns.ClientConfig, error) {
27 | s := ``
28 | for _, ip := range ips {
29 | s += fmt.Sprintf("nameserver %s\n", ip.String())
30 | }
31 | r := strings.NewReader(s)
32 | return dns.ClientConfigFromReader(r)
33 | }
34 |
35 | //CreateHandler Starts DNS handler to receive request from guest
36 | func CreateHandler(IPv6 bool) (*Handler, error) {
37 | cc, err := dns.ClientConfigFromFile("/etc/resolv.conf")
38 | if err != nil {
39 | fallbackIPs := []net.IP{net.ParseIP("8.8.8.8"), net.ParseIP("1.1.1.1")}
40 | logrus.WithError(err).Warnf("failed to detect system DNS, falling back to %v", fallbackIPs)
41 | cc, err = newStaticClientConfig(fallbackIPs)
42 | if err != nil {
43 | return nil, err
44 | }
45 | }
46 | clients := []*dns.Client{
47 | {}, // UDP
48 | {Net: "tcp"},
49 | }
50 | h := &Handler{
51 | clientConfig: cc,
52 | clients: clients,
53 | IPv6: IPv6,
54 | cname: make(map[string]string),
55 | ip: make(map[string]net.IP),
56 | }
57 | return h, nil
58 | }
59 |
60 | func (h *Handler) handleQuery(req *dns.Msg) *dns.Msg {
61 | var (
62 | reply dns.Msg
63 | handled bool
64 | )
65 | reply.SetReply(req)
66 | for _, q := range req.Question {
67 | hdr := dns.RR_Header{
68 | Name: q.Name,
69 | Rrtype: q.Qtype,
70 | Class: q.Qclass,
71 | Ttl: 5,
72 | }
73 | qtype := q.Qtype
74 | switch qtype {
75 | case dns.TypeAAAA:
76 | if !h.IPv6 {
77 | // A "correct" answer would be to set `handled = true` and return a NODATA response.
78 | // Unfortunately some older resolvers use a slow random source to set the transaction id.
79 | // This creates a problem on M1 computers, which are too fast for that implementation:
80 | // Both the A and AAAA queries might end up with the same id. Returning NODATA for AAAA
81 | // is faster, so would arrive first, and be treated as the response to the A query.
82 | // To avoid this, we will treat an AAAA query as an A query when IPv6 has been disabled.
83 | // This way it is either a valid response for an A query, or the A records will be discarded
84 | // by a genuine AAAA query, resulting in the desired NODATA response.
85 | qtype = dns.TypeA
86 | }
87 | fallthrough
88 | case dns.TypeCNAME, dns.TypeA:
89 | cname := q.Name
90 | seen := make(map[string]bool)
91 | for {
92 | // break cyclic definition
93 | if seen[cname] {
94 | break
95 | }
96 | if _, ok := h.cname[cname]; ok {
97 | seen[cname] = true
98 | cname = h.cname[cname]
99 | continue
100 | }
101 | break
102 | }
103 | var err error
104 | if _, ok := h.ip[cname]; !ok {
105 | cname, err = net.LookupCNAME(cname)
106 | if err != nil {
107 | break
108 | }
109 | }
110 | if cname != "" && cname != q.Name {
111 | hdr.Rrtype = dns.TypeCNAME
112 | a := &dns.CNAME{
113 | Hdr: hdr,
114 | Target: cname,
115 | }
116 | reply.Answer = append(reply.Answer, a)
117 | handled = true
118 | }
119 | if qtype == dns.TypeCNAME {
120 | break
121 | }
122 | hdr.Name = cname
123 | var addrs []net.IP
124 | if _, ok := h.ip[cname]; ok {
125 | addrs = []net.IP{h.ip[cname]}
126 | err = nil
127 | } else {
128 | addrs, err = net.LookupIP(cname)
129 | }
130 | if err == nil && len(addrs) > 0 {
131 | for _, ip := range addrs {
132 | var a dns.RR
133 | ipv6 := ip.To4() == nil
134 | if qtype == dns.TypeA && !ipv6 {
135 | hdr.Rrtype = dns.TypeA
136 | a = &dns.A{
137 | Hdr: hdr,
138 | A: ip.To4(),
139 | }
140 | } else if qtype == dns.TypeAAAA && ipv6 {
141 | hdr.Rrtype = dns.TypeAAAA
142 | a = &dns.AAAA{
143 | Hdr: hdr,
144 | AAAA: ip.To16(),
145 | }
146 | } else {
147 | continue
148 | }
149 | reply.Answer = append(reply.Answer, a)
150 | handled = true
151 | }
152 | }
153 | case dns.TypeTXT:
154 | txt, err := net.LookupTXT(q.Name)
155 | if err == nil && len(txt) > 0 {
156 | a := &dns.TXT{
157 | Hdr: hdr,
158 | Txt: txt,
159 | }
160 | reply.Answer = append(reply.Answer, a)
161 | handled = true
162 | }
163 | case dns.TypeNS:
164 | ns, err := net.LookupNS(q.Name)
165 | if err == nil && len(ns) > 0 {
166 | for _, s := range ns {
167 | if s.Host != "" {
168 | a := &dns.NS{
169 | Hdr: hdr,
170 | Ns: s.Host,
171 | }
172 | reply.Answer = append(reply.Answer, a)
173 | handled = true
174 | }
175 | }
176 | }
177 | case dns.TypeMX:
178 | mx, err := net.LookupMX(q.Name)
179 | if err == nil && len(mx) > 0 {
180 | for _, s := range mx {
181 | if s.Host != "" {
182 | a := &dns.MX{
183 | Hdr: hdr,
184 | Mx: s.Host,
185 | Preference: s.Pref,
186 | }
187 | reply.Answer = append(reply.Answer, a)
188 | handled = true
189 | }
190 | }
191 | }
192 | case dns.TypeSRV:
193 | _, addrs, err := net.LookupSRV("", "", q.Name)
194 | if err == nil {
195 | hdr.Rrtype = dns.TypeSRV
196 | for _, addr := range addrs {
197 | a := &dns.SRV{
198 | Hdr: hdr,
199 | Target: addr.Target,
200 | Port: addr.Port,
201 | Priority: addr.Priority,
202 | Weight: addr.Weight,
203 | }
204 | reply.Answer = append(reply.Answer, a)
205 | handled = true
206 | }
207 | }
208 | }
209 | }
210 | if handled {
211 | reply.Truncate(truncateSize)
212 | return &reply
213 | }
214 | return h.handleDefault(req)
215 | }
216 |
217 | func (h *Handler) handleDefault(req *dns.Msg) *dns.Msg {
218 | for _, client := range h.clients {
219 | for _, srv := range h.clientConfig.Servers {
220 | addr := fmt.Sprintf("%s:%s", srv, h.clientConfig.Port)
221 | reply, _, err := client.Exchange(req, addr)
222 | if err == nil {
223 | reply.Truncate(truncateSize)
224 | return reply
225 | }
226 | }
227 | }
228 | var reply dns.Msg
229 | reply.SetReply(req)
230 | reply.Truncate(truncateSize)
231 | return &reply
232 | }
233 |
234 | //HandleDNSRequest Handles the DNS request from guest and returns response
235 | func (h *Handler) HandleDNSRequest(req []byte) *dns.Msg {
236 | var (
237 | original dns.Msg
238 | )
239 | _ = original.Unpack(req)
240 | switch original.Opcode {
241 | case dns.OpcodeQuery:
242 | return h.handleQuery(&original)
243 | default:
244 | return h.handleDefault(&original)
245 | }
246 | }
247 |
248 | //UpdateDefaults Updates the predefined list of cname and ip
249 | func (h *Handler) UpdateDefaults(hosts map[string]string) {
250 | for host, address := range hosts {
251 | if ip := net.ParseIP(address); ip != nil {
252 | h.ip[host] = ip
253 | } else {
254 | h.cname[host] = yaml.Cname(address)
255 | }
256 | }
257 | }
258 |
--------------------------------------------------------------------------------
/pkg/hostagent/events/events.go:
--------------------------------------------------------------------------------
1 | package events
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | type Status struct {
8 | Running bool `json:"running,omitempty"`
9 | // When Degraded is true, Running must be true as well
10 | Degraded bool `json:"degraded,omitempty"`
11 | // When Exiting is true, Running must be false
12 | Exiting bool `json:"exiting,omitempty"`
13 |
14 | Errors []string `json:"errors,omitempty"`
15 | }
16 |
17 | type Event struct {
18 | Time time.Time `json:"time,omitempty"`
19 | Status Status `json:"status,omitempty"`
20 | }
21 |
--------------------------------------------------------------------------------
/pkg/hostagent/events/watcher.go:
--------------------------------------------------------------------------------
1 | package events
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "time"
7 |
8 | "github.com/mac-vz/macvz/pkg/logrusutil"
9 | "github.com/nxadm/tail"
10 | "github.com/sirupsen/logrus"
11 | )
12 |
13 | func Watch(ctx context.Context, haStdoutPath, haStderrPath string, begin time.Time, onEvent func(Event) bool) error {
14 | haStdoutTail, err := tail.TailFile(haStdoutPath,
15 | tail.Config{
16 | Follow: true,
17 | MustExist: true,
18 | })
19 | if err != nil {
20 | return err
21 | }
22 | defer func() {
23 | _ = haStdoutTail.Stop()
24 | haStdoutTail.Cleanup()
25 | }()
26 |
27 | haStderrTail, err := tail.TailFile(haStderrPath,
28 | tail.Config{
29 | Follow: true,
30 | MustExist: true,
31 | })
32 | if err != nil {
33 | return err
34 | }
35 | defer func() {
36 | _ = haStderrTail.Stop()
37 | haStderrTail.Cleanup()
38 | }()
39 |
40 | loop:
41 | for {
42 | select {
43 | case <-ctx.Done():
44 | break loop
45 | case line := <-haStdoutTail.Lines:
46 | if line.Err != nil {
47 | logrus.Error(line.Err)
48 | }
49 | if line.Text == "" {
50 | continue
51 | }
52 | var ev Event
53 | if err := json.Unmarshal([]byte(line.Text), &ev); err != nil {
54 | return err
55 | }
56 | logrus.WithField("event", ev).Debugf("received an event")
57 | if stop := onEvent(ev); stop {
58 | return nil
59 | }
60 | case line := <-haStderrTail.Lines:
61 | if line.Err != nil {
62 | logrus.Error(line.Err)
63 | }
64 | logrusutil.PropagateJSON(logrus.StandardLogger(), []byte(line.Text), "[hostagent] ", begin)
65 | }
66 | }
67 |
68 | return nil
69 | }
70 |
--------------------------------------------------------------------------------
/pkg/hostagent/hostagent.go:
--------------------------------------------------------------------------------
1 | package hostagent
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "github.com/hashicorp/yamux"
8 | "github.com/lima-vm/sshocker/pkg/ssh"
9 | "github.com/mac-vz/macvz/pkg/cidata"
10 | "github.com/mac-vz/macvz/pkg/hostagent/dns"
11 | "github.com/mac-vz/macvz/pkg/hostagent/events"
12 | "github.com/mac-vz/macvz/pkg/socket"
13 | "github.com/mac-vz/macvz/pkg/types"
14 | "github.com/mac-vz/macvz/pkg/vzrun"
15 | "github.com/mac-vz/macvz/pkg/yaml"
16 | "net"
17 | "os"
18 | "os/exec"
19 | "path/filepath"
20 | "strings"
21 | "sync"
22 | "time"
23 |
24 | "github.com/hashicorp/go-multierror"
25 | guestagentapi "github.com/mac-vz/macvz/pkg/guestagent/api"
26 | "github.com/mac-vz/macvz/pkg/sshutil"
27 | "github.com/mac-vz/macvz/pkg/store"
28 | "github.com/sirupsen/logrus"
29 | )
30 |
31 | type HostAgent struct {
32 | y *yaml.MacVZYaml
33 | instDir string
34 | instName string
35 | sshConfig *ssh.SSHConfig
36 | portForwarder *portForwarder
37 |
38 | udpDNSLocalPort int
39 | tcpDNSLocalPort int
40 | dnsHandler *dns.Handler
41 |
42 | onClose []func() error // LIFO
43 |
44 | sigintCh chan os.Signal
45 |
46 | sshRemote string
47 | eventEnc *json.Encoder
48 | eventEncMu sync.Mutex
49 | }
50 |
51 | // New creates the HostAgent.
52 | //
53 | // stdout is for emitting JSON lines of Events.
54 | func New(instName string, sigintCh chan os.Signal) (*HostAgent, error) {
55 | inst, err := store.Inspect(instName)
56 | if err != nil {
57 | return nil, err
58 | }
59 |
60 | y, err := inst.LoadYAML()
61 | if err != nil {
62 | return nil, err
63 | }
64 | // y is loaded with FillDefault() already, so no need to care about nil pointers.
65 |
66 | sshOpts, err := sshutil.SSHOpts(inst.Dir, *y.SSH.LoadDotSSHPubKeys, *y.SSH.ForwardAgent)
67 | if err != nil {
68 | return nil, err
69 | }
70 | sshConfig := &ssh.SSHConfig{
71 | AdditionalArgs: sshutil.SSHArgsFromOpts(sshOpts),
72 | }
73 |
74 | if err := cidata.GenerateISO9660(inst.Dir, instName, y); err != nil {
75 | return nil, err
76 | }
77 |
78 | rules := make([]yaml.PortForward, 0, 2+len(y.PortForwards))
79 | // Block ports 22 and sshLocalPort on all IPs
80 | for _, port := range []int{22} {
81 | rule := yaml.PortForward{GuestIP: net.IPv4zero, GuestPort: port, Ignore: true}
82 | yaml.FillPortForwardDefaults(&rule, inst.Dir)
83 | rules = append(rules, rule)
84 | }
85 | rules = append(rules, y.PortForwards...)
86 | // Default forwards for all non-privileged ports from "127.0.0.1" and "::1"
87 | rule := yaml.PortForward{GuestIP: guestagentapi.IPv4loopback1}
88 | yaml.FillPortForwardDefaults(&rule, inst.Dir)
89 | rules = append(rules, rule)
90 |
91 | var dnsHandler *dns.Handler
92 | if *y.HostResolver.Enabled {
93 | dnsHandler, err = dns.CreateHandler(*y.HostResolver.IPv6)
94 | if err != nil {
95 | logrus.Error("cannot start DNS server: %w", err)
96 | }
97 | }
98 |
99 | a := &HostAgent{
100 | y: y,
101 | instDir: inst.Dir,
102 | instName: instName,
103 | sshConfig: sshConfig,
104 | sigintCh: sigintCh,
105 | eventEnc: json.NewEncoder(os.Stdout),
106 | portForwarder: newPortForwarder(sshConfig, rules),
107 | dnsHandler: dnsHandler,
108 | }
109 |
110 | return a, nil
111 | }
112 |
113 | func (a *HostAgent) Run(ctx context.Context) error {
114 | defer func() {
115 | exitingEv := events.Event{
116 | Status: events.Status{
117 | Exiting: true,
118 | },
119 | }
120 | a.emitEvent(ctx, exitingEv)
121 | }()
122 |
123 | stBooting := events.Status{}
124 | a.emitEvent(ctx, events.Event{Status: stBooting})
125 |
126 | ctxHA, cancelHA := context.WithCancel(ctx)
127 | go func() {
128 | stRunning := events.Status{}
129 | if haErr := a.startHostAgentRoutines(ctxHA); haErr != nil {
130 | stRunning.Degraded = true
131 | stRunning.Errors = append(stRunning.Errors, haErr.Error())
132 | }
133 | stRunning.Running = true
134 | a.emitEvent(ctx, events.Event{Status: stRunning})
135 | }()
136 |
137 | handlers := make(map[types.Kind]func(ctx2 context.Context, stream *yamux.Stream, event interface{}))
138 | handlers[types.InfoMessage] = a.infoEventHandler
139 | handlers[types.PortMessage] = a.portEventHandler
140 | handlers[types.DNSMessage] = a.dnsEventHandler
141 |
142 | //Init vm
143 | vm, err := vzrun.InitializeVM(a.instName, handlers, a.sigintCh)
144 | if err != nil {
145 | logrus.Fatal("INIT", err)
146 | }
147 | err = vm.Run()
148 | if err != nil {
149 | logrus.Fatal("RUN", err)
150 | }
151 | cancelHA()
152 | return nil
153 | }
154 |
155 | func (a *HostAgent) infoEventHandler(ctx context.Context, stream *yamux.Stream, event interface{}) {
156 | infoEvent := event.(types.InfoEvent)
157 | hosts := a.y.HostResolver.Hosts
158 | hosts["host.macvz.internal."] = infoEvent.GatewayIP
159 | hosts[fmt.Sprintf("macvz-%s.", a.instName)] = infoEvent.GatewayIP
160 | a.dnsHandler.UpdateDefaults(hosts)
161 | }
162 |
163 | func (a *HostAgent) portEventHandler(ctx context.Context, stream *yamux.Stream, event interface{}) {
164 | portEvent := event.(types.PortEvent)
165 | logrus.Debugf("guest agent event: %+v", portEvent)
166 | for _, f := range portEvent.Errors {
167 | logrus.Warnf("received error from the guest: %q", f)
168 | }
169 | sshRemoteUser := sshutil.SSHRemoteUser(*a.y.MACAddress)
170 | a.portForwarder.OnEvent(ctx, sshRemoteUser, portEvent)
171 | }
172 |
173 | func (a *HostAgent) dnsEventHandler(ctx context.Context, stream *yamux.Stream, event interface{}) {
174 | dnsEvent := event.(types.DNSEvent)
175 | if a.dnsHandler != nil {
176 | res := a.dnsHandler.HandleDNSRequest(dnsEvent.Msg)
177 | pack, _ := res.Pack()
178 | encoder, _ := socket.GetStreamIO(stream)
179 | resEvent := types.DNSEventResponse{
180 | Msg: pack,
181 | }
182 | resEvent.Kind = types.DNSResponseMessage
183 | _ = encoder.Encode(&resEvent)
184 | }
185 | }
186 |
187 | func (a *HostAgent) setSSHRemote(remote string) {
188 | a.sshRemote = remote
189 | }
190 |
191 | func (a *HostAgent) startHostAgentRoutines(ctx context.Context) error {
192 | a.onClose = append(a.onClose, func() error {
193 | logrus.Debugf("shutting down the SSH master")
194 | if exitMasterErr := ssh.ExitMaster(a.sshRemote, 22, a.sshConfig); exitMasterErr != nil {
195 | logrus.WithError(exitMasterErr).Warn("failed to exit SSH master")
196 | }
197 | return nil
198 | })
199 |
200 | var mErr error
201 | if err := a.waitForRequirements(ctx, "host", a.hostRequirements()); err != nil {
202 | mErr = multierror.Append(mErr, err)
203 | }
204 | sshRemoteUser := sshutil.SSHRemoteUser(*a.y.MACAddress)
205 | a.setSSHRemote(sshRemoteUser)
206 |
207 | if err := a.waitForRequirements(ctx, "essential", a.essentialRequirements()); err != nil {
208 | mErr = multierror.Append(mErr, err)
209 | }
210 | if err := a.waitForRequirements(ctx, "optional", a.optionalRequirements()); err != nil {
211 | mErr = multierror.Append(mErr, err)
212 | }
213 | go a.ForwardDefinedSockets(ctx)
214 | if err := a.waitForRequirements(ctx, "final", a.finalRequirements()); err != nil {
215 | mErr = multierror.Append(mErr, err)
216 | }
217 | return mErr
218 | }
219 |
220 | func (a *HostAgent) ForwardDefinedSockets(ctx context.Context) {
221 | // Setup all socket forwards and defer their teardown
222 | logrus.Debugf("Forwarding unix sockets")
223 | for _, rule := range a.y.PortForwards {
224 | if rule.GuestSocket != "" {
225 | local := hostAddress(rule, types.IPPort{})
226 | _ = forwardSSH(ctx, a.sshConfig, a.sshRemote, local, rule.GuestSocket, verbForward)
227 | }
228 | }
229 |
230 | a.onClose = append(a.onClose, func() error {
231 | logrus.Debugf("Stop forwarding unix sockets")
232 | var mErr error
233 | for _, rule := range a.y.PortForwards {
234 | if rule.GuestSocket != "" {
235 | local := hostAddress(rule, types.IPPort{})
236 | // using ctx.Background() because ctx has already been cancelled
237 | if err := forwardSSH(context.Background(), a.sshConfig, a.sshRemote, local, rule.GuestSocket, verbCancel); err != nil {
238 | mErr = multierror.Append(mErr, err)
239 | }
240 | }
241 | }
242 | return mErr
243 | })
244 |
245 | for {
246 | // _, _, err := ssh.ExecuteScript(a.sshRemote, 22, a.sshConfig, `#!/bin/bash
247 | //true`, "Ping to keep SSH Master alive")
248 | // if err != nil {
249 | // logrus.Error("SSH Ping to guest failed", err)
250 | // }
251 | select {
252 | case <-ctx.Done():
253 | return
254 | case <-time.After(10 * time.Second):
255 | }
256 | }
257 | }
258 |
259 | func (a *HostAgent) emitEvent(ctx context.Context, ev events.Event) {
260 | a.eventEncMu.Lock()
261 | defer a.eventEncMu.Unlock()
262 | if ev.Time.IsZero() {
263 | ev.Time = time.Now()
264 | }
265 | if err := a.eventEnc.Encode(ev); err != nil {
266 | logrus.Println("Emit")
267 | logrus.WithField("event", ev).WithError(err).Error("failed to emit an event")
268 | }
269 | }
270 |
271 | const (
272 | verbForward = "forward"
273 | verbCancel = "cancel"
274 | )
275 |
276 | func forwardSSH(ctx context.Context, sshConfig *ssh.SSHConfig, userAndIp string, local, remote string, verb string) error {
277 | args := sshConfig.Args()
278 | args = append(args,
279 | "-T",
280 | "-O", verb,
281 | "-L", local+":"+remote,
282 | "-N",
283 | "-f",
284 | userAndIp,
285 | "--",
286 | )
287 | if strings.HasPrefix(local, "/") {
288 | switch verb {
289 | case verbForward:
290 | logrus.Infof("Forwarding %q (guest) to %q (host)", remote, local)
291 | if err := os.RemoveAll(local); err != nil {
292 | logrus.WithError(err).Warnf("Failed to clean up %q (host) before setting up forwarding", local)
293 | }
294 | if err := os.MkdirAll(filepath.Dir(local), 0750); err != nil {
295 | return fmt.Errorf("can't create directory for local socket %q: %w", local, err)
296 | }
297 | case verbCancel:
298 | logrus.Infof("Stopping forwarding %q (guest) to %q (host)", remote, local)
299 | defer func() {
300 | if err := os.RemoveAll(local); err != nil {
301 | logrus.WithError(err).Warnf("Failed to clean up %q (host) after stopping forwarding", local)
302 | }
303 | }()
304 | default:
305 | panic(fmt.Errorf("invalid verb %q", verb))
306 | }
307 | }
308 | cmd := exec.CommandContext(ctx, sshConfig.Binary(), args...)
309 | if out, err := cmd.Output(); err != nil {
310 | if verb == verbForward && strings.HasPrefix(local, "/") {
311 | logrus.WithError(err).Warnf("Failed to set up forward from %q (guest) to %q (host)", remote, local)
312 | if removeErr := os.RemoveAll(local); err != nil {
313 | logrus.WithError(removeErr).Warnf("Failed to clean up %q (host) after forwarding failed", local)
314 | }
315 | }
316 | return fmt.Errorf("failed to run %v: %q: %w", cmd.Args, string(out), err)
317 | }
318 | return nil
319 | }
320 |
--------------------------------------------------------------------------------
/pkg/hostagent/port.go:
--------------------------------------------------------------------------------
1 | package hostagent
2 |
3 | import (
4 | "context"
5 | "github.com/mac-vz/macvz/pkg/types"
6 | "github.com/mac-vz/macvz/pkg/yaml"
7 | "net"
8 |
9 | "github.com/lima-vm/sshocker/pkg/ssh"
10 | "github.com/mac-vz/macvz/pkg/guestagent/api"
11 | "github.com/sirupsen/logrus"
12 | )
13 |
14 | type portForwarder struct {
15 | sshConfig *ssh.SSHConfig
16 | rules []yaml.PortForward
17 | }
18 |
19 | func newPortForwarder(sshConfig *ssh.SSHConfig, rules []yaml.PortForward) *portForwarder {
20 | return &portForwarder{
21 | sshConfig: sshConfig,
22 | rules: rules,
23 | }
24 | }
25 |
26 | func hostAddress(rule yaml.PortForward, guest types.IPPort) string {
27 | if rule.HostSocket != "" {
28 | return rule.HostSocket
29 | }
30 | host := types.IPPort{IP: rule.HostIP}
31 | if guest.Port == 0 {
32 | // guest is a socket
33 | host.Port = rule.HostPort
34 | } else {
35 | host.Port = guest.Port + rule.HostPortRange[0] - rule.GuestPortRange[0]
36 | }
37 | return host.String()
38 | }
39 |
40 | func (pf *portForwarder) forwardingAddresses(guest types.IPPort) (string, string) {
41 | for _, rule := range pf.rules {
42 | if rule.GuestSocket != "" {
43 | continue
44 | }
45 | if guest.Port < rule.GuestPortRange[0] || guest.Port > rule.GuestPortRange[1] {
46 | continue
47 | }
48 | switch {
49 | case guest.IP.IsUnspecified():
50 | case guest.IP.Equal(rule.GuestIP):
51 | case guest.IP.Equal(net.IPv6loopback) && rule.GuestIP.Equal(api.IPv4loopback1):
52 | case rule.GuestIP.IsUnspecified() && !rule.GuestIPMustBeZero:
53 | // When GuestIPMustBeZero is true, then 0.0.0.0 must be an exact match, which is already
54 | // handled above by the guest.IP.IsUnspecified() condition.
55 | default:
56 | continue
57 | }
58 | if rule.Ignore {
59 | if guest.IP.IsUnspecified() && !rule.GuestIP.IsUnspecified() {
60 | continue
61 | }
62 | break
63 | }
64 | return hostAddress(rule, guest), guest.String()
65 | }
66 | return "", guest.String()
67 | }
68 |
69 | func (pf *portForwarder) OnEvent(ctx context.Context, sshRemote string, ev types.PortEvent) {
70 | for _, f := range ev.LocalPortsRemoved {
71 | local, remote := pf.forwardingAddresses(f)
72 | if local == "" {
73 | continue
74 | }
75 | logrus.Infof("Stopping forwarding TCP from %s to %s", remote, local)
76 | if err := forwardTCP(ctx, pf.sshConfig, sshRemote, local, remote, verbCancel); err != nil {
77 | logrus.WithError(err).Warnf("failed to stop forwarding tcp port %d", f.Port)
78 | }
79 | }
80 | for _, f := range ev.LocalPortsAdded {
81 | local, remote := pf.forwardingAddresses(f)
82 | if local == "" {
83 | logrus.Infof("Not forwarding TCP %s", remote)
84 | continue
85 | }
86 | logrus.Infof("Forwarding TCP from %s to %s", remote, local)
87 | if err := forwardTCP(ctx, pf.sshConfig, sshRemote, local, remote, verbForward); err != nil {
88 | logrus.WithError(err).Warnf("failed to set up forwarding tcp port %d (negligible if already forwarded)", f.Port)
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/pkg/hostagent/port_darwin.go:
--------------------------------------------------------------------------------
1 | package hostagent
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net"
7 | "os"
8 | "path/filepath"
9 | "strconv"
10 | "strings"
11 |
12 | "github.com/lima-vm/sshocker/pkg/ssh"
13 | "github.com/mac-vz/macvz/pkg/guestagent/api"
14 | "github.com/norouter/norouter/pkg/agent/bicopy"
15 | "github.com/sirupsen/logrus"
16 | )
17 |
18 | // forwardTCP is not thread-safe
19 | func forwardTCP(ctx context.Context, sshConfig *ssh.SSHConfig, sshRemote string, local, remote string, verb string) error {
20 | if strings.HasPrefix(local, "/") {
21 | return forwardSSH(ctx, sshConfig, sshRemote, local, remote, verb)
22 | }
23 | localIPStr, localPortStr, err := net.SplitHostPort(local)
24 | if err != nil {
25 | return err
26 | }
27 | localIP := net.ParseIP(localIPStr)
28 | localPort, err := strconv.Atoi(localPortStr)
29 | if err != nil {
30 | return err
31 | }
32 |
33 | if !localIP.Equal(api.IPv4loopback1) || localPort >= 1024 {
34 | return forwardSSH(ctx, sshConfig, sshRemote, local, remote, verb)
35 | }
36 |
37 | // on macOS, listening on 127.0.0.1:80 requires root while 0.0.0.0:80 does not require root.
38 | // https://twitter.com/_AkihiroSuda_/status/1403403845842075648
39 | //
40 | // We use "pseudoloopback" forwarder that listens on 0.0.0.0:80 but rejects connections from non-loopback src IP.
41 | logrus.Debugf("using pseudoloopback sshRemote forwarder for %q", local)
42 |
43 | if verb == verbCancel {
44 | plf, ok := pseudoLoopbackForwarders[local]
45 | if ok {
46 | localUnix := plf.unixAddr.Name
47 | _ = plf.Close()
48 | delete(pseudoLoopbackForwarders, local)
49 | if err := forwardSSH(ctx, sshConfig, sshRemote, localUnix, remote, verb); err != nil {
50 | return err
51 | }
52 | } else {
53 | logrus.Warnf("forwarding for %q seems already cancelled?", local)
54 | }
55 | return nil
56 | }
57 |
58 | localUnixDir, err := os.MkdirTemp("/tmp", fmt.Sprintf("lima-psl-%s-%d-", localIP, localPort))
59 | if err != nil {
60 | return err
61 | }
62 | localUnix := filepath.Join(localUnixDir, "sock")
63 | logrus.Debugf("forwarding %q to %q", localUnix, remote)
64 | if err := forwardSSH(ctx, sshConfig, sshRemote, localUnix, remote, verb); err != nil {
65 | return err
66 | }
67 | plf, err := newPseudoLoopbackForwarder(localPort, localUnix)
68 | if err != nil {
69 | if cancelErr := forwardSSH(ctx, sshConfig, sshRemote, localUnix, remote, verbCancel); cancelErr != nil {
70 | logrus.WithError(cancelErr).Warnf("failed to cancel forwarding %q to %q", localUnix, remote)
71 | }
72 | return err
73 | }
74 | plf.onClose = func() error {
75 | return os.RemoveAll(localUnixDir)
76 | }
77 | pseudoLoopbackForwarders[local] = plf
78 | go func() {
79 | if plfErr := plf.Serve(); plfErr != nil {
80 | logrus.WithError(plfErr).Warning("pseudoloopback forwarder crashed")
81 | }
82 | }()
83 | return nil
84 | }
85 |
86 | var pseudoLoopbackForwarders = make(map[string]*pseudoLoopbackForwarder)
87 |
88 | type pseudoLoopbackForwarder struct {
89 | ln *net.TCPListener
90 | unixAddr *net.UnixAddr
91 | onClose func() error
92 | }
93 |
94 | func newPseudoLoopbackForwarder(localPort int, unixSock string) (*pseudoLoopbackForwarder, error) {
95 | unixAddr, err := net.ResolveUnixAddr("unix", unixSock)
96 | if err != nil {
97 | return nil, err
98 | }
99 |
100 | lnAddr, err := net.ResolveTCPAddr("tcp4", fmt.Sprintf("0.0.0.0:%d", localPort))
101 | if err != nil {
102 | return nil, err
103 | }
104 | ln, err := net.ListenTCP("tcp4", lnAddr)
105 | if err != nil {
106 | return nil, err
107 | }
108 |
109 | plf := &pseudoLoopbackForwarder{
110 | ln: ln,
111 | unixAddr: unixAddr,
112 | }
113 |
114 | return plf, nil
115 | }
116 |
117 | func (plf *pseudoLoopbackForwarder) Serve() error {
118 | defer plf.ln.Close()
119 | for {
120 | ac, err := plf.ln.AcceptTCP()
121 | if err != nil {
122 | return err
123 | }
124 | remoteAddr := ac.RemoteAddr().String() // ip:port
125 | remoteAddrIP, _, err := net.SplitHostPort(remoteAddr)
126 | if err != nil {
127 | logrus.WithError(err).Debugf("pseudoloopback forwarder: rejecting non-loopback remoteAddr %q (unparsable)", remoteAddr)
128 | ac.Close()
129 | continue
130 | }
131 | if remoteAddrIP != "127.0.0.1" {
132 | logrus.WithError(err).Debugf("pseudoloopback forwarder: rejecting non-loopback remoteAddr %q", remoteAddr)
133 | ac.Close()
134 | continue
135 | }
136 | go func(ac *net.TCPConn) {
137 | if fErr := plf.forward(ac); fErr != nil {
138 | logrus.Error(fErr)
139 | }
140 | }(ac)
141 | }
142 | }
143 |
144 | func (plf *pseudoLoopbackForwarder) forward(ac *net.TCPConn) error {
145 | defer ac.Close()
146 | unixConn, err := net.DialUnix("unix", nil, plf.unixAddr)
147 | if err != nil {
148 | return err
149 | }
150 | defer unixConn.Close()
151 | bicopy.Bicopy(ac, unixConn, nil)
152 | return nil
153 | }
154 |
155 | func (plf *pseudoLoopbackForwarder) Close() error {
156 | _ = plf.ln.Close()
157 | return plf.onClose()
158 | }
159 |
--------------------------------------------------------------------------------
/pkg/hostagent/port_others.go:
--------------------------------------------------------------------------------
1 | //go:build !darwin
2 | // +build !darwin
3 |
4 | package hostagent
5 |
6 | import (
7 | "context"
8 |
9 | "github.com/lima-vm/sshocker/pkg/ssh"
10 | )
11 |
12 | func forwardTCP(ctx context.Context, sshConfig *ssh.SSHConfig, userAndIp string, local, remote string, verb string) error {
13 | return forwardSSH(ctx, sshConfig, userAndIp, local, remote, verb)
14 | }
15 |
--------------------------------------------------------------------------------
/pkg/hostagent/requirements.go:
--------------------------------------------------------------------------------
1 | package hostagent
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "github.com/lima-vm/sshocker/pkg/ssh"
8 | "github.com/mac-vz/macvz/pkg/osutil"
9 | "os/exec"
10 | "strings"
11 | "time"
12 |
13 | "github.com/hashicorp/go-multierror"
14 | "github.com/mac-vz/macvz/pkg/yaml"
15 | "github.com/sirupsen/logrus"
16 | )
17 |
18 | func (a *HostAgent) waitForRequirements(ctx context.Context, label string, requirements []requirement) error {
19 | const (
20 | retries = 60
21 | sleepDuration = 10 * time.Second
22 | )
23 | var mErr error
24 |
25 | for i, req := range requirements {
26 | retryLoop:
27 | for j := 0; j < retries; j++ {
28 | logrus.Infof("Waiting for the %s requirement %d of %d: %q", label, i+1, len(requirements), req.description)
29 | err := a.waitForRequirement(ctx, req)
30 | if err == nil {
31 | logrus.Infof("The %s requirement %d of %d is satisfied", label, i+1, len(requirements))
32 | break retryLoop
33 | }
34 | if req.fatal {
35 | logrus.Infof("No further %s requirements will be checked", label)
36 | return multierror.Append(mErr, fmt.Errorf("failed to satisfy the %s requirement %d of %d %q: %s; skipping further checks: %w", label, i+1, len(requirements), req.description, req.debugHint, err))
37 | }
38 | if j == retries-1 {
39 | mErr = multierror.Append(mErr, fmt.Errorf("failed to satisfy the %s requirement %d of %d %q: %s: %w", label, i+1, len(requirements), req.description, req.debugHint, err))
40 | break retryLoop
41 | }
42 | time.Sleep(10 * time.Second)
43 | }
44 | }
45 | return mErr
46 | }
47 |
48 | func (a *HostAgent) waitForRequirement(ctx context.Context, r requirement) error {
49 | logrus.Debugf("executing script %q", r.description)
50 | if r.host {
51 | bashCmd := exec.Command("bash")
52 | bashCmd.Stdin = strings.NewReader(r.script)
53 | var stderr bytes.Buffer
54 | bashCmd.Stderr = &stderr
55 | stdout, err := bashCmd.Output()
56 | logrus.Debugf("stdout=%q, stderr=%q, err=%v", stdout, stderr, err)
57 | if err != nil {
58 | return fmt.Errorf("stdout=%q, stderr=%q: %w", stdout, stderr, err)
59 | }
60 | } else {
61 | stdout, stderr, err := ssh.ExecuteScript(a.sshRemote, 22, a.sshConfig, r.script, r.description)
62 | logrus.Debugf("stdout=%q, stderr=%q, err=%v", stdout, stderr, err)
63 | if err != nil {
64 | return fmt.Errorf("stdout=%q, stderr=%q: %w", stdout, stderr, err)
65 | }
66 | }
67 | return nil
68 | }
69 |
70 | type requirement struct {
71 | description string
72 | script string
73 | debugHint string
74 | fatal bool
75 | host bool
76 | }
77 |
78 | func (a *HostAgent) hostRequirements() []requirement {
79 | req := make([]requirement, 0)
80 | req = append(req,
81 | requirement{
82 | description: "Host IP Bind",
83 | script: fmt.Sprintf(`#!/bin/bash
84 | if [[ $( arp -a | grep -w -i '%s' | awk '{print $2}') ]]; then
85 | exit 0
86 | else
87 | exit 1
88 | fi
89 | `, osutil.TrimMACAddress(*a.y.MACAddress)),
90 | debugHint: `Failed to acquire host IP.
91 | `,
92 | host: true,
93 | })
94 | return req
95 | }
96 |
97 | func (a *HostAgent) essentialRequirements() []requirement {
98 | req := make([]requirement, 0)
99 | req = append(req,
100 | requirement{
101 | description: "ssh",
102 | script: `#!/bin/bash
103 | true
104 | `,
105 | debugHint: `Failed to SSH into the guest.
106 | If any private key under ~/.ssh is protected with a passphrase, you need to have ssh-agent to be running.
107 | `,
108 | })
109 | req = append(req, requirement{
110 | description: "user session is ready for ssh",
111 | script: `#!/bin/bash
112 | set -eux -o pipefail
113 | if ! timeout 30s bash -c "until sudo diff -q /run/macvz-ssh-ready /mnt/cidata/meta-data 2>/dev/null; do sleep 3; done"; then
114 | echo >&2 "not ready to start persistent ssh session"
115 | exit 1
116 | fi
117 | `,
118 | debugHint: `The boot sequence will terminate any existing user session after updating
119 | /etc/environment to make sure the session includes the new values.
120 | Terminating the session will break the persistent SSH tunnel, so
121 | it must not be created until the session reset is done.
122 | `,
123 | })
124 | // req = append(req, requirement{
125 | // description: "the guest agent to be running",
126 | // script: `#!/bin/bash
127 | //set -eux -o pipefail
128 | //sock="/run/lima-guestagent.sock"
129 | //if ! timeout 30s bash -c "until [ -S \"${sock}\" ]; do sleep 3; done"; then
130 | // echo >&2 "lima-guestagent is not installed yet"
131 | // exit 1
132 | //fi
133 | //`,
134 | // debugHint: `The guest agent (/run/lima-guestagent.sock) does not seem running.
135 | //Make sure that you are using an officially supported image.
136 | //Also see "/var/log/cloud-init-output.log" in the guest.
137 | //A possible workaround is to run "lima-guestagent install-systemd" in the guest.
138 | //`,
139 | // })
140 | return req
141 | }
142 |
143 | func (a *HostAgent) optionalRequirements() []requirement {
144 | req := make([]requirement, 0)
145 | for _, probe := range a.y.Probes {
146 | if probe.Mode == yaml.ProbeModeReadiness {
147 | req = append(req, requirement{
148 | description: probe.Description,
149 | script: probe.Script,
150 | debugHint: probe.Hint,
151 | })
152 | }
153 | }
154 | return req
155 | }
156 |
157 | func (a *HostAgent) finalRequirements() []requirement {
158 | req := make([]requirement, 0)
159 | req = append(req,
160 | requirement{
161 | description: "boot scripts must have finished",
162 | script: `#!/bin/bash
163 | set -eux -o pipefail
164 | if ! timeout 30s bash -c "until sudo diff -q /run/macvz-boot-done /mnt/cidata/meta-data 2>/dev/null; do sleep 3; done"; then
165 | echo >&2 "boot scripts have not finished"
166 | exit 1
167 | fi
168 | `,
169 | debugHint: `All boot scripts, provisioning scripts, and readiness probes must
170 | finish before the instance is considered "ready".
171 | Check "/var/log/cloud-init-output.log" in the guest to see where the process is blocked!
172 | `,
173 | })
174 | return req
175 | }
176 |
--------------------------------------------------------------------------------
/pkg/iso9660util/iso9660util.go:
--------------------------------------------------------------------------------
1 | package iso9660util
2 |
3 | import (
4 | "archive/tar"
5 | "compress/gzip"
6 | "github.com/diskfs/go-diskfs/filesystem"
7 | "github.com/diskfs/go-diskfs/filesystem/iso9660"
8 | "io"
9 | "os"
10 | "path"
11 | )
12 |
13 | type Entry struct {
14 | Path string
15 | Reader io.Reader
16 | }
17 |
18 | func Write(isoPath, label string, layout []Entry) error {
19 | if err := os.RemoveAll(isoPath); err != nil {
20 | return err
21 | }
22 |
23 | isoFile, err := os.Create(isoPath)
24 | if err != nil {
25 | return err
26 | }
27 |
28 | defer isoFile.Close()
29 |
30 | fs, err := iso9660.Create(isoFile, 0, 0, 0, "")
31 | if err != nil {
32 | return err
33 | }
34 |
35 | for _, f := range layout {
36 | if _, err := WriteFile(fs, f.Path, f.Reader); err != nil {
37 | return err
38 | }
39 | }
40 |
41 | finalizeOptions := iso9660.FinalizeOptions{
42 | RockRidge: true,
43 | VolumeIdentifier: label,
44 | }
45 | if err := fs.Finalize(finalizeOptions); err != nil {
46 | return err
47 | }
48 |
49 | return isoFile.Close()
50 | }
51 |
52 | func WriteFile(fs filesystem.FileSystem, pathStr string, r io.Reader) (int64, error) {
53 | if dir := path.Dir(pathStr); dir != "" && dir != "/" {
54 | if err := fs.Mkdir(dir); err != nil {
55 | return 0, err
56 | }
57 | }
58 | f, err := fs.OpenFile(pathStr, os.O_CREATE|os.O_RDWR)
59 | if err != nil {
60 | return 0, err
61 | }
62 | defer f.Close()
63 | return io.Copy(f, r)
64 | }
65 |
66 | func IsISO9660(imagePath string) (bool, error) {
67 | imageFile, err := os.Open(imagePath)
68 | if err != nil {
69 | return false, err
70 | }
71 | defer imageFile.Close()
72 |
73 | fileInfo, err := imageFile.Stat()
74 | if err != nil {
75 | return false, err
76 | }
77 | _, err = iso9660.Read(imageFile, fileInfo.Size(), 0, 0)
78 |
79 | return err == nil, nil
80 | }
81 |
82 | func Extract(tarPath string, fileInTar string, outputPath string) error {
83 | tarFile, err := os.Open(tarPath)
84 | if err != nil {
85 | return err
86 | }
87 | defer tarFile.Close()
88 |
89 | gzr, err := gzip.NewReader(tarFile)
90 | if err != nil {
91 | return err
92 | }
93 | defer gzr.Close()
94 |
95 | tr := tar.NewReader(gzr)
96 |
97 | for {
98 | header, err := tr.Next()
99 |
100 | switch {
101 |
102 | // if no more files are found return
103 | case err == io.EOF:
104 | return nil
105 |
106 | // return any other error
107 | case err != nil:
108 | return err
109 |
110 | // if the header is nil, just skip it (not sure how this happens)
111 | case header == nil:
112 | continue
113 | }
114 |
115 | // the target location where the dir/file should be created
116 | if header.Typeflag == tar.TypeReg && header.Name == fileInTar {
117 | f, err := os.OpenFile(outputPath, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
118 | if err != nil {
119 | return err
120 | }
121 |
122 | // copy over contents
123 | if _, err := io.Copy(f, tr); err != nil {
124 | return err
125 | }
126 |
127 | // manually close here after each file operation; defering would cause each file close
128 | // to wait until all operations have completed.
129 | f.Close()
130 | }
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/pkg/lockutil/lockutil.go:
--------------------------------------------------------------------------------
1 | // From https://github.com/containerd/nerdctl/blob/v0.9.0/pkg/lockutil/lockutil_linux.go
2 | /*
3 | Copyright The containerd Authors.
4 |
5 | Licensed under the Apache License, Version 2.0 (the "License");
6 | you may not use this file except in compliance with the License.
7 | You may obtain a copy of the License at
8 |
9 | http://www.apache.org/licenses/LICENSE-2.0
10 |
11 | Unless required by applicable law or agreed to in writing, software
12 | distributed under the License is distributed on an "AS IS" BASIS,
13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | See the License for the specific language governing permissions and
15 | limitations under the License.
16 | */
17 |
18 | package lockutil
19 |
20 | import (
21 | "fmt"
22 | "os"
23 |
24 | "github.com/sirupsen/logrus"
25 | "golang.org/x/sys/unix"
26 | )
27 |
28 | func WithDirLock(dir string, fn func() error) error {
29 | dirFile, err := os.Open(dir)
30 | if err != nil {
31 | return err
32 | }
33 | defer dirFile.Close()
34 | if err := Flock(dirFile, unix.LOCK_EX); err != nil {
35 | return fmt.Errorf("failed to lock %q: %w", dir, err)
36 | }
37 | defer func() {
38 | if err := Flock(dirFile, unix.LOCK_UN); err != nil {
39 | logrus.WithError(err).Errorf("failed to unlock %q", dir)
40 | }
41 | }()
42 | return fn()
43 | }
44 |
45 | func Flock(f *os.File, flags int) error {
46 | fd := int(f.Fd())
47 | for {
48 | err := unix.Flock(fd, flags)
49 | if err == nil || err != unix.EINTR {
50 | return err
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/pkg/logrusutil/logrusutil.go:
--------------------------------------------------------------------------------
1 | package logrusutil
2 |
3 | import (
4 | "encoding/json"
5 | "strings"
6 | "time"
7 |
8 | "github.com/sirupsen/logrus"
9 | )
10 |
11 | const epsilon = 1 * time.Second
12 |
13 | // PropagateJSON propagates JSONFormatter lines.
14 | //
15 | // PanicLevel and FatalLevel are converted to ErrorLevel.
16 | func PropagateJSON(logger *logrus.Logger, jsonLine []byte, header string, begin time.Time) {
17 | if strings.TrimSpace(string(jsonLine)) == "" {
18 | return
19 | }
20 |
21 | var (
22 | lv logrus.Level
23 | j JSON
24 | err error
25 | )
26 | if err := json.Unmarshal(jsonLine, &j); err != nil {
27 | goto fallback
28 | }
29 | if !j.Time.IsZero() && !begin.IsZero() && begin.After(j.Time.Add(epsilon)) {
30 | return
31 | }
32 | lv, err = logrus.ParseLevel(j.Level)
33 | if err != nil {
34 | goto fallback
35 | }
36 | switch lv {
37 | case logrus.PanicLevel, logrus.FatalLevel:
38 | logger.WithField("level", lv).Error(header + j.Msg)
39 | case logrus.ErrorLevel:
40 | logger.Error(header + j.Msg)
41 | case logrus.WarnLevel:
42 | logger.Warn(header + j.Msg)
43 | case logrus.InfoLevel:
44 | logger.Info(header + j.Msg)
45 | case logrus.DebugLevel:
46 | logger.Debug(header + j.Msg)
47 | case logrus.TraceLevel:
48 | logger.Trace(header + j.Msg)
49 | }
50 | return
51 |
52 | fallback:
53 | logrus.Info(header + string(jsonLine))
54 | }
55 |
56 | // JSON is the type used in logrus.JSONFormatter
57 | type JSON struct {
58 | Level string `json:"level"`
59 | Msg string `json:"msg"`
60 | Time time.Time `json:"time"`
61 | }
62 |
--------------------------------------------------------------------------------
/pkg/osutil/ip.go:
--------------------------------------------------------------------------------
1 | package osutil
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "io"
7 | "os"
8 | "regexp"
9 | "strings"
10 | )
11 |
12 | const (
13 | // LeasesPath is the path to dhcpd leases
14 | LeasesPath = "/var/db/dhcpd_leases"
15 | )
16 |
17 | var (
18 | leadingZeroRegexp = regexp.MustCompile(`0([A-Fa-f0-9](:|$))`)
19 | )
20 |
21 | // DHCPEntry holds a parsed DNS entry
22 | type DHCPEntry struct {
23 | Name string
24 | IPAddress string
25 | HWAddress string
26 | ID string
27 | Lease string
28 | }
29 |
30 | // GetIPAddressByMACAddress gets the IP address of a MAC address
31 | func GetIPFromMac(mac string) (string, error) {
32 | mac = TrimMACAddress(mac)
33 | return getIPAddressFromFile(mac, LeasesPath)
34 | }
35 |
36 | func getIPAddressFromFile(mac, path string) (string, error) {
37 | file, err := os.Open(path)
38 | if err != nil {
39 | return "", err
40 | }
41 | defer file.Close()
42 |
43 | dhcpEntries, err := parseDHCPdLeasesFile(file)
44 | if err != nil {
45 | return "", err
46 | }
47 |
48 | for _, dhcpEntry := range dhcpEntries {
49 | if dhcpEntry.HWAddress == mac {
50 | return dhcpEntry.IPAddress, nil
51 | }
52 | }
53 | return "", fmt.Errorf("could not find an IP address for %s", mac)
54 | }
55 |
56 | func parseDHCPdLeasesFile(file io.Reader) ([]DHCPEntry, error) {
57 | var (
58 | dhcpEntry *DHCPEntry
59 | dhcpEntries []DHCPEntry
60 | )
61 |
62 | scanner := bufio.NewScanner(file)
63 | for scanner.Scan() {
64 | line := strings.TrimSpace(scanner.Text())
65 | if line == "{" {
66 | dhcpEntry = new(DHCPEntry)
67 | continue
68 | } else if line == "}" {
69 | dhcpEntries = append(dhcpEntries, *dhcpEntry)
70 | continue
71 | }
72 |
73 | split := strings.SplitN(line, "=", 2)
74 | if len(split) != 2 {
75 | return nil, fmt.Errorf("invalid line in dhcp leases file: %s", line)
76 | }
77 | key, val := split[0], split[1]
78 | switch key {
79 | case "name":
80 | dhcpEntry.Name = val
81 | case "ip_address":
82 | dhcpEntry.IPAddress = val
83 | case "hw_address":
84 | // The mac addresses have a '1,' at the start.
85 | dhcpEntry.HWAddress = val[2:]
86 | case "identifier":
87 | dhcpEntry.ID = val
88 | case "lease":
89 | dhcpEntry.Lease = val
90 | default:
91 | return dhcpEntries, fmt.Errorf("unable to parse line: %s", line)
92 | }
93 | }
94 | return dhcpEntries, scanner.Err()
95 | }
96 |
97 | // TrimMacAddress trimming "0" of the ten's digit
98 | func TrimMACAddress(macAddress string) string {
99 | return leadingZeroRegexp.ReplaceAllString(macAddress, "$1")
100 | }
101 |
--------------------------------------------------------------------------------
/pkg/osutil/osutil_linux.go:
--------------------------------------------------------------------------------
1 | package osutil
2 |
3 | // UnixPathMax is the value of UNIX_PATH_MAX.
4 | const UnixPathMax = 108
5 |
--------------------------------------------------------------------------------
/pkg/osutil/osutil_others.go:
--------------------------------------------------------------------------------
1 | //go:build !linux
2 | // +build !linux
3 |
4 | package osutil
5 |
6 | // UnixPathMax is the value of UNIX_PATH_MAX.
7 | const UnixPathMax = 104
8 |
--------------------------------------------------------------------------------
/pkg/osutil/user.go:
--------------------------------------------------------------------------------
1 | package osutil
2 |
3 | import (
4 | "fmt"
5 | "os/user"
6 | "regexp"
7 | "strconv"
8 | "sync"
9 |
10 | "github.com/sirupsen/logrus"
11 | )
12 |
13 | type User struct {
14 | User string
15 | Uid uint32
16 | Group string
17 | Gid uint32
18 | }
19 |
20 | type Group struct {
21 | Name string
22 | Gid uint32
23 | }
24 |
25 | var users map[string]User
26 | var groups map[string]Group
27 |
28 | func LookupUser(name string) (User, error) {
29 | if users == nil {
30 | users = make(map[string]User)
31 | }
32 | if _, ok := users[name]; !ok {
33 | u, err := user.Lookup(name)
34 | if err != nil {
35 | return User{}, err
36 | }
37 | g, err := user.LookupGroupId(u.Gid)
38 | if err != nil {
39 | return User{}, err
40 | }
41 | uid, err := strconv.ParseUint(u.Uid, 10, 32)
42 | if err != nil {
43 | return User{}, err
44 | }
45 | gid, err := strconv.ParseUint(u.Gid, 10, 32)
46 | if err != nil {
47 | return User{}, err
48 | }
49 | users[name] = User{User: u.Username, Uid: uint32(uid), Group: g.Name, Gid: uint32(gid)}
50 | }
51 | return users[name], nil
52 | }
53 |
54 | func LookupGroup(name string) (Group, error) {
55 | if groups == nil {
56 | groups = make(map[string]Group)
57 | }
58 | if _, ok := groups[name]; !ok {
59 | g, err := user.LookupGroup(name)
60 | if err != nil {
61 | return Group{}, err
62 | }
63 | gid, err := strconv.ParseUint(g.Gid, 10, 32)
64 | if err != nil {
65 | return Group{}, err
66 | }
67 | groups[name] = Group{Name: g.Name, Gid: uint32(gid)}
68 | }
69 | return groups[name], nil
70 | }
71 |
72 | const (
73 | fallbackUser = "macvz"
74 | )
75 |
76 | var cache struct {
77 | sync.Once
78 | u *user.User
79 | err error
80 | warning string
81 | }
82 |
83 | func MacVZUser(warn bool) (*user.User, error) {
84 | cache.Do(func() {
85 | cache.u, cache.err = user.Current()
86 | if cache.err == nil {
87 | // `useradd` only allows user and group names matching the following pattern:
88 | // (it allows a trailing '$', but it feels prudent to map those to the fallback user as well)
89 | validName := "^[a-z_][a-z0-9_-]*$"
90 | if !regexp.MustCompile(validName).Match([]byte(cache.u.Username)) {
91 | cache.warning = fmt.Sprintf("local user %q is not a valid Linux username (must match %q); using %q username instead",
92 | cache.u.Username, validName, fallbackUser)
93 | cache.u.Username = fallbackUser
94 | }
95 | }
96 | })
97 | if warn && cache.warning != "" {
98 | logrus.Warn(cache.warning)
99 | }
100 | return cache.u, cache.err
101 | }
102 |
--------------------------------------------------------------------------------
/pkg/socket/io.go:
--------------------------------------------------------------------------------
1 | package socket
2 |
3 | import (
4 | "github.com/fxamacker/cbor/v2"
5 | "github.com/hashicorp/yamux"
6 | "github.com/mitchellh/mapstructure"
7 | "github.com/sirupsen/logrus"
8 | )
9 |
10 | //GetIO Open a yamux connection and returns encoder and decoder
11 | func GetIO(sess *yamux.Session) (*cbor.Encoder, *cbor.Decoder) {
12 | out, err := sess.Open()
13 | if err != nil {
14 | logrus.Error("error opening yamux session", err)
15 | return nil, nil
16 | }
17 | return cbor.NewEncoder(out), cbor.NewDecoder(out)
18 | }
19 |
20 | //GetStreamIO Creates encoder and decoder for yamux stream
21 | func GetStreamIO(c *yamux.Stream) (*cbor.Encoder, *cbor.Decoder) {
22 | return cbor.NewEncoder(c), cbor.NewDecoder(c)
23 | }
24 |
25 | //Write encodes the given obj
26 | func Write(enc *cbor.Encoder, obj interface{}) {
27 | err := enc.Encode(obj)
28 | if err != nil {
29 | logrus.Error("error encoding value", err)
30 | }
31 | }
32 |
33 | //Read decodes the value to obj
34 | func Read(dec *cbor.Decoder, obj interface{}) {
35 | err := dec.Decode(obj)
36 | if err != nil {
37 | logrus.Error("error encoding value", err)
38 | }
39 | }
40 |
41 | //ReadMap decodes src to dest interface
42 | func ReadMap(src map[string]interface{}, dest interface{}) {
43 | err := mapstructure.Decode(src, dest)
44 | if err != nil {
45 | logrus.Error("error encoding value", err)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/pkg/sshutil/sshutil.go:
--------------------------------------------------------------------------------
1 | package sshutil
2 |
3 | import (
4 | "bytes"
5 | "encoding/base64"
6 | "encoding/binary"
7 | "errors"
8 | "fmt"
9 | "github.com/mac-vz/macvz/pkg/osutil"
10 | "io/fs"
11 | "os"
12 | "os/exec"
13 | "path/filepath"
14 | "regexp"
15 | "strings"
16 | "sync"
17 |
18 | "github.com/coreos/go-semver/semver"
19 | "github.com/mac-vz/macvz/pkg/lockutil"
20 | "github.com/mac-vz/macvz/pkg/store/dirnames"
21 | "github.com/mac-vz/macvz/pkg/store/filenames"
22 | "github.com/sirupsen/logrus"
23 | )
24 |
25 | type PubKey struct {
26 | Filename string
27 | Content string
28 | }
29 |
30 | func readPublicKey(f string) (PubKey, error) {
31 | entry := PubKey{
32 | Filename: f,
33 | }
34 | content, err := os.ReadFile(f)
35 | if err == nil {
36 | entry.Content = strings.TrimSpace(string(content))
37 | } else {
38 | err = fmt.Errorf("failed to read ssh public key %q: %w", f, err)
39 | }
40 | return entry, err
41 | }
42 |
43 | // DefaultPubKeys returns the public key from $LIMA_HOME/_config/user.pub.
44 | // The key will be created if it does not yet exist.
45 | //
46 | // When loadDotSSH is true, ~/.ssh/*.pub will be appended to make the VM accessible without specifying
47 | // an identity explicitly.
48 | func DefaultPubKeys(loadDotSSH bool) ([]PubKey, error) {
49 | // Read $LIMA_HOME/_config/user.pub
50 | configDir, err := dirnames.MacVZConfigDir()
51 | if err != nil {
52 | return nil, err
53 | }
54 | _, err = os.Stat(filepath.Join(configDir, filenames.UserPrivateKey))
55 | if err != nil {
56 | if !errors.Is(err, os.ErrNotExist) {
57 | return nil, err
58 | }
59 | if err := os.MkdirAll(configDir, 0700); err != nil {
60 | return nil, fmt.Errorf("could not create %q directory: %w", configDir, err)
61 | }
62 | if err := lockutil.WithDirLock(configDir, func() error {
63 | keygenCmd := exec.Command("ssh-keygen", "-t", "ed25519", "-q", "-N", "", "-f",
64 | filepath.Join(configDir, filenames.UserPrivateKey))
65 | logrus.Debugf("executing %v", keygenCmd.Args)
66 | if out, err := keygenCmd.CombinedOutput(); err != nil {
67 | return fmt.Errorf("failed to run %v: %q: %w", keygenCmd.Args, string(out), err)
68 | }
69 | return nil
70 | }); err != nil {
71 | return nil, err
72 | }
73 | }
74 | entry, err := readPublicKey(filepath.Join(configDir, filenames.UserPublicKey))
75 | if err != nil {
76 | return nil, err
77 | }
78 | res := []PubKey{entry}
79 |
80 | if !loadDotSSH {
81 | return res, nil
82 | }
83 |
84 | // Append all of ~/.ssh/*.pub
85 | homeDir, err := os.UserHomeDir()
86 | if err != nil {
87 | return nil, err
88 | }
89 | files, err := filepath.Glob(filepath.Join(homeDir, ".ssh/*.pub"))
90 | if err != nil {
91 | panic(err) // Only possible error is ErrBadPattern, so this should be unreachable.
92 | }
93 | for _, f := range files {
94 | if !strings.HasSuffix(f, ".pub") {
95 | panic(fmt.Errorf("unexpected ssh public key filename %q", f))
96 | }
97 | entry, err := readPublicKey(f)
98 | if err == nil {
99 | if !detectValidPublicKey(entry.Content) {
100 | logrus.Warnf("public key %q doesn't seem to be in ssh format", entry.Filename)
101 | } else {
102 | res = append(res, entry)
103 | }
104 | } else if !errors.Is(err, os.ErrNotExist) {
105 | return nil, err
106 | }
107 | }
108 | return res, nil
109 | }
110 |
111 | var sshInfo struct {
112 | sync.Once
113 | // aesAccelerated is set to true when AES acceleration is available.
114 | // Available on almost all modern Intel/AMD processors.
115 | aesAccelerated bool
116 | // openSSHVersion is set to the version of OpenSSH, or semver.New("0.0.0") if the version cannot be determined.
117 | openSSHVersion semver.Version
118 | }
119 |
120 | func SSHRemoteUser(macaddr string) string {
121 | ip, err := osutil.GetIPFromMac(macaddr)
122 | if err != nil {
123 | logrus.Error("Unable to get IP from mac", err)
124 | }
125 | user, err := osutil.MacVZUser(true)
126 | if err != nil {
127 | logrus.Error("Unable to get current user", err)
128 | }
129 | return user.Username + "@" + ip
130 | }
131 |
132 | // SSHOpts adds the following options to CommonOptions: User, ControlMaster, ControlPath, ControlPersist
133 | func SSHOpts(instDir string, useDotSSH, forwardAgent bool) ([]string, error) {
134 | controlSock := filepath.Join(instDir, filenames.SSHSock)
135 | if len(controlSock) >= osutil.UnixPathMax {
136 | return nil, fmt.Errorf("socket path %q is too long: >= UNIX_PATH_MAX=%d", controlSock, osutil.UnixPathMax)
137 | }
138 |
139 | u, err := osutil.MacVZUser(false)
140 | if err != nil {
141 | return nil, err
142 | }
143 |
144 | //Run generation of public keys, so it can be used in common opts
145 | _, _ = DefaultPubKeys(true)
146 | opts, err := CommonOpts(useDotSSH)
147 | if err != nil {
148 | return nil, err
149 | }
150 | opts = append(opts,
151 | fmt.Sprintf("User=%s", u.Username), // guest and host have the same username, but we should specify the username explicitly (#85)
152 | "ControlMaster=auto",
153 | fmt.Sprintf("ControlPath=\"%s\"", controlSock),
154 | "ControlPersist=5m",
155 | )
156 | if forwardAgent {
157 | opts = append(opts, "ForwardAgent=yes")
158 | }
159 | return opts, nil
160 | }
161 |
162 | // SSHArgsFromOpts returns ssh args from opts.
163 | // The result always contains {"-F", "/dev/null} in additon to {"-o", "KEY=VALUE", ...}.
164 | func SSHArgsFromOpts(opts []string) []string {
165 | args := []string{"-F", "/dev/null"}
166 | for _, o := range opts {
167 | args = append(args, "-o", o)
168 | }
169 | return args
170 | }
171 |
172 | // CommonOpts returns ssh option key-value pairs like {"IdentityFile=/path/to/id_foo"}.
173 | // The result may contain different values with the same key.
174 | //
175 | // The result always contains the IdentityFile option.
176 | // The result never contains the Port option.
177 | func CommonOpts(useDotSSH bool) ([]string, error) {
178 | configDir, err := dirnames.MacVZConfigDir()
179 | if err != nil {
180 | return nil, err
181 | }
182 | privateKeyPath := filepath.Join(configDir, filenames.UserPrivateKey)
183 | _, err = os.Stat(privateKeyPath)
184 | if err != nil {
185 | return nil, err
186 | }
187 | opts := []string{"IdentityFile=\"" + privateKeyPath + "\""}
188 |
189 | // Append all private keys corresponding to ~/.ssh/*.pub to keep old instances working
190 | // that had been created before lima started using an internal identity.
191 | if useDotSSH {
192 | homeDir, err := os.UserHomeDir()
193 | if err != nil {
194 | return nil, err
195 | }
196 | files, err := filepath.Glob(filepath.Join(homeDir, ".ssh/*.pub"))
197 | if err != nil {
198 | panic(err) // Only possible error is ErrBadPattern, so this should be unreachable.
199 | }
200 | for _, f := range files {
201 | if !strings.HasSuffix(f, ".pub") {
202 | panic(fmt.Errorf("unexpected ssh public key filename %q", f))
203 | }
204 | privateKeyPath := strings.TrimSuffix(f, ".pub")
205 | _, err = os.Stat(privateKeyPath)
206 | if errors.Is(err, fs.ErrNotExist) {
207 | // Skip .pub files without a matching private key. This is reasonably common,
208 | // due to major projects like Vault recommending the ${name}-cert.pub format
209 | // for SSH certificate files.
210 | //
211 | // e.g. https://www.vaultproject.io/docs/secrets/ssh/signed-ssh-certificates
212 | continue
213 | }
214 | if err != nil {
215 | // Fail on permission-related and other path errors
216 | return nil, err
217 | }
218 | opts = append(opts, "IdentityFile=\""+privateKeyPath+"\"")
219 | }
220 | }
221 |
222 | opts = append(opts,
223 | "StrictHostKeyChecking=no",
224 | "UserKnownHostsFile=/dev/null",
225 | "NoHostAuthenticationForLocalhost=yes",
226 | "GSSAPIAuthentication=no",
227 | "PreferredAuthentications=publickey",
228 | "Compression=no",
229 | "BatchMode=yes",
230 | "IdentitiesOnly=yes",
231 | )
232 |
233 | sshInfo.Do(func() {
234 | sshInfo.aesAccelerated = detectAESAcceleration()
235 | sshInfo.openSSHVersion = DetectOpenSSHVersion()
236 | })
237 |
238 | // Only OpenSSH version 8.1 and later support adding ciphers to the front of the default set
239 | if !sshInfo.openSSHVersion.LessThan(*semver.New("8.1.0")) {
240 | // By default, `ssh` choose chacha20-poly1305@openssh.com, even when AES accelerator is available.
241 | // (OpenSSH_8.1p1, macOS 11.6, MacBookPro 2020, Core i7-1068NG7)
242 | //
243 | // We prioritize AES algorithms when AES accelerator is available.
244 | if sshInfo.aesAccelerated {
245 | logrus.Debugf("AES accelerator seems available, prioritizing aes128-gcm@openssh.com and aes256-gcm@openssh.com")
246 | //opts = append(opts, "Ciphers=\"^aes128-gcm@openssh.com,aes256-gcm@openssh.com\"")
247 | } else {
248 | logrus.Debugf("AES accelerator does not seem available, prioritizing chacha20-poly1305@openssh.com")
249 | //opts = append(opts, "Ciphers=\"^chacha20-poly1305@openssh.com\"")
250 | }
251 | }
252 | return opts, nil
253 | }
254 |
255 | func ParseOpenSSHVersion(version []byte) *semver.Version {
256 | regex := regexp.MustCompile(`^OpenSSH_(\d+\.\d+)(?:p(\d+))?\b`)
257 | matches := regex.FindSubmatch(version)
258 | if len(matches) == 3 {
259 | if len(matches[2]) == 0 {
260 | matches[2] = []byte("0")
261 | }
262 | return semver.New(fmt.Sprintf("%s.%s", matches[1], matches[2]))
263 | }
264 | return &semver.Version{}
265 | }
266 |
267 | func DetectOpenSSHVersion() semver.Version {
268 | var (
269 | v semver.Version
270 | stderr bytes.Buffer
271 | )
272 | cmd := exec.Command("ssh", "-V")
273 | cmd.Stderr = &stderr
274 | if err := cmd.Run(); err != nil {
275 | logrus.Warnf("failed to run %v: stderr=%q", cmd.Args, stderr.String())
276 | } else {
277 | v = *ParseOpenSSHVersion(stderr.Bytes())
278 | logrus.Debugf("OpenSSH version %s detected", v)
279 | }
280 | return v
281 | }
282 |
283 | // detectValidPublicKey returns whether content represent a public key.
284 | // OpenSSH public key format have the structure of ' '.
285 | // By checking 'algorithm' with signature format identifier in 'key' part,
286 | // this function may report false positive but provide better compatibility.
287 | func detectValidPublicKey(content string) bool {
288 | if strings.ContainsRune(content, '\n') {
289 | return false
290 | }
291 | var spaced = strings.SplitN(content, " ", 3)
292 | if len(spaced) < 2 {
293 | return false
294 | }
295 | var algo, base64Key = spaced[0], spaced[1]
296 | var decodedKey, err = base64.StdEncoding.DecodeString(base64Key)
297 | if err != nil || len(decodedKey) < 4 {
298 | return false
299 | }
300 | var sigLength = binary.BigEndian.Uint32(decodedKey)
301 | if uint32(len(decodedKey)) < sigLength {
302 | return false
303 | }
304 | var sigFormat = string(decodedKey[4 : 4+sigLength])
305 | return algo == sigFormat
306 | }
307 |
--------------------------------------------------------------------------------
/pkg/sshutil/sshutil_darwin.go:
--------------------------------------------------------------------------------
1 | package sshutil
2 |
3 | import (
4 | "fmt"
5 | "runtime"
6 | "strings"
7 | "syscall"
8 |
9 | "github.com/sirupsen/logrus"
10 | )
11 |
12 | func detectAESAcceleration() bool {
13 | switch runtime.GOARCH {
14 | case "amd64":
15 | const fallback = true
16 | features, err := syscall.Sysctl("machdep.cpu.features") // not available on M1
17 | if err != nil {
18 | err = fmt.Errorf("failed to read sysctl \"machdep.cpu.features\": %w", err)
19 | logrus.WithError(err).Warnf("failed to detect whether AES accelerator is available, assuming %v", fallback)
20 | return fallback
21 | }
22 | return strings.Contains(features, "AES ")
23 | default:
24 | // According to https://gist.github.com/voluntas/fd279c7b4e71f9950cfd4a5ab90b722b ,
25 | // aes-128-gcm is faster than chacha20-poly1305 on Apple M1.
26 | return true
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/pkg/sshutil/sshutil_linux.go:
--------------------------------------------------------------------------------
1 | package sshutil
2 |
3 | import (
4 | "os"
5 | "runtime"
6 | "strings"
7 |
8 | "github.com/sirupsen/logrus"
9 | )
10 |
11 | func detectAESAcceleration() bool {
12 | const fallback = runtime.GOARCH == "amd64"
13 | cpuinfo, err := os.ReadFile("/proc/cpuinfo")
14 | if err != nil {
15 | logrus.WithError(err).Warnf("failed to detect whether AES accelerator is available, assuming %v", fallback)
16 | return fallback
17 | }
18 | // Checking "aes " should be enough for x86_64 and aarch64
19 | return strings.Contains(string(cpuinfo), "aes ")
20 | }
21 |
--------------------------------------------------------------------------------
/pkg/sshutil/sshutil_others.go:
--------------------------------------------------------------------------------
1 | //go:build !darwin && !linux
2 | // +build !darwin,!linux
3 |
4 | package sshutil
5 |
6 | import (
7 | "runtime"
8 |
9 | "github.com/sirupsen/logrus"
10 | )
11 |
12 | func detectAESAcceleration() bool {
13 | const fallback = runtime.GOARCH == "amd64"
14 | logrus.WithError(err).Warnf("cannot detect whether AES accelerator is available, assuming %v", fallback)
15 | return fallback
16 | }
17 |
--------------------------------------------------------------------------------
/pkg/sshutil/sshutil_test.go:
--------------------------------------------------------------------------------
1 | package sshutil
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/coreos/go-semver/semver"
7 | "gotest.tools/v3/assert"
8 | )
9 |
10 | func TestDefaultPubKeys(t *testing.T) {
11 | keys, _ := DefaultPubKeys(true)
12 | t.Logf("found %d public keys", len(keys))
13 | for _, key := range keys {
14 | t.Logf("%s: %q", key.Filename, key.Content)
15 | }
16 | }
17 |
18 | func TestParseOpenSSHVersion(t *testing.T) {
19 | assert.Check(t, ParseOpenSSHVersion([]byte("OpenSSH_8.4p1 Ubuntu")).Equal(
20 | semver.Version{Major: 8, Minor: 4, Patch: 1, PreRelease: "", Metadata: ""}))
21 |
22 | assert.Check(t, ParseOpenSSHVersion([]byte("OpenSSH_7.6p1 Ubuntu")).LessThan(*semver.New("8.0.0")))
23 |
24 | // macOS 10.15
25 | assert.Check(t, ParseOpenSSHVersion([]byte("OpenSSH_8.1p1, LibreSSL 2.7.3")).Equal(*semver.New("8.1.1")))
26 |
27 | // OpenBSD 5.8
28 | assert.Check(t, ParseOpenSSHVersion([]byte("OpenSSH_7.0, LibreSSL")).Equal(*semver.New("7.0.0")))
29 | }
30 |
31 | func Test_detectValidPublicKey(t *testing.T) {
32 | assert.Check(t, detectValidPublicKey("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAACQDf2IooTVPDBw== 64bit"))
33 | assert.Check(t, detectValidPublicKey("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAACQDf2IooTVPDBw=="))
34 | assert.Check(t, detectValidPublicKey("ssh-dss AAAAB3NzaC1kc3MAAACBAP/yAytaYzqXq01uTd5+1RC=" /* truncate */))
35 | assert.Check(t, detectValidPublicKey("ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY=" /* truncate */))
36 | assert.Check(t, detectValidPublicKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICs1tSO/jx8oc4O=" /* truncate */))
37 |
38 | assert.Check(t, !detectValidPublicKey("wrong-algo AAAAB3NzaC1kc3MAAACBAP/yAytaYzqXq01uTd5+1RC="))
39 | assert.Check(t, !detectValidPublicKey("huge-length AAAD6A=="))
40 | assert.Check(t, !detectValidPublicKey("arbitrary content"))
41 | assert.Check(t, !detectValidPublicKey(""))
42 | }
43 |
--------------------------------------------------------------------------------
/pkg/start/start.go:
--------------------------------------------------------------------------------
1 | package start
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | hostagentevents "github.com/mac-vz/macvz/pkg/hostagent/events"
8 | "os"
9 | "os/exec"
10 | "path/filepath"
11 | "time"
12 |
13 | "github.com/mac-vz/macvz/pkg/store"
14 | "github.com/mac-vz/macvz/pkg/store/filenames"
15 | "github.com/mac-vz/macvz/pkg/vzrun"
16 | "github.com/mac-vz/macvz/pkg/yaml"
17 | "github.com/sirupsen/logrus"
18 | )
19 |
20 | func ensureDisk(ctx context.Context, instName, instDir string, y *yaml.MacVZYaml) error {
21 | vmCfg := vzrun.VM{
22 | Name: instName,
23 | InstanceDir: instDir,
24 | MacVZYaml: y,
25 | }
26 | if err := vzrun.EnsureDisk(ctx, vmCfg); err != nil {
27 | return err
28 | }
29 |
30 | return nil
31 | }
32 |
33 | func Start(ctx context.Context, inst *store.Instance) error {
34 | vzPid := filepath.Join(inst.Dir, filenames.VZPid)
35 | if _, err := os.Stat(vzPid); !errors.Is(err, os.ErrNotExist) {
36 | return fmt.Errorf("instance %q seems running (hint: remove %q if the instance is not actually running)", inst.Name, vzPid)
37 | }
38 |
39 | y, err := inst.LoadYAML()
40 | if err != nil {
41 | return err
42 | }
43 |
44 | if err := ensureDisk(ctx, inst.Name, inst.Dir, y); err != nil {
45 | return err
46 | }
47 | if err != nil {
48 | return err
49 | }
50 |
51 | self, err := os.Executable()
52 | if err != nil {
53 | return err
54 | }
55 | haStdoutPath := filepath.Join(inst.Dir, filenames.HaStdoutLog)
56 | haStderrPath := filepath.Join(inst.Dir, filenames.HaStderrLog)
57 | if err := os.RemoveAll(haStdoutPath); err != nil {
58 | return err
59 | }
60 | if err := os.RemoveAll(haStderrPath); err != nil {
61 | return err
62 | }
63 | haStdoutW, err := os.Create(haStdoutPath)
64 | if err != nil {
65 | return err
66 | }
67 | // no defer haStdoutW.Close()
68 | haStderrW, err := os.Create(haStderrPath)
69 | if err != nil {
70 | return err
71 | }
72 | // no defer haStderrW.Close()
73 |
74 | var args []string
75 | if logrus.GetLevel() >= logrus.DebugLevel {
76 | args = append(args, "--debug")
77 | }
78 | args = append(args, "vz", inst.Name)
79 | vzCmd := exec.CommandContext(ctx, self, args...)
80 |
81 | vzCmd.Stdout = haStdoutW
82 | vzCmd.Stderr = haStderrW
83 |
84 | // used for logrus propagation
85 |
86 | if err := vzCmd.Start(); err != nil {
87 | return err
88 | }
89 |
90 | if err := waitHostAgentStart(ctx, vzPid, haStderrPath); err != nil {
91 | return err
92 | }
93 | begin := time.Now() // used for logrus propagation
94 |
95 | watchErrCh := make(chan error)
96 | go func() {
97 | watchErrCh <- watchHostAgentEvents(ctx, inst, haStdoutPath, haStderrPath, begin)
98 | close(watchErrCh)
99 | }()
100 | waitErrCh := make(chan error)
101 | go func() {
102 | waitErrCh <- vzCmd.Wait()
103 | close(waitErrCh)
104 | }()
105 |
106 | select {
107 | case watchErr := <-watchErrCh:
108 | // watchErr can be nil
109 | return watchErr
110 | // leave the hostagent process running
111 | case waitErr := <-waitErrCh:
112 | // waitErr should not be nil
113 | return fmt.Errorf("VZ process has exited: %w", waitErr)
114 | }
115 | }
116 |
117 | func waitHostAgentStart(ctx context.Context, screenFile, haStderrPath string) error {
118 | begin := time.Now()
119 | deadlineDuration := 15 * time.Second
120 | deadline := begin.Add(deadlineDuration)
121 | for {
122 | if _, err := os.Stat(screenFile); !errors.Is(err, os.ErrNotExist) {
123 | return nil
124 | }
125 | if time.Now().After(deadline) {
126 | return fmt.Errorf("hostagent (%q) did not start up in %v (hint: see %q)", screenFile, deadlineDuration, haStderrPath)
127 | }
128 | }
129 | }
130 |
131 | func watchHostAgentEvents(ctx context.Context, inst *store.Instance, haStdoutPath, haStderrPath string, begin time.Time) error {
132 | ctx2, cancel := context.WithTimeout(ctx, 10*time.Minute)
133 | defer cancel()
134 |
135 | var (
136 | receivedRunningEvent bool
137 | err error
138 | )
139 | onEvent := func(ev hostagentevents.Event) bool {
140 | if len(ev.Status.Errors) > 0 {
141 | logrus.Errorf("%+v", ev.Status.Errors)
142 | }
143 | if ev.Status.Exiting {
144 | err = fmt.Errorf("exiting, status=%+v (hint: see %q)", ev.Status, haStderrPath)
145 | return true
146 | } else if ev.Status.Running {
147 | receivedRunningEvent = true
148 | if ev.Status.Degraded {
149 | logrus.Warnf("DEGRADED. The VM seems running, but file sharing and port forwarding may not work. (hint: see %q)", haStderrPath)
150 | err = fmt.Errorf("degraded, status=%+v", ev.Status)
151 | return true
152 | }
153 |
154 | logrus.Infof("READY. Run `%s` to open the shell.", MacvzShellCmd(inst.Name))
155 | ShowMessage(inst)
156 | err = nil
157 | return true
158 | }
159 | return false
160 | }
161 |
162 | if xerr := hostagentevents.Watch(ctx2, haStdoutPath, haStderrPath, begin, onEvent); xerr != nil {
163 | return xerr
164 | }
165 |
166 | if err != nil {
167 | return err
168 | }
169 |
170 | if !receivedRunningEvent {
171 | return errors.New("did not receive an event with the \"running\" status")
172 | }
173 |
174 | return nil
175 | }
176 |
177 | func MacvzShellCmd(instName string) string {
178 | shellCmd := fmt.Sprintf("macvz shell %s", instName)
179 | if instName == "default" {
180 | shellCmd = "macvz"
181 | }
182 | return shellCmd
183 | }
184 |
185 | func ShowMessage(inst *store.Instance) error {
186 | //if inst.Message == "" {
187 | // return nil
188 | //}
189 | //t, err := template.New("message").Parse(inst.Message)
190 | //if err != nil {
191 | // return err
192 | //}
193 | //data, err := store.AddGlobalFields(inst)
194 | //if err != nil {
195 | // return err
196 | //}
197 | //var b bytes.Buffer
198 | //if err := t.Execute(&b, data); err != nil {
199 | // return err
200 | //}
201 | //scanner := bufio.NewScanner(&b)
202 | //logrus.Infof("Message from the instance %q:", inst.Name)
203 | //for scanner.Scan() {
204 | // // Avoid prepending logrus "INFO" header, for ease of copypasting
205 | // fmt.Println(scanner.Text())
206 | //}
207 | //if err := scanner.Err(); err != nil {
208 | // return err
209 | //}
210 | return nil
211 | }
212 |
--------------------------------------------------------------------------------
/pkg/store/dirnames/dirnames.go:
--------------------------------------------------------------------------------
1 | package dirnames
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "github.com/mac-vz/macvz/pkg/store/filenames"
7 | "os"
8 | "path/filepath"
9 | )
10 |
11 | // DotLima is a directory that appears under the home directory.
12 | const DotLima = ".macvz"
13 |
14 | // MacVZDir returns the abstract path of `~/.lima` (or $LIMA_HOME, if set).
15 | //
16 | // NOTE: We do not use `~/Library/Application Support/Lima` on macOS.
17 | // We use `~/.lima` so that we can have enough space for the length of the socket path,
18 | // which can be only 104 characters on macOS.
19 | func MacVZDir() (string, error) {
20 | dir := os.Getenv("MACVZ_HOME")
21 | if dir == "" {
22 | homeDir, err := os.UserHomeDir()
23 | if err != nil {
24 | return "", err
25 | }
26 | dir = filepath.Join(homeDir, DotLima)
27 | }
28 | if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) {
29 | return dir, nil
30 | }
31 | realdir, err := filepath.EvalSymlinks(dir)
32 | if err != nil {
33 | return "", fmt.Errorf("cannot evaluate symlinks in %q: %w", dir, err)
34 | }
35 | return realdir, nil
36 | }
37 |
38 | // LimaConfigDir returns the path of the config directory, $MACVZ_HOME/_config.
39 | func MacVZConfigDir() (string, error) {
40 | limaDir, err := MacVZDir()
41 | if err != nil {
42 | return "", err
43 | }
44 | return filepath.Join(limaDir, filenames.ConfigDir), nil
45 | }
46 |
--------------------------------------------------------------------------------
/pkg/store/filenames/filenames.go:
--------------------------------------------------------------------------------
1 | // Package filenames defines the names of the files that appear under an instance dir
2 | // or inside the config directory.
3 | //
4 | // See docs/internal.md .
5 | package filenames
6 |
7 | // Instance names starting with an underscore are reserved for lima internal usage
8 |
9 | const (
10 | ConfigDir = "_config"
11 | )
12 |
13 | // Filenames used inside the ConfigDir
14 |
15 | const (
16 | UserPrivateKey = "user"
17 | UserPublicKey = UserPrivateKey + ".pub"
18 | Default = "default.yaml"
19 | Override = "override.yaml"
20 | )
21 |
22 | // Filenames that may appear under an instance directory
23 |
24 | const (
25 | MacVZYAML = "macvz.yaml"
26 | CIDataISO = "cidata.iso"
27 | BaseDiskZip = "basedisk.zip"
28 | BaseDisk = "basedisk"
29 | KernelCompressed = "vmlinux-compressed"
30 | Kernel = "vmlinux"
31 | Initrd = "initrd"
32 |
33 | HaStdoutLog = "ha.stdout.log"
34 | HaStderrLog = "ha.stderr.log"
35 |
36 | VZPid = "vz.pid"
37 | VZStdoutLog = "vz.stdout.log"
38 | VZStderrLog = "vz.stderr.log"
39 |
40 | SSHSock = "ssh.sock"
41 | SocketDir = "sockets"
42 | )
43 |
44 | // LongestSock is the longest socket name.
45 | // On macOS, the full path of the socket (excluding the NUL terminator) must be less than 104 characters.
46 | // See unix(4).
47 | //
48 | // On Linux, the full path must be less than 108 characters.
49 | //
50 | // ssh appends 16 bytes of random characters when it first creates the socket:
51 | // https://github.com/openssh/openssh-portable/blob/V_8_7_P1/mux.c#L1271-L1285
52 | const LongestSock = SSHSock + ".1234567890123456"
53 |
--------------------------------------------------------------------------------
/pkg/store/instance.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "errors"
5 | "github.com/docker/go-units"
6 | "github.com/mac-vz/macvz/pkg/store/filenames"
7 | "github.com/mac-vz/macvz/pkg/yaml"
8 | "os"
9 | "path/filepath"
10 | "strconv"
11 | "strings"
12 | "syscall"
13 | )
14 |
15 | type Status = string
16 |
17 | const (
18 | StatusUnknown Status = ""
19 | StatusBroken Status = "Broken"
20 | StatusStopped Status = "Stopped"
21 | StatusRunning Status = "Running"
22 | )
23 |
24 | type Instance struct {
25 | Name string `json:"name"`
26 | Status Status `json:"status"`
27 | Dir string `json:"dir"`
28 | CPUs int `json:"cpus,omitempty"`
29 | Memory int64 `json:"memory,omitempty"` // bytes
30 | Disk int64 `json:"disk,omitempty"` // bytes
31 |
32 | VZPid int `json:"VZPid,omitempty"`
33 | Errors []error `json:"errors,omitempty"`
34 | }
35 |
36 | func (inst *Instance) LoadYAML() (*yaml.MacVZYaml, error) {
37 | if inst.Dir == "" {
38 | return nil, errors.New("inst.Dir is empty")
39 | }
40 | yamlPath := filepath.Join(inst.Dir, filenames.MacVZYAML)
41 | return LoadYAMLByFilePath(yamlPath)
42 | }
43 |
44 | // Inspect returns err only when the instance does not exist (os.ErrNotExist).
45 | // Other errors are returned as *Instance.Errors
46 | func Inspect(instName string) (*Instance, error) {
47 | inst := &Instance{
48 | Name: instName,
49 | Status: StatusUnknown,
50 | }
51 | // InstanceDir validates the instName but does not check whether the instance exists
52 | instDir, err := InstanceDir(instName)
53 | if err != nil {
54 | return nil, err
55 | }
56 | yamlPath := filepath.Join(instDir, filenames.MacVZYAML)
57 | y, err := LoadYAMLByFilePath(yamlPath)
58 | if err != nil {
59 | if errors.Is(err, os.ErrNotExist) {
60 | return nil, err
61 | }
62 | inst.Errors = append(inst.Errors, err)
63 | return inst, nil
64 | }
65 | inst.Dir = instDir
66 | inst.CPUs = *y.CPUs
67 | memory, err := units.RAMInBytes(*y.Memory)
68 | if err == nil {
69 | inst.Memory = memory
70 | }
71 | disk, err := units.RAMInBytes(*y.Disk)
72 | if err == nil {
73 | inst.Disk = disk
74 | }
75 |
76 | vzPidFile := filepath.Join(instDir, filenames.VZPid)
77 | inst.VZPid, err = ReadPIDFile(vzPidFile)
78 |
79 | _, err = os.Stat(vzPidFile)
80 |
81 | if inst.Status == StatusUnknown {
82 | if inst.VZPid > 0 && err == nil {
83 | inst.Status = StatusRunning
84 | } else if inst.VZPid == 0 {
85 | inst.Status = StatusStopped
86 | }
87 | }
88 |
89 | return inst, nil
90 | }
91 |
92 | type FormatData struct {
93 | Instance
94 | HostOS string
95 | HostArch string
96 | LimaHome string
97 | IdentityFile string
98 | }
99 |
100 | func ReadPIDFile(path string) (int, error) {
101 | b, err := os.ReadFile(path)
102 | if err != nil {
103 | if errors.Is(err, os.ErrNotExist) {
104 | return 0, nil
105 | }
106 | return 0, err
107 | }
108 | pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
109 | if err != nil {
110 | return 0, err
111 | }
112 | proc, err := os.FindProcess(pid)
113 | if err != nil {
114 | return 0, err
115 | }
116 | err = proc.Signal(syscall.Signal(0))
117 | if err != nil {
118 | if errors.Is(err, os.ErrProcessDone) {
119 | _ = os.Remove(path)
120 | return 0, nil
121 | }
122 | // We may not have permission to send the signal (e.g. to network daemon running as root).
123 | // But if we get a permissions error, it means the process is still running.
124 | if !errors.Is(err, os.ErrPermission) {
125 | return 0, err
126 | }
127 | }
128 | return pid, nil
129 | }
130 |
--------------------------------------------------------------------------------
/pkg/store/store.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 | "strings"
7 |
8 | "github.com/mac-vz/macvz/pkg/store/dirnames"
9 | "github.com/mac-vz/macvz/pkg/yaml"
10 | )
11 |
12 | // Instances returns the names of the instances under MacVZDir.
13 | func Instances() ([]string, error) {
14 | limaDir, err := dirnames.MacVZDir()
15 | if err != nil {
16 | return nil, err
17 | }
18 | limaDirList, err := os.ReadDir(limaDir)
19 | if err != nil {
20 | return nil, err
21 | }
22 | var names []string
23 | for _, f := range limaDirList {
24 | if strings.HasPrefix(f.Name(), ".") || strings.HasPrefix(f.Name(), "_") {
25 | continue
26 | }
27 | names = append(names, f.Name())
28 | }
29 | return names, nil
30 | }
31 |
32 | // InstanceDir returns the instance dir.
33 | // InstanceDir does not check whether the instance exists
34 | func InstanceDir(name string) (string, error) {
35 | limaDir, err := dirnames.MacVZDir()
36 | if err != nil {
37 | return "", err
38 | }
39 | dir := filepath.Join(limaDir, name)
40 | return dir, nil
41 | }
42 |
43 | // LoadYAMLByFilePath loads and validates the yaml.
44 | func LoadYAMLByFilePath(filePath string) (*yaml.MacVZYaml, error) {
45 | // We need to use the absolute path because it may be used to determine hostSocket locations.
46 | absPath, err := filepath.Abs(filePath)
47 | if err != nil {
48 | return nil, err
49 | }
50 | yContent, err := os.ReadFile(absPath)
51 | if err != nil {
52 | return nil, err
53 | }
54 | y, err := yaml.Load(yContent, absPath)
55 | if err != nil {
56 | return nil, err
57 | }
58 | if err := yaml.Validate(*y, false); err != nil {
59 | return nil, err
60 | }
61 | return y, nil
62 | }
63 |
--------------------------------------------------------------------------------
/pkg/templateutil/templateutil.go:
--------------------------------------------------------------------------------
1 | package templateutil
2 |
3 | import (
4 | "bytes"
5 | "text/template"
6 | )
7 |
8 | func Execute(tmpl string, args interface{}) ([]byte, error) {
9 | x, err := template.New("").Parse(tmpl)
10 | if err != nil {
11 | return nil, err
12 | }
13 | var b bytes.Buffer
14 | if err := x.Execute(&b, args); err != nil {
15 | return nil, err
16 | }
17 | return b.Bytes(), nil
18 | }
19 |
--------------------------------------------------------------------------------
/pkg/types/types.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "net"
5 | "strconv"
6 | )
7 |
8 | //Kind Enum that defines the type of message
9 | type Kind = string
10 |
11 | const (
12 | //PortMessage PortEvent kind
13 | PortMessage Kind = "port-event"
14 | //InfoMessage InfoEvent kind
15 | InfoMessage Kind = "info-event"
16 | //DNSMessage DNSEvent kind
17 | DNSMessage Kind = "dns-event"
18 | //DNSResponseMessage DNSEventResponse kind
19 | DNSResponseMessage Kind = "dns-event-response"
20 | )
21 |
22 | //Event base type for all event
23 | type Event struct {
24 | Kind Kind `json:"kind"`
25 | }
26 |
27 | //InfoEvent used by guest to send negotitation request
28 | type InfoEvent struct {
29 | Event
30 | GatewayIP string `json:"gatewayIP"`
31 | LocalPorts []IPPort `json:"localPorts"`
32 | }
33 |
34 | //PortEvent used by guest to send port binding events
35 | type PortEvent struct {
36 | Event
37 | Time string `json:"time,omitempty"`
38 | LocalPortsAdded []IPPort `json:"localPortsAdded,omitempty"`
39 | LocalPortsRemoved []IPPort `json:"localPortsRemoved,omitempty"`
40 | Errors []string `json:"errors,omitempty"`
41 | }
42 |
43 | //DNSEvent used by guest to send DNS request
44 | type DNSEvent struct {
45 | Event
46 | Msg []byte `json:"msg"`
47 | }
48 |
49 | //DNSEventResponse used by host to send DNS response
50 | type DNSEventResponse struct {
51 | Event
52 | Msg []byte `json:"msg"`
53 | }
54 |
55 | //IPPort Used by PortEvent for IP and Port representation
56 | type IPPort struct {
57 | IP net.IP `json:"ip"`
58 | Port int `json:"port"`
59 | }
60 |
61 | func (x *IPPort) String() string {
62 | return net.JoinHostPort(x.IP.String(), strconv.Itoa(x.Port))
63 | }
64 |
--------------------------------------------------------------------------------
/pkg/version/version.go:
--------------------------------------------------------------------------------
1 | package version
2 |
3 | var (
4 | // Version is filled on compilation time
5 | Version = ""
6 | )
7 |
--------------------------------------------------------------------------------
/pkg/vzrun/disk.go:
--------------------------------------------------------------------------------
1 | package vzrun
2 |
3 | import (
4 | "compress/gzip"
5 | "context"
6 | "errors"
7 | "fmt"
8 | "github.com/docker/go-units"
9 | "github.com/h2non/filetype"
10 | "github.com/h2non/filetype/matchers"
11 | "github.com/mac-vz/macvz/pkg/downloader"
12 | "github.com/mac-vz/macvz/pkg/iso9660util"
13 | "github.com/mac-vz/macvz/pkg/store/filenames"
14 | "github.com/mac-vz/macvz/pkg/yaml"
15 | "github.com/sirupsen/logrus"
16 | "io"
17 | "os"
18 | "path/filepath"
19 | )
20 |
21 | //EnsureDisk Creates. and verifies if the VM Disk are present
22 | func EnsureDisk(ctx context.Context, cfg VM) error {
23 | kernelCompressed := filepath.Join(cfg.InstanceDir, filenames.KernelCompressed)
24 | initrd := filepath.Join(cfg.InstanceDir, filenames.Initrd)
25 | baseDisk := filepath.Join(cfg.InstanceDir, filenames.BaseDisk)
26 | BaseDiskZip := filepath.Join(cfg.InstanceDir, filenames.BaseDiskZip)
27 |
28 | if _, err := os.Stat(baseDisk); errors.Is(err, os.ErrNotExist) {
29 | var ensuredRequiredImages bool
30 | errs := make([]error, len(cfg.MacVZYaml.Images))
31 | resolveArch := yaml.ResolveArch()
32 | for i, f := range cfg.MacVZYaml.Images {
33 | if f.Arch != resolveArch {
34 | errs[i] = fmt.Errorf("image architecture %s didn't match system architecture: %s", f.Arch, resolveArch)
35 | continue
36 | }
37 | err := downloadImage(kernelCompressed, f.Kernel)
38 | if err != nil {
39 | errs[i] = fmt.Errorf("failed to download required images: %w", err)
40 | continue
41 | }
42 | err = downloadImage(initrd, f.Initram)
43 | if err != nil {
44 | errs[i] = fmt.Errorf("failed to download required images: %w", err)
45 | continue
46 | }
47 | err = downloadImage(BaseDiskZip, f.Base)
48 | if err != nil {
49 | errs[i] = fmt.Errorf("failed to download required images: %w", err)
50 | continue
51 | }
52 | fileName := ""
53 | if f.Arch == yaml.X8664 {
54 | fileName = "amd64"
55 | } else {
56 | fileName = "arm64"
57 | }
58 | err = iso9660util.Extract(BaseDiskZip, "focal-server-cloudimg-"+fileName+".img", baseDisk)
59 | if err != nil {
60 | errs[i] = fmt.Errorf("failed to extract base image: %w", err)
61 | continue
62 | }
63 |
64 | ensuredRequiredImages = true
65 | break
66 | }
67 | if !ensuredRequiredImages {
68 | return fmt.Errorf("failed to download the required images, attempted %d candidates, errors=%v",
69 | len(cfg.MacVZYaml.Images), errs)
70 | }
71 |
72 | err = uncompress(kernelCompressed, filepath.Join(cfg.InstanceDir, filenames.Kernel))
73 | if err != nil {
74 | logrus.Error("Error during uncompressing of kernel", err.Error())
75 | }
76 | inBytes, _ := units.RAMInBytes(*cfg.MacVZYaml.Disk)
77 | err := os.Truncate(baseDisk, inBytes)
78 | if err != nil {
79 | logrus.Error("Error during basedisk initial resize", err.Error())
80 | return err
81 | }
82 | }
83 | return nil
84 | }
85 |
86 | func downloadImage(disk string, remote string) error {
87 | res, err := downloader.Download(disk, remote,
88 | downloader.WithCache(),
89 | )
90 | if err != nil {
91 | return fmt.Errorf("failed to download %q: %w", remote, err)
92 | }
93 | switch res.Status {
94 | case downloader.StatusDownloaded:
95 | logrus.Infof("Downloaded image from %q", remote)
96 | case downloader.StatusUsedCache:
97 | logrus.Infof("Using cache %q", res.CachePath)
98 | default:
99 | logrus.Warnf("Unexpected result from downloader.Download(): %+v", res)
100 | }
101 | return nil
102 | }
103 |
104 | func isKernelUncompressed(filename string) (bool, error) {
105 | file, err := os.Open(filename)
106 | if err != nil {
107 | return false, err
108 | }
109 | defer file.Close()
110 |
111 | buf := make([]byte, 2048)
112 | _, err = file.Read(buf)
113 | if err != nil {
114 | return false, err
115 | }
116 | kind, err := filetype.Match(buf)
117 | if err != nil {
118 | return false, err
119 | }
120 | // uncompressed ARM64 kernels are matched as a MS executable, which is
121 | // also an archive, so we need to special case it
122 | if kind == matchers.TypeExe {
123 | return true, nil
124 | }
125 |
126 | return false, nil
127 | }
128 |
129 | func uncompress(compressedFile string, targetFile string) error {
130 | uncompressed, err := isKernelUncompressed(compressedFile)
131 | if err != nil {
132 | logrus.Error("Error during uncompressing of kernel", err.Error())
133 | return err
134 | }
135 |
136 | gzipFile, err := os.Open(compressedFile)
137 | if err != nil {
138 | return err
139 | }
140 |
141 | writer, err := os.Create(targetFile)
142 | if err != nil {
143 | return err
144 | }
145 | defer writer.Close()
146 |
147 | if uncompressed {
148 | logrus.Println("Skipping uncompress of kernel...")
149 | reader, err := os.Open(compressedFile)
150 | if err != nil {
151 | return err
152 | }
153 | defer reader.Close()
154 | if _, err = io.Copy(writer, reader); err != nil {
155 | return err
156 | }
157 | return nil
158 | } else {
159 | logrus.Println("Trying to uncompress kernel...")
160 | reader, err := gzip.NewReader(gzipFile)
161 | if err != nil {
162 | return err
163 | }
164 | defer reader.Close()
165 | if _, err = io.Copy(writer, reader); err != nil {
166 | return err
167 | }
168 | return nil
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/pkg/vzrun/main.go:
--------------------------------------------------------------------------------
1 | package vzrun
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "github.com/Code-Hex/vz/v2"
8 | "github.com/docker/go-units"
9 | "github.com/hashicorp/yamux"
10 | "github.com/mac-vz/macvz/pkg/socket"
11 | "github.com/mac-vz/macvz/pkg/store"
12 | "github.com/mac-vz/macvz/pkg/store/filenames"
13 | "github.com/mac-vz/macvz/pkg/types"
14 | "github.com/mac-vz/macvz/pkg/yaml"
15 | "github.com/mitchellh/go-homedir"
16 | "github.com/sirupsen/logrus"
17 | "io"
18 | "net"
19 | "os"
20 | "path/filepath"
21 | "strconv"
22 | "strings"
23 | )
24 |
25 | // VM VirtualMachine instance
26 | type VM struct {
27 | Name string
28 | InstanceDir string
29 | MacVZYaml *yaml.MacVZYaml
30 | Handlers map[types.Kind]func(ctx context.Context, stream *yamux.Stream, event interface{})
31 | sigintCh chan os.Signal
32 | }
33 |
34 | // InitializeVM Create a virtual machine instance
35 | func InitializeVM(
36 | instName string,
37 | handlers map[types.Kind]func(ctx context.Context, stream *yamux.Stream, event interface{}),
38 | sigintCh chan os.Signal,
39 | ) (*VM, error) {
40 | inst, err := store.Inspect(instName)
41 | if err != nil {
42 | return nil, err
43 | }
44 |
45 | y, err := inst.LoadYAML()
46 | if err != nil {
47 | return nil, err
48 | }
49 |
50 | a := &VM{
51 | MacVZYaml: y,
52 | InstanceDir: inst.Dir,
53 | Name: inst.Name,
54 | Handlers: handlers,
55 | sigintCh: sigintCh,
56 | }
57 | return a, nil
58 | }
59 |
60 | // Run Starts the VM instance
61 | func (vm VM) Run() error {
62 | y := vm.MacVZYaml
63 |
64 | kernelCommandLineArguments := []string{
65 | // Use the first virtio console device as system console.
66 | "console=hvc0",
67 | "irqfixup",
68 | // Stop in the initial ramdisk before attempting to transition to
69 | // the root file system.
70 | "root=/dev/vda",
71 | }
72 |
73 | vmlinuz := filepath.Join(vm.InstanceDir, filenames.Kernel)
74 | initrd := filepath.Join(vm.InstanceDir, filenames.Initrd)
75 | diskPath := filepath.Join(vm.InstanceDir, filenames.BaseDisk)
76 | ciData := filepath.Join(vm.InstanceDir, filenames.CIDataISO)
77 |
78 | bootLoader := vz.NewLinuxBootLoader(
79 | vmlinuz,
80 | vz.WithCommandLine(strings.Join(kernelCommandLineArguments, " ")),
81 | vz.WithInitrd(initrd),
82 | )
83 |
84 | bytes, err := units.RAMInBytes(*y.Memory)
85 | config := vz.NewVirtualMachineConfiguration(
86 | bootLoader,
87 | uint(*y.CPUs),
88 | uint64(bytes),
89 | )
90 | //setRawMode(os.Stdin)
91 |
92 | // console
93 | readFile, _ := os.Create(filepath.Join(vm.InstanceDir, filenames.VZStdoutLog))
94 | writeFile, _ := os.Create(filepath.Join(vm.InstanceDir, filenames.VZStderrLog))
95 | serialPortAttachment := vz.NewFileHandleSerialPortAttachment(readFile, writeFile)
96 | consoleConfig := vz.NewVirtioConsoleDeviceSerialPortConfiguration(serialPortAttachment)
97 |
98 | readFile1, _ := os.Create(filepath.Join(vm.InstanceDir, "read.sock"))
99 | writeFile1, _ := os.Create(filepath.Join(vm.InstanceDir, "write.sock"))
100 | serialPortAttachment1 := vz.NewFileHandleSerialPortAttachment(readFile1, writeFile1)
101 | console1Config := vz.NewVirtioConsoleDeviceSerialPortConfiguration(serialPortAttachment1)
102 |
103 | config.SetSerialPortsVirtualMachineConfiguration([]*vz.VirtioConsoleDeviceSerialPortConfiguration{
104 | consoleConfig,
105 | console1Config,
106 | })
107 |
108 | // network
109 | macAddr, err := net.ParseMAC(*y.MACAddress)
110 |
111 | natAttachment := vz.NewNATNetworkDeviceAttachment()
112 | networkConfig := vz.NewVirtioNetworkDeviceConfiguration(natAttachment)
113 | networkConfig.SetMACAddress(vz.NewMACAddress(macAddr))
114 |
115 | config.SetNetworkDevicesVirtualMachineConfiguration([]*vz.VirtioNetworkDeviceConfiguration{
116 | networkConfig,
117 | })
118 | // entropy
119 | entropyConfig := vz.NewVirtioEntropyDeviceConfiguration()
120 | config.SetEntropyDevicesVirtualMachineConfiguration([]*vz.VirtioEntropyDeviceConfiguration{
121 | entropyConfig,
122 | })
123 |
124 | ciDataIso, err := vz.NewDiskImageStorageDeviceAttachment(ciData, true)
125 | if err != nil {
126 | logrus.Fatal(err)
127 | }
128 | ciDataConfig := vz.NewVirtioBlockDeviceConfiguration(ciDataIso)
129 |
130 | diskImageAttachment, err := vz.NewDiskImageStorageDeviceAttachment(diskPath, false)
131 | if err != nil {
132 | logrus.Fatal(err)
133 | }
134 | storageDeviceConfig := vz.NewVirtioBlockDeviceConfiguration(diskImageAttachment)
135 | config.SetStorageDevicesVirtualMachineConfiguration([]vz.StorageDeviceConfiguration{
136 | storageDeviceConfig,
137 | ciDataConfig,
138 | })
139 |
140 | // traditional memory balloon device which allows for managing guest memory. (optional)
141 | config.SetMemoryBalloonDevicesVirtualMachineConfiguration([]vz.MemoryBalloonDeviceConfiguration{
142 | vz.NewVirtioTraditionalMemoryBalloonDeviceConfiguration(),
143 | })
144 |
145 | // socket device (optional)
146 | config.SetSocketDevicesVirtualMachineConfiguration([]vz.SocketDeviceConfiguration{
147 | vz.NewVirtioSocketDeviceConfiguration(),
148 | })
149 |
150 | mounts := make([]vz.DirectorySharingDeviceConfiguration, len(vm.MacVZYaml.Mounts))
151 | for i, mount := range y.Mounts {
152 | expand, _ := homedir.Expand(mount.Location)
153 | config := vz.NewVirtioFileSystemDeviceConfiguration(expand)
154 | config.SetDirectoryShare(vz.NewSingleDirectoryShare(vz.NewSharedDirectory(expand, !*mount.Writable)))
155 | mounts[i] = config
156 | }
157 | config.SetDirectorySharingDevicesVirtualMachineConfiguration(mounts)
158 |
159 | validated, err := config.Validate()
160 | if !validated || err != nil {
161 | logrus.Fatal("validation failed", err)
162 | }
163 |
164 | machine := vz.NewVirtualMachine(config)
165 |
166 | errCh := make(chan error, 1)
167 |
168 | machine.Start(func(err error) {
169 | if err != nil {
170 | errCh <- err
171 | }
172 | })
173 |
174 | for {
175 | select {
176 | case <-vm.sigintCh:
177 | result, err := machine.RequestStop()
178 | if err != nil {
179 | logrus.Println("request stop error:", err)
180 | return nil
181 | }
182 | logrus.Println("recieved signal", result)
183 | case newState := <-machine.StateChangedNotify():
184 | if newState == vz.VirtualMachineStateRunning {
185 | pidFile := filepath.Join(vm.InstanceDir, filenames.VZPid)
186 | if err != nil {
187 | return err
188 | }
189 | if pidFile != "" {
190 | if _, err := os.Stat(pidFile); !errors.Is(err, os.ErrNotExist) {
191 | return fmt.Errorf("pidfile %q already exists", pidFile)
192 | }
193 | if err := os.WriteFile(pidFile, []byte(strconv.Itoa(os.Getpid())+"\n"), 0644); err != nil {
194 | return err
195 | }
196 | defer os.RemoveAll(pidFile)
197 | }
198 |
199 | background, cancel := context.WithCancel(context.Background())
200 | defer cancel()
201 | yamuxListener, err := vm.createVSockListener(background)
202 | if err != nil {
203 | logrus.Fatal("Failed to create listener", err)
204 | }
205 | for _, socketDevice := range machine.SocketDevices() {
206 | socketDevice.SetSocketListenerForPort(yamuxListener, 47)
207 | }
208 | logrus.Println("start VM is running")
209 | }
210 | if newState == vz.VirtualMachineStateStopped {
211 | logrus.Println("stopped successfully")
212 | return nil
213 | }
214 | case err := <-errCh:
215 | logrus.Println("in start:", err)
216 | return errors.New("error during start of VM")
217 | }
218 | }
219 | }
220 |
221 | func (vm VM) createVSockListener(ctx context.Context) (*vz.VirtioSocketListener, error) {
222 | connCh := make(chan *vz.VirtioSocketConnection)
223 |
224 | go func() {
225 | var (
226 | conn *vz.VirtioSocketConnection
227 | sess *yamux.Session
228 | )
229 |
230 | for {
231 | select {
232 | case ci := <-connCh:
233 | conn = ci
234 |
235 | if sess != nil {
236 | sess.Close()
237 | }
238 |
239 | cfg := yamux.DefaultConfig()
240 | cfg.EnableKeepAlive = true
241 | cfg.AcceptBacklog = 10
242 |
243 | sess, _ = yamux.Client(conn, cfg)
244 |
245 | go vm.handleFromGuest(ctx, sess)
246 | }
247 | }
248 | }()
249 |
250 | listener := vz.NewVirtioSocketListener(func(conn *vz.VirtioSocketConnection, err error) {
251 | if err != nil {
252 | return
253 | }
254 | connCh <- conn
255 | })
256 |
257 | return listener, nil
258 | }
259 |
260 | func (vm VM) handleFromGuest(ctx context.Context, sess *yamux.Session) {
261 | for {
262 | c, err := sess.AcceptStream()
263 | if err != nil {
264 | if !errors.Is(err, io.EOF) {
265 | logrus.Warn("unable to accept new incoming yamux streams", "error", err)
266 | }
267 | return
268 | }
269 |
270 | go vm.handleGuestConn(ctx, c)
271 | }
272 | }
273 |
274 | func (vm VM) handleGuestConn(ctx context.Context, c *yamux.Stream) {
275 | defer c.Close()
276 |
277 | _, dec := socket.GetStreamIO(c)
278 |
279 | var genericMap map[string]interface{}
280 |
281 | socket.Read(dec, &genericMap)
282 | switch genericMap["kind"] {
283 | case types.InfoMessage:
284 | info := types.InfoEvent{}
285 | socket.ReadMap(genericMap, &info)
286 | vm.Handlers[types.InfoMessage](ctx, c, info)
287 | case types.PortMessage:
288 | event := types.PortEvent{}
289 | socket.ReadMap(genericMap, &event)
290 | vm.Handlers[types.PortMessage](ctx, c, event)
291 | case types.DNSMessage:
292 | dns := types.DNSEvent{}
293 | socket.ReadMap(genericMap, &dns)
294 | vm.Handlers[types.DNSMessage](ctx, c, dns)
295 | }
296 | }
297 |
--------------------------------------------------------------------------------
/pkg/yaml/default.yaml:
--------------------------------------------------------------------------------
1 | # ===================================================================== #
2 | # BASIC CONFIGURATION
3 | # ===================================================================== #
4 |
5 | # Default values are specified by `null` instead of the builtin default value,
6 | # so they can be overridden by the default.yaml mechanism documented at the
7 | # end of this file.
8 |
9 | # Arch: "default", "x86_64", "aarch64".
10 | # Default: "default" (corresponds to the host architecture)
11 | arch: null
12 |
13 | # An image must support systemd and cloud-init.
14 | # Ubuntu and Fedora are known to work.
15 | # Default: none (must be specified)
16 | images:
17 | - kernel: ""
18 | initram: ""
19 | base: ""
20 | arch: "x86_64"
21 | - kernel: ""
22 | initram: ""
23 | base: ""
24 | arch: "aarch64"
25 |
26 | # CPUs: if you see performance issues, try limiting cpus to 1.
27 | # Default: 4
28 | cpus: null
29 |
30 | # Memory size
31 | # Default: "4GiB"
32 | memory: null
33 |
34 | # Disk size
35 | # Default: "100GiB"
36 | disk: null
37 |
38 | # Expose host directories to the guest, the mount point might be accessible from all UIDs in the guest
39 | # Default: null
40 | mounts:
41 | - location: "~"
42 | # CAUTION: `writable` SHOULD be false for the home directory.
43 | # Setting `writable` to true is possible, but untested and dangerous.
44 | # Default: false
45 | writable: null
46 | - location: "/tmp/lima"
47 | writable: true
48 |
49 | # ===================================================================== #
50 | # END OF TEMPLATE
51 | # ===================================================================== #
52 |
--------------------------------------------------------------------------------
/pkg/yaml/defaults.go:
--------------------------------------------------------------------------------
1 | package yaml
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "github.com/Code-Hex/vz/v2"
7 | "github.com/mac-vz/macvz/pkg/guestagent/api"
8 | "github.com/mac-vz/macvz/pkg/osutil"
9 | "github.com/mac-vz/macvz/pkg/store/filenames"
10 | "github.com/sirupsen/logrus"
11 | "github.com/xorcare/pointer"
12 | "net"
13 | "os"
14 | osuser "os/user"
15 | "path/filepath"
16 | "runtime"
17 | "strings"
18 | "text/template"
19 | )
20 |
21 | func FillDefault(y, d, o *MacVZYaml, filePath string) {
22 | defaultArch := pointer.String(ResolveArch())
23 |
24 | y.Images = append(append(o.Images, y.Images...), d.Images...)
25 | for i := range y.Images {
26 | img := &y.Images[i]
27 | if img.Arch == "" {
28 | img.Arch = *defaultArch
29 | }
30 | }
31 |
32 | if y.CPUs == nil {
33 | y.CPUs = d.CPUs
34 | }
35 | if o.CPUs != nil {
36 | y.CPUs = o.CPUs
37 | }
38 | if y.CPUs == nil || *y.CPUs == 0 {
39 | y.CPUs = pointer.Int(4)
40 | }
41 |
42 | if y.Memory == nil {
43 | y.Memory = d.Memory
44 | }
45 | if o.Memory != nil {
46 | y.Memory = o.Memory
47 | }
48 | if y.Memory == nil || *y.Memory == "" {
49 | y.Memory = pointer.String("4GiB")
50 | }
51 |
52 | if y.Disk == nil {
53 | y.Disk = d.Disk
54 | }
55 | if o.Disk != nil {
56 | y.Disk = o.Disk
57 | }
58 | if y.Disk == nil || *y.Disk == "" {
59 | y.Disk = pointer.String("100GiB")
60 | }
61 |
62 | if y.MACAddress == nil || *y.MACAddress == "" {
63 | y.MACAddress = pointer.String(vz.NewRandomLocallyAdministeredMACAddress().String())
64 | }
65 |
66 | hosts := make(map[string]string)
67 | // Values can be either names or IP addresses. Name values are canonicalized in the hostResolver.
68 | for k, v := range d.HostResolver.Hosts {
69 | hosts[Cname(k)] = v
70 | }
71 | for k, v := range y.HostResolver.Hosts {
72 | hosts[Cname(k)] = v
73 | }
74 | for k, v := range o.HostResolver.Hosts {
75 | hosts[Cname(k)] = v
76 | }
77 | y.HostResolver.Hosts = hosts
78 |
79 | y.Provision = append(append(o.Provision, y.Provision...), d.Provision...)
80 | for i := range y.Provision {
81 | provision := &y.Provision[i]
82 | if provision.Mode == "" {
83 | provision.Mode = ProvisionModeSystem
84 | }
85 | }
86 |
87 | y.Probes = append(append(o.Probes, y.Probes...), d.Probes...)
88 | for i := range y.Probes {
89 | probe := &y.Probes[i]
90 | if probe.Mode == "" {
91 | probe.Mode = ProbeModeReadiness
92 | }
93 | if probe.Description == "" {
94 | probe.Description = fmt.Sprintf("user probe %d/%d", i+1, len(y.Probes))
95 | }
96 | }
97 |
98 | y.PortForwards = append(append(o.PortForwards, y.PortForwards...), d.PortForwards...)
99 | instDir := filepath.Dir(filePath)
100 | for i := range y.PortForwards {
101 | FillPortForwardDefaults(&y.PortForwards[i], instDir)
102 | // After defaults processing the singular HostPort and GuestPort values should not be used again.
103 | }
104 |
105 | if y.SSH.LocalPort == nil {
106 | y.SSH.LocalPort = d.SSH.LocalPort
107 | }
108 | if o.SSH.LocalPort != nil {
109 | y.SSH.LocalPort = o.SSH.LocalPort
110 | }
111 | if y.SSH.LocalPort == nil {
112 | // y.SSH.LocalPort value is not filled here (filled by the hostagent)
113 | y.SSH.LocalPort = pointer.Int(0)
114 | }
115 | if y.SSH.LoadDotSSHPubKeys == nil {
116 | y.SSH.LoadDotSSHPubKeys = d.SSH.LoadDotSSHPubKeys
117 | }
118 | if o.SSH.LoadDotSSHPubKeys != nil {
119 | y.SSH.LoadDotSSHPubKeys = o.SSH.LoadDotSSHPubKeys
120 | }
121 | if y.SSH.LoadDotSSHPubKeys == nil {
122 | y.SSH.LoadDotSSHPubKeys = pointer.Bool(true)
123 | }
124 |
125 | if y.SSH.ForwardAgent == nil {
126 | y.SSH.ForwardAgent = d.SSH.ForwardAgent
127 | }
128 | if o.SSH.ForwardAgent != nil {
129 | y.SSH.ForwardAgent = o.SSH.ForwardAgent
130 | }
131 | if y.SSH.ForwardAgent == nil {
132 | y.SSH.ForwardAgent = pointer.Bool(false)
133 | }
134 |
135 | // If both `useHostResolved` and `HostResolver.Enabled` are defined in the same config,
136 | // then the deprecated `useHostResolved` setting is silently ignored.
137 | if y.HostResolver.IPv6 == nil {
138 | y.HostResolver.IPv6 = d.HostResolver.IPv6
139 | }
140 | if o.HostResolver.IPv6 != nil {
141 | y.HostResolver.IPv6 = o.HostResolver.IPv6
142 | }
143 | if y.HostResolver.IPv6 == nil {
144 | y.HostResolver.IPv6 = pointer.Bool(false)
145 | }
146 |
147 | // Combine all mounts; highest priority entry determines writable status.
148 | // Only works for exact matches; does not normalize case or resolve symlinks.
149 | mounts := make([]Mount, 0, len(d.Mounts)+len(y.Mounts)+len(o.Mounts))
150 | location := make(map[string]int)
151 | for _, mount := range append(append(d.Mounts, y.Mounts...), o.Mounts...) {
152 | if i, ok := location[mount.Location]; ok {
153 | if mount.Writable != nil {
154 | mounts[i].Writable = mount.Writable
155 | }
156 | } else {
157 | location[mount.Location] = len(mounts)
158 | mounts = append(mounts, mount)
159 | }
160 | }
161 | y.Mounts = mounts
162 |
163 | for i := range y.Mounts {
164 | mount := &y.Mounts[i]
165 | if mount.Writable == nil {
166 | mount.Writable = pointer.Bool(false)
167 | }
168 | }
169 | }
170 |
171 | func NewArch(arch string) Arch {
172 | switch arch {
173 | case "amd64":
174 | return X8664
175 | case "arm64":
176 | return AARCH64
177 | default:
178 | logrus.Warnf("Unknown arch: %s", arch)
179 | return arch
180 | }
181 | }
182 |
183 | func ResolveArch() Arch {
184 | return NewArch(runtime.GOARCH)
185 | }
186 |
187 | func FillPortForwardDefaults(rule *PortForward, instDir string) {
188 | if rule.Proto == "" {
189 | rule.Proto = TCP
190 | }
191 | if rule.GuestIP == nil {
192 | if rule.GuestIPMustBeZero {
193 | rule.GuestIP = net.IPv4zero
194 | } else {
195 | rule.GuestIP = api.IPv4loopback1
196 | }
197 | }
198 | if rule.HostIP == nil {
199 | rule.HostIP = api.IPv4loopback1
200 | }
201 | if rule.GuestPortRange[0] == 0 && rule.GuestPortRange[1] == 0 {
202 | if rule.GuestPort == 0 {
203 | rule.GuestPortRange[0] = 1
204 | rule.GuestPortRange[1] = 65535
205 | } else {
206 | rule.GuestPortRange[0] = rule.GuestPort
207 | rule.GuestPortRange[1] = rule.GuestPort
208 | }
209 | }
210 | if rule.HostPortRange[0] == 0 && rule.HostPortRange[1] == 0 {
211 | if rule.HostPort == 0 {
212 | rule.HostPortRange = rule.GuestPortRange
213 | } else {
214 | rule.HostPortRange[0] = rule.HostPort
215 | rule.HostPortRange[1] = rule.HostPort
216 | }
217 | }
218 | if rule.GuestSocket != "" {
219 | tmpl, err := template.New("").Parse(rule.GuestSocket)
220 | if err == nil {
221 | user, _ := osutil.MacVZUser(false)
222 | data := map[string]string{
223 | "Home": fmt.Sprintf("/home/%s.linux", user.Username),
224 | "UID": user.Uid,
225 | "User": user.Username,
226 | }
227 | var out bytes.Buffer
228 | if err := tmpl.Execute(&out, data); err == nil {
229 | rule.GuestSocket = out.String()
230 | } else {
231 | logrus.WithError(err).Warnf("Couldn't process guestSocket %q as a template", rule.GuestSocket)
232 | }
233 | }
234 | }
235 | if rule.HostSocket != "" {
236 | tmpl, err := template.New("").Parse(rule.HostSocket)
237 | if err == nil {
238 | user, _ := osuser.Current()
239 | home, _ := os.UserHomeDir()
240 | data := map[string]string{
241 | "Dir": instDir,
242 | "Home": home,
243 | "Name": filepath.Base(instDir),
244 | "UID": user.Uid,
245 | "User": user.Username,
246 | }
247 | var out bytes.Buffer
248 | if err := tmpl.Execute(&out, data); err == nil {
249 | rule.HostSocket = out.String()
250 | } else {
251 | logrus.WithError(err).Warnf("Couldn't process hostSocket %q as a template", rule.HostSocket)
252 | }
253 | }
254 | if !filepath.IsAbs(rule.HostSocket) {
255 | rule.HostSocket = filepath.Join(instDir, filenames.SocketDir, rule.HostSocket)
256 | }
257 | }
258 | }
259 |
260 | func Cname(host string) string {
261 | host = strings.ToLower(host)
262 | if !strings.HasSuffix(host, ".") {
263 | host += "."
264 | }
265 | return host
266 | }
267 |
--------------------------------------------------------------------------------
/pkg/yaml/load.go:
--------------------------------------------------------------------------------
1 | package yaml
2 |
3 | import (
4 | "errors"
5 | "os"
6 | "path/filepath"
7 |
8 | "github.com/mac-vz/macvz/pkg/store/dirnames"
9 | "github.com/mac-vz/macvz/pkg/store/filenames"
10 | "github.com/sirupsen/logrus"
11 | "gopkg.in/yaml.v2"
12 | )
13 |
14 | // Load loads the yaml and fulfills unspecified fields with the default values.
15 | //
16 | // Load does not validate. Use Validate for validation.
17 | func Load(b []byte, filePath string) (*MacVZYaml, error) {
18 | var y, d, o MacVZYaml
19 |
20 | if err := yaml.Unmarshal(b, &y); err != nil {
21 | return nil, err
22 | }
23 | configDir, err := dirnames.MacVZConfigDir()
24 | if err != nil {
25 | return nil, err
26 | }
27 |
28 | defaultPath := filepath.Join(configDir, filenames.Default)
29 | bytes, err := os.ReadFile(defaultPath)
30 | if err == nil {
31 | logrus.Debugf("Mixing %q into %q", defaultPath, filePath)
32 | if err := yaml.Unmarshal(bytes, &d); err != nil {
33 | return nil, err
34 | }
35 | } else if !errors.Is(err, os.ErrNotExist) {
36 | return nil, err
37 | }
38 |
39 | overridePath := filepath.Join(configDir, filenames.Override)
40 | bytes, err = os.ReadFile(overridePath)
41 | if err == nil {
42 | logrus.Debugf("Mixing %q into %q", overridePath, filePath)
43 | if err := yaml.Unmarshal(bytes, &o); err != nil {
44 | return nil, err
45 | }
46 | } else if !errors.Is(err, os.ErrNotExist) {
47 | return nil, err
48 | }
49 |
50 | FillDefault(&y, &d, &o, filePath)
51 | return &y, nil
52 | }
53 |
--------------------------------------------------------------------------------
/pkg/yaml/template.go:
--------------------------------------------------------------------------------
1 | package yaml
2 |
3 | import (
4 | _ "embed"
5 | )
6 |
7 | //go:embed default.yaml
8 | var DefaultTemplate []byte
9 |
--------------------------------------------------------------------------------
/pkg/yaml/validate.go:
--------------------------------------------------------------------------------
1 | package yaml
2 |
3 | import (
4 | "fmt"
5 | "net"
6 | "os"
7 | "path/filepath"
8 | "strings"
9 |
10 | "errors"
11 |
12 | "github.com/docker/go-units"
13 | "github.com/mac-vz/macvz/pkg/osutil"
14 | "github.com/mitchellh/go-homedir"
15 | )
16 |
17 | func Validate(y MacVZYaml, warn bool) error {
18 |
19 | if len(y.Images) == 0 {
20 | return errors.New("field `images` must be set")
21 | }
22 | for i, f := range y.Images {
23 |
24 | if !strings.Contains(f.Kernel, "://") {
25 | if _, err := homedir.Expand(f.Kernel); err != nil {
26 | return fmt.Errorf("field `images[%d].kernel` refers to an invalid local file path: %q: %w", i, f.Kernel, err)
27 | }
28 | }
29 | if !strings.Contains(f.Base, "://") {
30 | if _, err := homedir.Expand(f.Base); err != nil {
31 | return fmt.Errorf("field `images[%d].base` refers to an invalid local file path: %q: %w", i, f.Base, err)
32 | }
33 | }
34 | if !strings.Contains(f.Initram, "://") {
35 | if _, err := homedir.Expand(f.Initram); err != nil {
36 | return fmt.Errorf("field `images[%d].initram` refers to an invalid local file path: %q: %w", i, f.Initram, err)
37 | }
38 | }
39 | switch f.Arch {
40 | case X8664, AARCH64:
41 | default:
42 | return fmt.Errorf("field `images.arch` must be %q or %q, got %q", X8664, AARCH64, f.Arch)
43 | }
44 | }
45 |
46 | if *y.CPUs == 0 {
47 | return errors.New("field `cpus` must be set")
48 | }
49 |
50 | if _, err := units.RAMInBytes(*y.Memory); err != nil {
51 | return fmt.Errorf("field `memory` has an invalid value: %w", err)
52 | }
53 |
54 | if _, err := units.RAMInBytes(*y.Disk); err != nil {
55 | return fmt.Errorf("field `memory` has an invalid value: %w", err)
56 | }
57 |
58 | u, err := osutil.MacVZUser(false)
59 | if err != nil {
60 | return fmt.Errorf("internal error (not an error of YAML): %w", err)
61 | }
62 | // reservedHome is the home directory defined in "cidata.iso:/user-data"
63 | reservedHome := fmt.Sprintf("/home/%s.linux", u.Username)
64 |
65 | for i, f := range y.Mounts {
66 | if !filepath.IsAbs(f.Location) && !strings.HasPrefix(f.Location, "~") {
67 | return fmt.Errorf("field `mounts[%d].location` must be an absolute path, got %q",
68 | i, f.Location)
69 | }
70 | loc, err := homedir.Expand(f.Location)
71 | if err != nil {
72 | return fmt.Errorf("field `mounts[%d].location` refers to an unexpandable path: %q: %w", i, f.Location, err)
73 | }
74 | switch loc {
75 | case "/", "/bin", "/dev", "/etc", "/home", "/opt", "/sbin", "/tmp", "/usr", "/var":
76 | return fmt.Errorf("field `mounts[%d].location` must not be a system path such as /etc or /usr", i)
77 | case reservedHome:
78 | return fmt.Errorf("field `mounts[%d].location` is internally reserved", i)
79 | }
80 |
81 | st, err := os.Stat(loc)
82 | if err != nil {
83 | if !errors.Is(err, os.ErrNotExist) {
84 | return fmt.Errorf("field `mounts[%d].location` refers to an inaccessible path: %q: %w", i, f.Location, err)
85 | }
86 | } else if !st.IsDir() {
87 | return fmt.Errorf("field `mounts[%d].location` refers to a non-directory path: %q: %w", i, f.Location, err)
88 | }
89 | }
90 |
91 | for i, p := range y.Provision {
92 | switch p.Mode {
93 | case ProvisionModeSystem, ProvisionModeUser:
94 | default:
95 | return fmt.Errorf("field `provision[%d].mode` must be either %q or %q",
96 | i, ProvisionModeSystem, ProvisionModeUser)
97 | }
98 | }
99 |
100 | for i, p := range y.Probes {
101 | switch p.Mode {
102 | case ProbeModeReadiness:
103 | default:
104 | return fmt.Errorf("field `probe[%d].mode` can only be %q",
105 | i, ProbeModeReadiness)
106 | }
107 | }
108 |
109 | for i, rule := range y.PortForwards {
110 | field := fmt.Sprintf("portForwards[%d]", i)
111 | if rule.GuestIPMustBeZero && !rule.GuestIP.Equal(net.IPv4zero) {
112 | return fmt.Errorf("field `%s.guestIPMustBeZero` can only be true when field `%s.guestIP` is 0.0.0.0", field, field)
113 | }
114 | if rule.GuestPort != 0 {
115 | if rule.GuestSocket != "" {
116 | return fmt.Errorf("field `%s.guestPort` must be 0 when field `%s.guestSocket` is set", field, field)
117 | }
118 | if rule.GuestPort != rule.GuestPortRange[0] {
119 | return fmt.Errorf("field `%s.guestPort` must match field `%s.guestPortRange[0]`", field, field)
120 | }
121 | // redundant validation to make sure the error contains the correct field name
122 | if err := validatePort(field+".guestPort", rule.GuestPort); err != nil {
123 | return err
124 | }
125 | }
126 | if rule.HostPort != 0 {
127 | if rule.HostSocket != "" {
128 | return fmt.Errorf("field `%s.hostPort` must be 0 when field `%s.hostSocket` is set", field, field)
129 | }
130 | if rule.HostPort != rule.HostPortRange[0] {
131 | return fmt.Errorf("field `%s.hostPort` must match field `%s.hostPortRange[0]`", field, field)
132 | }
133 | // redundant validation to make sure the error contains the correct field name
134 | if err := validatePort(field+".hostPort", rule.HostPort); err != nil {
135 | return err
136 | }
137 | }
138 | for j := 0; j < 2; j++ {
139 | if err := validatePort(fmt.Sprintf("%s.guestPortRange[%d]", field, j), rule.GuestPortRange[j]); err != nil {
140 | return err
141 | }
142 | if err := validatePort(fmt.Sprintf("%s.hostPortRange[%d]", field, j), rule.HostPortRange[j]); err != nil {
143 | return err
144 | }
145 | }
146 | if rule.GuestPortRange[0] > rule.GuestPortRange[1] {
147 | return fmt.Errorf("field `%s.guestPortRange[1]` must be greater than or equal to field `%s.guestPortRange[0]`", field, field)
148 | }
149 | if rule.HostPortRange[0] > rule.HostPortRange[1] {
150 | return fmt.Errorf("field `%s.hostPortRange[1]` must be greater than or equal to field `%s.hostPortRange[0]`", field, field)
151 | }
152 | if rule.GuestPortRange[1]-rule.GuestPortRange[0] != rule.HostPortRange[1]-rule.HostPortRange[0] {
153 | return fmt.Errorf("field `%s.hostPortRange` must specify the same number of ports as field `%s.guestPortRange`", field, field)
154 | }
155 | if rule.GuestSocket != "" {
156 | if !filepath.IsAbs(rule.GuestSocket) {
157 | return fmt.Errorf("field `%s.guestSocket` must be an absolute path", field)
158 | }
159 | if rule.HostSocket == "" && rule.HostPortRange[1]-rule.HostPortRange[0] > 0 {
160 | return fmt.Errorf("field `%s.guestSocket` can only be mapped to a single port or socket. not a range", field)
161 | }
162 | }
163 | if rule.HostSocket != "" {
164 | if !filepath.IsAbs(rule.HostSocket) {
165 | // should be unreachable because FillDefault() will prepend the instance directory to relative names
166 | return fmt.Errorf("field `%s.hostSocket` must be an absolute path, but is %q", field, rule.HostSocket)
167 | }
168 | if rule.GuestSocket == "" && rule.GuestPortRange[1]-rule.GuestPortRange[0] > 0 {
169 | return fmt.Errorf("field `%s.hostSocket` can only be mapped from a single port or socket. not a range", field)
170 | }
171 | }
172 | if len(rule.HostSocket) >= osutil.UnixPathMax {
173 | return fmt.Errorf("field `%s.hostSocket` must be less than UNIX_PATH_MAX=%d characters, but is %d",
174 | field, osutil.UnixPathMax, len(rule.HostSocket))
175 | }
176 | if rule.Proto != TCP {
177 | return fmt.Errorf("field `%s.proto` must be %q", field, TCP)
178 | }
179 | // Not validating that the various GuestPortRanges and HostPortRanges are not overlapping. Rules will be
180 | // processed sequentially and the first matching rule for a guest port determines forwarding behavior.
181 | }
182 |
183 | return nil
184 | }
185 |
186 | func validatePort(field string, port int) error {
187 | switch {
188 | case port < 0:
189 | return fmt.Errorf("field `%s` must be > 0", field)
190 | case port == 0:
191 | return fmt.Errorf("field `%s` must be set", field)
192 | case port == 22:
193 | return fmt.Errorf("field `%s` must not be 22", field)
194 | case port > 65535:
195 | return fmt.Errorf("field `%s` must be < 65536", field)
196 | }
197 | return nil
198 | }
199 |
--------------------------------------------------------------------------------
/pkg/yaml/yaml.go:
--------------------------------------------------------------------------------
1 | package yaml
2 |
3 | import "net"
4 |
5 | type MacVZYaml struct {
6 | Images []Image `yaml:"images" json:"images"` // REQUIRED
7 | CPUs *int `yaml:"cpus,omitempty" json:"cpus,omitempty"`
8 | Memory *string `yaml:"memory,omitempty" json:"memory,omitempty"` // go-units.RAMInBytes
9 | Disk *string `yaml:"disk,omitempty" json:"disk,omitempty"` // go-units.RAMInBytes
10 | Mounts []Mount `yaml:"mounts,omitempty" json:"mounts,omitempty"`
11 | MACAddress *string `yaml:"MACAddress,omitempty" json:"MACAddress,omitempty"`
12 |
13 | SSH SSH `yaml:"ssh,omitempty" json:"ssh,omitempty"` // REQUIRED
14 | PortForwards []PortForward `yaml:"portForwards,omitempty" json:"portForwards,omitempty"`
15 | Provision []Provision `yaml:"provision,omitempty" json:"provision,omitempty"`
16 | Probes []Probe `yaml:"probes,omitempty" json:"probes,omitempty"`
17 | HostResolver HostResolver `yaml:"hostResolver,omitempty" json:"hostResolver,omitempty"`
18 | }
19 |
20 | type Image struct {
21 | Kernel string `yaml:"kernel" json:"kernel"` // REQUIRED
22 | Initram string `yaml:"initram" json:"initram"` // REQUIRED
23 | Base string `yaml:"base" json:"base"` // REQUIRED
24 | Arch Arch `yaml:"arch,omitempty" json:"arch,omitempty"`
25 | }
26 |
27 | type Arch = string
28 |
29 | const (
30 | X8664 Arch = "x86_64"
31 | AARCH64 Arch = "aarch64"
32 | )
33 |
34 | type Mount struct {
35 | Location string `yaml:"location" json:"location"` // REQUIRED
36 | Writable *bool `yaml:"writable,omitempty" json:"writable,omitempty"`
37 | }
38 |
39 | type ProvisionMode = string
40 |
41 | const (
42 | ProvisionModeSystem ProvisionMode = "system"
43 | ProvisionModeUser ProvisionMode = "user"
44 | )
45 |
46 | type Provision struct {
47 | Mode ProvisionMode `yaml:"mode" json:"mode"` // default: "system"
48 | Script string `yaml:"script" json:"script"`
49 | }
50 |
51 | type ProbeMode = string
52 |
53 | const (
54 | ProbeModeReadiness ProbeMode = "readiness"
55 | )
56 |
57 | type Probe struct {
58 | Mode ProbeMode // default: "readiness"
59 | Description string
60 | Script string
61 | Hint string
62 | }
63 |
64 | type SSH struct {
65 | LocalPort *int `yaml:"localPort,omitempty" json:"localPort,omitempty"`
66 |
67 | // LoadDotSSHPubKeys loads ~/.ssh/*.pub in addition to $MACVZ_HOME/_config/user.pub .
68 | LoadDotSSHPubKeys *bool `yaml:"loadDotSSHPubKeys,omitempty" json:"loadDotSSHPubKeys,omitempty"` // default: true
69 | ForwardAgent *bool `yaml:"forwardAgent,omitempty" json:"forwardAgent,omitempty"` // default: false
70 | }
71 |
72 | type Proto = string
73 |
74 | const (
75 | TCP Proto = "tcp"
76 | )
77 |
78 | type PortForward struct {
79 | GuestIPMustBeZero bool `yaml:"guestIPMustBeZero,omitempty" json:"guestIPMustBeZero,omitempty"`
80 | GuestIP net.IP `yaml:"guestIP,omitempty" json:"guestIP,omitempty"`
81 | GuestPort int `yaml:"guestPort,omitempty" json:"guestPort,omitempty"`
82 | GuestPortRange [2]int `yaml:"guestPortRange,omitempty" json:"guestPortRange,omitempty"`
83 | GuestSocket string `yaml:"guestSocket,omitempty" json:"guestSocket,omitempty"`
84 | HostIP net.IP `yaml:"hostIP,omitempty" json:"hostIP,omitempty"`
85 | HostPort int `yaml:"hostPort,omitempty" json:"hostPort,omitempty"`
86 | HostPortRange [2]int `yaml:"hostPortRange,omitempty" json:"hostPortRange,omitempty"`
87 | HostSocket string `yaml:"hostSocket,omitempty" json:"hostSocket,omitempty"`
88 | Proto Proto `yaml:"proto,omitempty" json:"proto,omitempty"`
89 | Ignore bool `yaml:"ignore,omitempty" json:"ignore,omitempty"`
90 | }
91 |
92 | type HostResolver struct {
93 | Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
94 | IPv6 *bool `yaml:"ipv6,omitempty" json:"ipv6,omitempty"`
95 | Hosts map[string]string `yaml:"hosts,omitempty" json:"hosts,omitempty"`
96 | }
97 |
--------------------------------------------------------------------------------
/vz.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.network.server
6 |
7 | com.apple.security.network.client
8 |
9 | com.apple.security.virtualization
10 |
11 |
12 |
--------------------------------------------------------------------------------