├── .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 | [![Go Report Card](https://goreportcard.com/badge/mac-vz/macvz)](https://goreportcard.com/report/github.com/mac-vz/macvz) 2 | [![Codacy grade](https://img.shields.io/codacy/grade/40eae50295114eabba6b12b7372bed81?&logo=codacy)](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 | [![GitHub](https://img.shields.io/github/license/mac-vz/macvz?color=brightgreen)](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 | --------------------------------------------------------------------------------