├── bin ├── .editorconfig ├── .goreleaser.yaml ├── .github └── workflows │ ├── trufflehog.yaml │ └── main.yml ├── internal ├── scripts │ ├── bin │ │ ├── hackenv_removebridge │ │ ├── hackenv_applylabels │ │ └── hackenv_createbridge │ └── vars.go ├── constants │ └── constants.go ├── logging │ └── logging.go ├── images │ ├── checksums.go │ ├── generic_test.go │ ├── kali.go │ ├── parrot.go │ ├── generic.go │ ├── images.go │ └── keycodes.go ├── commands │ ├── version.go │ ├── down.go │ ├── status.go │ ├── fix.go │ ├── ssh.go │ ├── gui.go │ ├── get.go │ └── up.go ├── options │ └── options.go ├── banner │ └── banner.go ├── network │ └── http.go ├── handling │ └── handling.go ├── paths │ └── paths.go ├── libvirt │ └── libvirt.go └── host │ └── host.go ├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── LICENSE ├── go.mod ├── cmd └── hackenv │ └── main.go ├── Makefile ├── README.md └── go.sum /bin: -------------------------------------------------------------------------------- 1 | internal/scripts/bin -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = tab 8 | trim_trailing_whitespace = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | 13 | [Makefile] 14 | indent_style = tab 15 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | version: 2 3 | 4 | builds: 5 | - id: hackenv 6 | main: ./cmd/hackenv 7 | goos: 8 | - linux 9 | goarch: 10 | - amd64 11 | 12 | checksum: 13 | algorithm: sha256 14 | 15 | archives: 16 | - id: hackenv 17 | ids: 18 | - hackenv 19 | formats: 20 | - tar.gz 21 | -------------------------------------------------------------------------------- /.github/workflows/trufflehog.yaml: -------------------------------------------------------------------------------- 1 | name: TruffleHog 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | scan-secrets: 11 | name: Scan for secrets 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v6 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Run TruffleHog 20 | uses: trufflesecurity/trufflehog@main 21 | with: 22 | extra_args: --results=verified,unknown 23 | -------------------------------------------------------------------------------- /internal/scripts/bin/hackenv_removebridge: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o errtrace 5 | 6 | readonly networkname='default' 7 | 8 | if_network_exists() { 9 | sudo virsh net-list --all --name | grep "^$networkname\$" > /dev/null 10 | } 11 | 12 | remove_network() { 13 | printf "Removing existing network...\n" >&2 14 | sudo virsh net-destroy "$networkname" 15 | sudo virsh net-undefine "$networkname" 16 | } 17 | 18 | if_network_exists && 19 | remove_network || 20 | printf "Network does not exist\n" >&2 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | out/ 3 | 4 | # Configuration files for coding agents 5 | .claude/ 6 | .clinerules/ 7 | .kilocode/ 8 | .roorules/ 9 | AGENTS.md 10 | CLAUDE.md 11 | GEMINI.md 12 | 13 | ### Go 14 | # Binaries for programs and plugins 15 | *.exe 16 | *.exe~ 17 | *.dll 18 | *.so 19 | *.dylib 20 | 21 | # Test binary, built with `go test -c` 22 | *.test 23 | 24 | # Output of the go coverage tool, specifically when used with LiteIDE 25 | *.out 26 | 27 | # Dependency directories (remove the comment below to include it) 28 | # vendor/ 29 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${workspaceFolder}/cmd/hackenv", 13 | "cwd": "${workspaceFolder}", 14 | "args": [ 15 | "version" 16 | ] 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /internal/constants/constants.go: -------------------------------------------------------------------------------- 1 | // Package constants contains global constants. 2 | package constants 3 | 4 | const ( 5 | // XdgAppname is the name of this tool for XDG purposes. 6 | XdgAppname = "hackenv" 7 | 8 | // ConnectURI is the URI where the QEMU instance is made available. 9 | ConnectURI = "qemu:///session" 10 | 11 | // SSHKeypairName is the name of the SSH keypair used to connect to the guest. 12 | SSHKeypairName = "sshkey" 13 | 14 | // PostbootFile is the filename of the script used to populate the guest. 15 | PostbootFile = "postboot.sh" 16 | ) 17 | -------------------------------------------------------------------------------- /internal/logging/logging.go: -------------------------------------------------------------------------------- 1 | // Package logging provides utilities for configuring and managing application logging. 2 | package logging 3 | 4 | import ( 5 | "log/slog" 6 | "os" 7 | ) 8 | 9 | // Setup configures the global slog logger based on the verbose flag. 10 | func Setup(verbose bool) { 11 | logLevel := slog.LevelInfo 12 | if verbose { 13 | logLevel = slog.LevelDebug 14 | } 15 | handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}) 16 | slog.SetDefault(slog.New(handler)) 17 | 18 | if verbose { 19 | slog.Info("Verbose logging enabled", "level", logLevel) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /internal/images/checksums.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | // parseChecksumLine extracts checksum information from a line 10 | func parseChecksumLine(line string, versionRegex *regexp.Regexp) (*DownloadInfo, error) { 11 | parts := strings.Fields(line) 12 | if len(parts) < 2 { 13 | return nil, fmt.Errorf("encountered invalid checksum line format: %s", line) 14 | } 15 | 16 | checksum := parts[0] 17 | filename := parts[len(parts)-1] 18 | 19 | return &DownloadInfo{ 20 | Checksum: checksum, 21 | Version: versionRegex.FindString(filename), 22 | Filename: filename, 23 | }, nil 24 | } 25 | -------------------------------------------------------------------------------- /internal/commands/version.go: -------------------------------------------------------------------------------- 1 | // Package commands contains functions that are exposed as dedicated commands of the tool. 2 | package commands 3 | 4 | import ( 5 | "fmt" 6 | "runtime/debug" 7 | 8 | "github.com/eikendev/hackenv/internal/options" 9 | ) 10 | 11 | // VersionCommand represents the options specific to the version command. 12 | type VersionCommand struct{} 13 | 14 | // Run is the function for the version command. 15 | func (*VersionCommand) Run(_ *options.Options) error { 16 | buildInfo, ok := debug.ReadBuildInfo() 17 | if !ok { 18 | return fmt.Errorf("cannot read build info") 19 | } 20 | 21 | fmt.Printf("hackenv %s\n", buildInfo.Main.Version) 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /internal/scripts/vars.go: -------------------------------------------------------------------------------- 1 | // Package scripts contains scripts that can be run from within the tool. 2 | package scripts 3 | 4 | import ( 5 | _ "embed" 6 | ) 7 | 8 | // CreateBridgeScript is a script that creates the bridge for network communication. 9 | // 10 | //go:embed bin/hackenv_createbridge 11 | var CreateBridgeScript string 12 | 13 | // RemoveBridgeScript is a script that removes the bridge for network communication. 14 | // 15 | //go:embed bin/hackenv_removebridge 16 | var RemoveBridgeScript string 17 | 18 | // ApplyLabelsScript is a script that applies necessary SELinux labels to images and shared folder. 19 | // 20 | //go:embed bin/hackenv_applylabels 21 | var ApplyLabelsScript string 22 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "type": "shell", 7 | "command": "make build", 8 | "presentation": { 9 | "panel": "shared", 10 | "reveal": "always", 11 | "focus": true 12 | }, 13 | "problemMatcher": [], 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | }, 19 | { 20 | "label": "test", 21 | "type": "shell", 22 | "command": "make test", 23 | "presentation": { 24 | "panel": "shared", 25 | "reveal": "always", 26 | "focus": true 27 | }, 28 | "problemMatcher": [], 29 | "group": { 30 | "kind": "test", 31 | "isDefault": true 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License (ISC) 2 | 3 | Copyright 2021-2022 eikendev 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 8 | -------------------------------------------------------------------------------- /internal/options/options.go: -------------------------------------------------------------------------------- 1 | // Package options defines the global options of this tool. 2 | package options 3 | 4 | // Options represents the global options of this tool. 5 | type Options struct { 6 | Type string `name:"type" env:"HACKENV_TYPE" default:"kali" enum:"kali,parrot" help:"The VM to control with this command"` 7 | Keymap string `name:"keymap" env:"HACKENV_KEYMAP" default:"" help:"The keyboard keymap to force"` 8 | Provision bool `name:"provision" env:"HACKENV_PROVISION" help:"Provision the VM"` 9 | Verbose bool `name:"verbose" short:"v" help:"Verbose mode"` 10 | } 11 | 12 | // Runnable defines an interface for subcommands that take the global settings and a password. 13 | type Runnable interface { 14 | Run(*Options) error 15 | } 16 | -------------------------------------------------------------------------------- /internal/banner/banner.go: -------------------------------------------------------------------------------- 1 | // Package banner provides functionality related to the banner printed on start. 2 | package banner 3 | 4 | import ( 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | 9 | "github.com/fatih/color" 10 | ) 11 | 12 | const banner = ` 13 | __ __ 14 | / /_ ____ ______/ /_____ ____ _ __ 15 | / __ \/ __ ` + "`" + `/ ___/ //_/ _ \/ __ \ | / / 16 | / / / / /_/ / /__/ ,< / __/ / / / |/ / 17 | /_/ /_/\__,_/\___/_/|_|\___/_/ /_/|___/ 18 | ` 19 | 20 | // PrintBanner prints the tool's banner. 21 | func PrintBanner() { 22 | fmt.Fprint(os.Stderr, banner) 23 | fmt.Fprintln(os.Stderr, "") 24 | 25 | _, err := color.New(color.FgBlue).Fprintln(os.Stderr, " @eikendev") 26 | if err != nil { 27 | slog.Warn("Cannot print banner", "err", err) 28 | } 29 | 30 | fmt.Fprintln(os.Stderr, "") 31 | } 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/eikendev/hackenv 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/adrg/xdg v0.5.3 7 | github.com/alecthomas/kong v1.13.0 8 | github.com/fatih/color v1.18.0 9 | github.com/melbahja/goph v1.4.0 10 | github.com/schollz/progressbar/v3 v3.18.0 11 | libvirt.org/go/libvirt v1.11006.0 12 | ) 13 | 14 | require ( 15 | github.com/kr/fs v0.1.0 // indirect 16 | github.com/mattn/go-colorable v0.1.14 // indirect 17 | github.com/mattn/go-isatty v0.0.20 // indirect 18 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 19 | github.com/pkg/errors v0.9.1 // indirect 20 | github.com/pkg/sftp v1.13.10 // indirect 21 | github.com/rivo/uniseg v0.4.7 // indirect 22 | golang.org/x/crypto v0.45.0 // indirect 23 | golang.org/x/sys v0.38.0 // indirect 24 | golang.org/x/term v0.37.0 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /internal/network/http.go: -------------------------------------------------------------------------------- 1 | // Package network provides HTTP client functionality. 2 | package network 3 | 4 | import ( 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | ) 9 | 10 | // GetResponse performs an HTTP GET request and returns the response with proper error handling 11 | func GetResponse(url string) (*http.Response, error) { 12 | resp, err := http.Get(url) //#nosec G107 13 | if err != nil { 14 | return nil, fmt.Errorf("failed to perform HTTP GET %s: %w", url, err) 15 | } 16 | if resp == nil { 17 | return nil, fmt.Errorf("received nil HTTP response from %s", url) 18 | } 19 | 20 | if resp.StatusCode != http.StatusOK { 21 | err = resp.Body.Close() 22 | if err != nil { 23 | slog.Warn("Failed to close response body", "err", err, "url", url) 24 | } 25 | 26 | return nil, fmt.Errorf("received bad HTTP status code (%s)", resp.Status) 27 | } 28 | 29 | return resp, nil 30 | } 31 | -------------------------------------------------------------------------------- /internal/scripts/bin/hackenv_applylabels: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o errtrace 5 | 6 | datahome="${XDG_DATA_HOME:-$HOME/.local/share}" 7 | datahome="$datahome/hackenv" 8 | 9 | shared="$(realpath "$datahome/shared")" 10 | 11 | printf "Applying SELinux labels...\n" >&2 12 | 13 | if ! command -v semanage &> /dev/null; then 14 | printf "SELinux coreutils binaries could not be found.\n" >&2 15 | if command -v yum &> /dev/null; then 16 | printf "Try 'sudo yum -y install policycoreutils-python-utils'\nthen try again." >&2 17 | else 18 | printf "Exiting.\n" >&2 && exit 0 19 | fi 20 | fi 21 | 22 | printf "Setting permissions for shared directory...\n" >&2 23 | 24 | mkdir -p "$shared" 25 | chmod 770 "$shared" 26 | sudo semanage fcontext "$shared(/.*)?" --deleteall 27 | sudo semanage fcontext -a -t svirt_image_t "$shared(/.*)?" 28 | sudo restorecon -vrF "$shared" 29 | 30 | find "$datahome" -maxdepth 1 -type f -name '*.iso' | while read -r image; do 31 | if ! [ -f "$image" ]; then 32 | continue 33 | fi 34 | 35 | printf "Setting permissions for image %s...\n" "$(basename "$image")" >&2 36 | 37 | sudo semanage fcontext "$image" --deleteall 38 | sudo semanage fcontext -a -t svirt_image_t "$image" 39 | sudo restorecon -vF "$image" 40 | done 41 | -------------------------------------------------------------------------------- /internal/handling/handling.go: -------------------------------------------------------------------------------- 1 | // Package handling provides convenience functions for cleaning up resources. 2 | package handling 3 | 4 | import ( 5 | "io" 6 | "log/slog" 7 | "os" 8 | 9 | rawLibvirt "libvirt.org/go/libvirt" 10 | ) 11 | 12 | // Close closes an io resource and prints a warning if that fails. 13 | func Close(c io.Closer) { 14 | if c == nil { 15 | return 16 | } 17 | if err := c.Close(); err != nil { 18 | slog.Warn("Failed to close resource", "err", err) 19 | } 20 | } 21 | 22 | // CloseConnect closes a libvirt connections and prints a warning if that fails. 23 | func CloseConnect(c *rawLibvirt.Connect) { 24 | if c == nil { 25 | return 26 | } 27 | if _, err := c.Close(); err != nil { 28 | slog.Warn("Failed to close libvirt connection", "err", err) 29 | } 30 | } 31 | 32 | // FreeDomain frees a libvirt domain and prints a warning if that fails. 33 | func FreeDomain(d *rawLibvirt.Domain) { 34 | if d == nil { 35 | return 36 | } 37 | if err := d.Free(); err != nil { 38 | slog.Warn("Failed to free libvirt domain", "err", err) 39 | } 40 | } 41 | 42 | // ReleaseProcess releases process information and prints a warning if that fails. 43 | func ReleaseProcess(p *os.Process) { 44 | if p == nil { 45 | return 46 | } 47 | if err := p.Release(); err != nil { 48 | slog.Warn("Failed to release process", "err", err) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | tags: 8 | - 'v[0-9]+.[0-9]+.[0-9]+' 9 | pull_request: 10 | branches: 11 | - 'main' 12 | 13 | permissions: 14 | contents: write 15 | 16 | env: 17 | GO_VERSION: '1.25.4' 18 | 19 | jobs: 20 | test_publish: 21 | name: Test and publish 22 | runs-on: ubuntu-latest 23 | timeout-minutes: 10 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@v6 27 | with: 28 | fetch-depth: 0 # Needed to describe git ref during build. 29 | 30 | - name: Install libvirt 31 | run: | 32 | sudo apt-get update 33 | sudo apt-get install -y libvirt-dev 34 | 35 | - name: Export GOBIN 36 | uses: actions/setup-go@v6 37 | with: 38 | go-version: '${{env.GO_VERSION}}' 39 | 40 | - name: Install dependencies 41 | run: make setup 42 | 43 | - name: Run tests 44 | run: make test 45 | 46 | - name: Build 47 | run: make build 48 | 49 | - name: Run GoReleaser 50 | uses: goreleaser/goreleaser-action@v6 51 | if: startsWith(github.ref, 'refs/tags/v') # Only release for tagged commits. 52 | with: 53 | distribution: goreleaser 54 | version: latest 55 | args: release --clean 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | -------------------------------------------------------------------------------- /cmd/hackenv/main.go: -------------------------------------------------------------------------------- 1 | // Package main provides the main function as a starting point of this tool. 2 | package main 3 | 4 | import ( 5 | "github.com/alecthomas/kong" 6 | 7 | "github.com/eikendev/hackenv/internal/commands" 8 | "github.com/eikendev/hackenv/internal/logging" 9 | "github.com/eikendev/hackenv/internal/options" 10 | ) 11 | 12 | var cmd struct { 13 | options.Options 14 | Down commands.DownCommand `cmd:"down" aliases:"d" help:"Shut down the VM"` 15 | Get commands.GetCommand `cmd:"get" help:"Download the VM image"` 16 | GUI commands.GuiCommand `cmd:"gui" aliases:"g" help:"Open a GUI for the VM"` 17 | SSH commands.SSHCommand `cmd:"ssh" aliases:"s" help:"Open an SSH session for the VM"` 18 | Status commands.StatusCommand `cmd:"status" help:"Print the status of the VM"` 19 | Up commands.UpCommand `cmd:"up" aliases:"u" help:"Initialize and start the VM or provision if already running"` 20 | Fix commands.FixCommand `cmd:"fix" aliases:"f" help:"Fix helpers: manage bridge and apply SELinux labels"` 21 | Version commands.VersionCommand `cmd:"version" help:"Print the version of hackenv"` 22 | } 23 | 24 | func main() { 25 | kctx := kong.Parse(&cmd, 26 | kong.Description("hackenv provisions and manages preconfigured VMs for security research."), 27 | kong.UsageOnError(), 28 | kong.Bind(&cmd.Options), 29 | ) 30 | 31 | logging.Setup(cmd.Verbose) 32 | 33 | err := kctx.Run() 34 | kctx.FatalIfErrorf(err) 35 | } 36 | -------------------------------------------------------------------------------- /internal/commands/down.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | "github.com/eikendev/hackenv/internal/handling" 8 | "github.com/eikendev/hackenv/internal/images" 9 | "github.com/eikendev/hackenv/internal/libvirt" 10 | "github.com/eikendev/hackenv/internal/options" 11 | ) 12 | 13 | // DownCommand represents the options specific to the down command. 14 | type DownCommand struct{} 15 | 16 | // Run is the function for the down command. 17 | func (c *DownCommand) Run(s *options.Options) error { 18 | image, err := images.GetImageDetails(s.Type) 19 | if err != nil { 20 | slog.Error("Failed to get image details for down command", "type", s.Type, "err", err) 21 | return fmt.Errorf("cannot resolve image details for %q: %w", s.Type, err) 22 | } 23 | 24 | conn, err := libvirt.Connect() 25 | if err != nil { 26 | slog.Error("Failed to connect to libvirt for down command", "err", err) 27 | return fmt.Errorf("cannot connect to libvirt: %w", err) 28 | } 29 | defer handling.CloseConnect(conn) 30 | 31 | dom, err := libvirt.GetDomain(conn, &image, true) 32 | if err != nil { 33 | slog.Error("Failed to lookup domain for down command", "image", image.Name, "err", err) 34 | return fmt.Errorf("cannot look up domain %q: %w", image.Name, err) 35 | } 36 | if dom != nil { 37 | defer handling.FreeDomain(dom) 38 | } 39 | 40 | if err := dom.Destroy(); err != nil { 41 | slog.Error("Cannot destroy domain", "err", err) 42 | return fmt.Errorf("cannot destroy domain %q: %w", image.Name, err) 43 | } 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/paths/paths.go: -------------------------------------------------------------------------------- 1 | // Package paths provides convenience functions related to the file system. 2 | package paths 3 | 4 | import ( 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | 11 | "github.com/adrg/xdg" 12 | 13 | "github.com/eikendev/hackenv/internal/constants" 14 | ) 15 | 16 | // GetDataFilePath returns a file from the XDG data directory. 17 | func GetDataFilePath(file string) (string, error) { 18 | path, err := xdg.DataFile(filepath.Join(constants.XdgAppname, file)) 19 | if err != nil { 20 | slog.Error("Cannot access data directory", "file", file, "err", err) 21 | return "", fmt.Errorf("cannot resolve data file %q: %w", file, err) 22 | } 23 | 24 | return path, nil 25 | } 26 | 27 | // EnsureDirExists creates the given directory if it does not exists. 28 | func EnsureDirExists(path string) error { 29 | err := os.MkdirAll(path, 0o750) 30 | if err != nil { 31 | slog.Error("Cannot create directory", "path", path, "err", err) 32 | return fmt.Errorf("cannot create directory %q: %w", path, err) 33 | } 34 | return nil 35 | } 36 | 37 | // DoesPostbootExist returns true if the postboot file exists, otherwise false. 38 | func DoesPostbootExist(path string) bool { 39 | path = fmt.Sprintf("%s/%s", path, constants.PostbootFile) 40 | if _, err := os.Stat(path); os.IsNotExist(err) { 41 | slog.Info("Postboot file does not exist", "path", path) 42 | return false 43 | } 44 | return true 45 | } 46 | 47 | // GetCmdPath returns the given executable from the PATH, otherwise an error. 48 | func GetCmdPath(cmd string) (string, error) { 49 | return exec.LookPath(cmd) 50 | } 51 | -------------------------------------------------------------------------------- /internal/images/generic_test.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import "testing" 4 | 5 | type compareVersionTest struct { 6 | v1 string 7 | v2 string 8 | out bool 9 | } 10 | 11 | var compareVersionsLtTest = []compareVersionTest{ 12 | {"1.0.0", "1.0.0", false}, 13 | {"1.0.0", "1.0.1", true}, 14 | {"0.0.1", "0.1.1", true}, 15 | {"1.0.1", "0.1.1", false}, 16 | {"1.0.1", "1.0.12", true}, 17 | {"1.0.12", "1.0.1", false}, 18 | {"1.0.12", "1.0.2", false}, 19 | } 20 | 21 | var compareVersionsEqTest = []compareVersionTest{ 22 | {"1.0.0", "1.0.0", true}, 23 | {"1.0.0", "1.0.1", false}, 24 | } 25 | 26 | var compareVersionsGtTest = []compareVersionTest{ 27 | {"1.0.0", "1.0.0", false}, 28 | {"1.0.0", "1.0.1", false}, 29 | {"1.0.1", "1.0.0", true}, 30 | } 31 | 32 | func TestCompareVersions(t *testing.T) { 33 | vc := getGenericVersionComparer() 34 | 35 | for _, test := range compareVersionsLtTest { 36 | if outLt := vc.Lt(test.v1, test.v2); outLt != test.out { 37 | t.Errorf("Incorrectly determined if %s < %s", test.v1, test.v2) 38 | } 39 | } 40 | 41 | for _, test := range compareVersionsEqTest { 42 | if outEq := vc.Eq(test.v1, test.v2); outEq && !test.out { 43 | t.Errorf("Determined versions %s and %s to be equal but they are not", test.v1, test.v2) 44 | } else if !outEq && test.out { 45 | t.Errorf("Determined versions %s and %s not to be equal but they are", test.v1, test.v2) 46 | } 47 | } 48 | 49 | for _, test := range compareVersionsGtTest { 50 | if outGt := vc.Gt(test.v1, test.v2); outGt != test.out { 51 | t.Errorf("Incorrectly determined if %s > %s", test.v1, test.v2) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/scripts/bin/hackenv_createbridge: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o errtrace 5 | 6 | datahome="${XDG_DATA_HOME:-$HOME/.local/share}" 7 | readonly datahome="$datahome/hackenv" 8 | 9 | readonly networkname='default' 10 | readonly interfacename='virbr0' 11 | 12 | if_network_exists() { 13 | sudo virsh net-list --all --name | grep "^$networkname\$" > /dev/null 14 | } 15 | 16 | # https://stackoverflow.com/a/52814732 17 | create_network() { 18 | printf "Creating XML network description...\n" >&2 19 | 20 | cat <<- EOF > "$datahome/network.xml" 21 | 22 | $networkname 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | EOF 32 | 33 | printf "Define the network...\n" >&2 34 | sudo virsh net-define "$datahome/network.xml" 35 | 36 | printf "Start the network...\n" >&2 37 | sudo virsh net-start "$networkname" 38 | 39 | printf "Flag the network to automatically start on boot...\n" >&2 40 | sudo virsh net-autostart "$networkname" 41 | 42 | if ! grep -q "allow $interfacename"; then 43 | sudo mkdir -p /etc/qemu 44 | echo "allow $interfacename" | sudo tee -a /etc/qemu/bridge.conf 45 | sudo chown -R :kvm /etc/qemu 46 | sudo chmod 640 /etc/qemu/bridge.conf 47 | fi 48 | } 49 | 50 | if_network_exists && 51 | printf "Network already exists; exiting...\n" >&2 && 52 | exit 0 53 | 54 | create_network 55 | 56 | printf "Verify that the network was created... " >&2 57 | if_network_exists && 58 | printf "success" >&2 || 59 | printf "failure" >&2 60 | printf "!\n" >&2 61 | -------------------------------------------------------------------------------- /internal/commands/status.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | "github.com/eikendev/hackenv/internal/handling" 8 | "github.com/eikendev/hackenv/internal/images" 9 | "github.com/eikendev/hackenv/internal/libvirt" 10 | "github.com/eikendev/hackenv/internal/options" 11 | ) 12 | 13 | // StatusCommand represents the options specific to the status command. 14 | type StatusCommand struct{} 15 | 16 | // Run is the function for the status command. 17 | func (*StatusCommand) Run(_ *options.Options) error { 18 | conn, err := libvirt.Connect() 19 | if err != nil { 20 | slog.Error("Failed to connect to libvirt for status command", "err", err) 21 | return fmt.Errorf("cannot connect to libvirt: %w", err) 22 | } 23 | defer handling.CloseConnect(conn) 24 | 25 | for _, image := range images.GetAllImages() { 26 | var state string 27 | 28 | image := image 29 | dom, err := libvirt.GetDomain(conn, &image, false) 30 | if err != nil { 31 | slog.Error("Failed to lookup domain for status command", "image", image.Name, "err", err) 32 | return fmt.Errorf("cannot look up domain %q: %w", image.Name, err) 33 | } 34 | if dom == nil { 35 | state = "DOWN" 36 | } else { 37 | defer handling.FreeDomain(dom) 38 | 39 | info, err := dom.GetInfo() 40 | if err != nil { 41 | slog.Warn("Cannot get domain info", "err", err, "domain", image.Name) 42 | continue 43 | } 44 | 45 | state, err = libvirt.ResolveDomainState(info.State) 46 | if err != nil { 47 | slog.Error("Failed to resolve domain state", "image", image.Name, "err", err) 48 | return fmt.Errorf("cannot resolve domain state for %q: %w", image.Name, err) 49 | } 50 | } 51 | 52 | fmt.Printf("%s\t%s\n", image.Name, state) 53 | } 54 | 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | OUT_DIR := ./out 2 | GO_FILES := $(shell find . -type f \( -iname '*.go' \)) 3 | GO_MODULE := github.com/eikendev/hackenv 4 | 5 | SCRIPT_FILES := $(wildcard ./bin/*) 6 | 7 | .PHONY: build 8 | build: 9 | mkdir -p $(OUT_DIR) 10 | go build -ldflags "-w -s" -o $(OUT_DIR)/hackenv ./cmd/hackenv 11 | 12 | .PHONY: clean 13 | clean: 14 | rm -rf $(OUT_DIR) 15 | 16 | .PHONY: test 17 | test: lint_scripts 18 | if [ -n "$$(gofumpt -l $(GO_FILES))" ]; then echo "Code is not properly formatted"; exit 1; fi 19 | if [ -n "$$(goimports -l -local $(GO_MODULE) $(GO_FILES))" ]; then echo "Imports are not properly formatted"; exit 1; fi 20 | go vet ./... 21 | misspell -error $(GO_FILES) 22 | gocyclo -over 10 $(GO_FILES) 23 | staticcheck ./... 24 | errcheck -ignoregenerated ./... 25 | gocritic check -disable='#experimental,#opinionated' -@ifElseChain.minThreshold 3 ./... 26 | revive -set_exit_status ./... 27 | nilaway ./... 28 | go test -v -cover ./... 29 | gosec -exclude-generated ./... 30 | govulncheck ./... 31 | @printf '\n%s\n' "> Test successful" 32 | 33 | .PHONY: setup 34 | setup: 35 | go install github.com/client9/misspell/cmd/misspell@latest 36 | go install github.com/fzipp/gocyclo/cmd/gocyclo@latest 37 | go install github.com/go-critic/go-critic/cmd/gocritic@latest 38 | go install github.com/kisielk/errcheck@latest 39 | go install github.com/mgechev/revive@latest 40 | go install github.com/securego/gosec/v2/cmd/gosec@latest 41 | go install go.uber.org/nilaway/cmd/nilaway@latest 42 | go install golang.org/x/tools/cmd/goimports@latest 43 | go install golang.org/x/vuln/cmd/govulncheck@latest 44 | go install honnef.co/go/tools/cmd/staticcheck@latest 45 | go install mvdan.cc/gofumpt@latest 46 | 47 | .PHONY: fmt 48 | fmt: 49 | gofumpt -l -w . 50 | 51 | .PHONY: lint_scripts 52 | lint_scripts: 53 | shellcheck ${SCRIPT_FILES} 54 | 55 | .PHONY: install_scripts 56 | install_scripts: 57 | ln -i -s -r ${SCRIPT_FILES} ${HOME}/bin/ 58 | -------------------------------------------------------------------------------- /internal/commands/fix.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os/exec" 7 | "strings" 8 | 9 | "github.com/eikendev/hackenv/internal/options" 10 | "github.com/eikendev/hackenv/internal/scripts" 11 | ) 12 | 13 | // FixCommand represents the options specific to the fix command. 14 | type FixCommand struct { 15 | CreateBridge createBridge `cmd:"create-bridge" aliases:"c" help:"Create bridge"` 16 | RemoveBridge removeBridge `cmd:"remove-bridge" aliases:"r" help:"Remove bridge"` 17 | ApplyLabels applyLabels `cmd:"apply-labels" aliases:"l" help:"Apply SELinux labels"` 18 | All all `cmd:"all" aliases:"a" help:"Create bridge and apply SELinux labels"` 19 | } 20 | 21 | type createBridge struct{} 22 | 23 | // Run is the function for the run command. 24 | func (c *createBridge) Run(s *options.Options) error { 25 | return execCommand([]string{scripts.CreateBridgeScript}, s.Verbose) 26 | } 27 | 28 | type removeBridge struct{} 29 | 30 | func (c *removeBridge) Run(s *options.Options) error { 31 | return execCommand([]string{scripts.RemoveBridgeScript}, s.Verbose) 32 | } 33 | 34 | type applyLabels struct{} 35 | 36 | func (c *applyLabels) Run(s *options.Options) error { 37 | return execCommand([]string{scripts.ApplyLabelsScript}, s.Verbose) 38 | } 39 | 40 | type all struct{} 41 | 42 | func (c *all) Run(s *options.Options) error { 43 | return execCommand([]string{scripts.CreateBridgeScript, scripts.ApplyLabelsScript}, s.Verbose) 44 | } 45 | 46 | func execCommand(scripts []string, verbose bool) error { 47 | for i, script := range scripts { 48 | cmd := exec.Command("bash") 49 | slog.Info("Running script", "index", i+1, "total", len(scripts)) 50 | cmd.Stdin = strings.NewReader(script) 51 | b, err := cmd.CombinedOutput() 52 | if verbose { 53 | fmt.Println(string(b)) 54 | } 55 | if err != nil { 56 | slog.Error("Script execution failed", "index", i+1, "total", len(scripts), "err", err) 57 | return fmt.Errorf("failed to run script %d/%d: %w", i+1, len(scripts), err) 58 | } 59 | } 60 | 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /internal/images/kali.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "regexp" 9 | "runtime" 10 | "strings" 11 | "time" 12 | 13 | rawLibvirt "libvirt.org/go/libvirt" 14 | 15 | "github.com/eikendev/hackenv/internal/network" 16 | ) 17 | 18 | var kaliConfigurationCmds = []string{ 19 | "touch ~/.hushlogin", 20 | } 21 | 22 | func findKaliChecksumLine(scanner *bufio.Scanner) (string, error) { 23 | for scanner.Scan() { 24 | line := scanner.Text() 25 | if strings.Contains(line, "live-"+runtime.GOARCH+".iso") { 26 | return strings.TrimSpace(line), nil 27 | } 28 | } 29 | 30 | return "", errors.New("cannot find checksum in file") 31 | } 32 | 33 | func kaliInfoRetriever(url string, versionRegex *regexp.Regexp) (*DownloadInfo, error) { 34 | resp, err := network.GetResponse(url) 35 | if err != nil { 36 | slog.Error("Failed to fetch Kali checksum file", "url", url, "err", err) 37 | return nil, fmt.Errorf("failed to get response: %w", err) 38 | } 39 | defer func() { 40 | if err := resp.Body.Close(); err != nil { 41 | slog.Warn("Failed to close response body", "err", err) 42 | } 43 | }() 44 | 45 | line, err := findKaliChecksumLine(bufio.NewScanner(resp.Body)) 46 | if err != nil { 47 | slog.Error("Failed to find Kali checksum line", "err", err) 48 | return nil, fmt.Errorf("cannot find Kali checksum line: %w", err) 49 | } 50 | 51 | return parseChecksumLine(line, versionRegex) 52 | } 53 | 54 | func kaliBootInitializer(dom *rawLibvirt.Domain) error { 55 | if err := genericBootInitializer(dom); err != nil { 56 | slog.Error("Failed to boot Kali image", "err", err) 57 | return fmt.Errorf("failed to boot Kali image: %w", err) 58 | } 59 | return nil 60 | } 61 | 62 | func kaliSSHStarter(dom *rawLibvirt.Domain) error { 63 | time.Sleep(5 * time.Second) 64 | if err := switchToTTY(dom); err != nil { 65 | slog.Error("Failed to switch Kali console to TTY", "err", err) 66 | return fmt.Errorf("failed to switch Kali console to TTY: %w", err) 67 | } 68 | 69 | time.Sleep(1 * time.Second) 70 | if err := systemdRestartSSH(dom); err != nil { 71 | slog.Error("Failed to restart SSH on Kali image", "err", err) 72 | return fmt.Errorf("failed to restart SSH on Kali image: %w", err) 73 | } 74 | 75 | if err := switchFromTTY(dom); err != nil { 76 | slog.Error("Failed to switch Kali console back from TTY", "err", err) 77 | return fmt.Errorf("failed to switch Kali console from TTY: %w", err) 78 | } 79 | 80 | return nil 81 | } 82 | -------------------------------------------------------------------------------- /internal/libvirt/libvirt.go: -------------------------------------------------------------------------------- 1 | // Package libvirt is an overlay to the actual libvirt library. 2 | package libvirt 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | 9 | "libvirt.org/go/libvirt" 10 | 11 | "github.com/eikendev/hackenv/internal/constants" 12 | "github.com/eikendev/hackenv/internal/images" 13 | ) 14 | 15 | var domainStates = map[libvirt.DomainState]string{ 16 | libvirt.DOMAIN_NOSTATE: "NOSTATE", 17 | libvirt.DOMAIN_RUNNING: "RUNNING", 18 | libvirt.DOMAIN_BLOCKED: "BLOCKED", 19 | libvirt.DOMAIN_PAUSED: "PAUSED", 20 | libvirt.DOMAIN_SHUTDOWN: "SHUTDOWN", 21 | libvirt.DOMAIN_CRASHED: "CRASHED", 22 | libvirt.DOMAIN_PMSUSPENDED: "PMSUSPENDED", 23 | libvirt.DOMAIN_SHUTOFF: "SHUTOFF", 24 | } 25 | 26 | // Connect establishes a connection to libvirt. 27 | func Connect() (*libvirt.Connect, error) { 28 | conn, err := libvirt.NewConnect(constants.ConnectURI) 29 | if err != nil { 30 | slog.Error("Cannot establish connection with libvirt", "err", err) 31 | return nil, fmt.Errorf("cannot establish connection with libvirt: %w", err) 32 | } 33 | 34 | return conn, nil 35 | } 36 | 37 | // GetDomain retrieves a given Domain from libvirt. 38 | func GetDomain(conn *libvirt.Connect, image *images.Image, fail bool) (*libvirt.Domain, error) { 39 | dom, err := conn.LookupDomainByName(image.Name) 40 | if err != nil { 41 | if fail { 42 | slog.Error("Domain is down", "image", image.DisplayName) 43 | return nil, fmt.Errorf("cannot use %s: domain is down", image.DisplayName) 44 | } 45 | return nil, nil 46 | } 47 | 48 | return dom, nil 49 | } 50 | 51 | // GetDomainIPAddress retrieves the IP address of the Domain. 52 | func GetDomainIPAddress(dom *libvirt.Domain, image *images.Image) (string, error) { 53 | ifaces, err := dom.ListAllInterfaceAddresses(libvirt.DOMAIN_INTERFACE_ADDRESSES_SRC_ARP) 54 | if err != nil { 55 | slog.Debug("Cannot retrieve VM IP address", "err", err, "image", image.Name) 56 | return "", fmt.Errorf("cannot retrieve VM IP address: %w", err) 57 | } 58 | 59 | for _, iface := range ifaces { 60 | if iface.Hwaddr == image.MacAddress { 61 | return iface.Addrs[0].Addr, nil 62 | } 63 | } 64 | 65 | slog.Debug("Cannot retrieve VM IP address", "image", image.Name) 66 | return "", errors.New("cannot retrieve VM IP address") 67 | } 68 | 69 | // ResolveDomainState translates the Domain status into a readable format. 70 | func ResolveDomainState(state libvirt.DomainState) (string, error) { 71 | display, ok := domainStates[state] 72 | if !ok { 73 | slog.Error("Cannot resolve domain state", "state", state) 74 | return "", fmt.Errorf("cannot resolve domain state: %d", state) 75 | } 76 | 77 | return display, nil 78 | } 79 | -------------------------------------------------------------------------------- /internal/host/host.go: -------------------------------------------------------------------------------- 1 | // Package host provides various utilities related to the host. 2 | package host 3 | 4 | import ( 5 | "bufio" 6 | "fmt" 7 | "log/slog" 8 | "net" 9 | "os/exec" 10 | "strings" 11 | 12 | "github.com/eikendev/hackenv/internal/paths" 13 | ) 14 | 15 | // GetHostIPAddress retrieves the IP address of the host on the given interface. 16 | func GetHostIPAddress(ifaceName string) (string, error) { 17 | ifaces, err := net.Interfaces() 18 | if err != nil { 19 | slog.Error("Cannot retrieve host interfaces", "err", err) 20 | return "", fmt.Errorf("cannot retrieve host interfaces: %w", err) 21 | } 22 | 23 | for _, iface := range ifaces { 24 | if iface.Name != ifaceName { 25 | continue 26 | } 27 | 28 | addrs, err := iface.Addrs() 29 | if err != nil { 30 | continue 31 | } 32 | 33 | for _, addr := range addrs { 34 | var ip net.IP 35 | switch v := addr.(type) { 36 | case *net.IPNet: 37 | ip = v.IP 38 | case *net.IPAddr: 39 | ip = v.IP 40 | default: 41 | continue 42 | } 43 | 44 | ip = ip.To16() 45 | if ip == nil { 46 | continue 47 | } 48 | 49 | return ip.String(), nil 50 | } 51 | } 52 | 53 | slog.Error("Cannot retrieve host IP address", "interface", ifaceName) 54 | return "", fmt.Errorf("cannot retrieve host IP address for interface %s", ifaceName) 55 | } 56 | 57 | // GetHostKeyboardLayout retrieves the configured keyboard layout on the host. 58 | func GetHostKeyboardLayout() (string, error) { 59 | setxkbmapPath, err := paths.GetCmdPath("setxkbmap") 60 | if err != nil { 61 | slog.Error("setxkbmap command not found", "err", err) 62 | return "", fmt.Errorf("cannot locate setxkbmap command: %w", err) 63 | } 64 | 65 | out, err := exec.Command( 66 | setxkbmapPath, 67 | "-query", 68 | ).Output() //#nosec G204 69 | if err != nil { 70 | slog.Error("Failed to query keyboard layout", "err", err) 71 | return "", fmt.Errorf("failed to query keyboard layout: %w", err) 72 | } 73 | 74 | var line string 75 | scanner := bufio.NewScanner(strings.NewReader(string(out))) 76 | 77 | for scanner.Scan() { 78 | line = scanner.Text() 79 | 80 | if strings.Contains(line, "layout") { 81 | break 82 | } 83 | } 84 | 85 | if line == "" { 86 | slog.Error("Unable to retrieve host keyboard layout: layout line missing") 87 | return "", fmt.Errorf("unable to retrieve host keyboard layout: layout line missing") 88 | } 89 | 90 | line = strings.TrimSpace(line) 91 | parts := strings.Split(line, " ") 92 | if len(parts) < 2 { 93 | slog.Error("Unable to retrieve host keyboard layout: malformed output", "output", line) 94 | return "", fmt.Errorf("unable to retrieve host keyboard layout: malformed output") 95 | } 96 | return parts[len(parts)-1], nil 97 | } 98 | -------------------------------------------------------------------------------- /internal/commands/ssh.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | "syscall" 8 | 9 | "github.com/eikendev/hackenv/internal/constants" 10 | "github.com/eikendev/hackenv/internal/handling" 11 | "github.com/eikendev/hackenv/internal/images" 12 | "github.com/eikendev/hackenv/internal/libvirt" 13 | "github.com/eikendev/hackenv/internal/options" 14 | "github.com/eikendev/hackenv/internal/paths" 15 | ) 16 | 17 | // SSHCommand represents the options specific to the ssh command. 18 | type SSHCommand struct{} 19 | 20 | // Run is the function for the ssh command. 21 | func (c *SSHCommand) Run(s *options.Options) error { 22 | image, err := images.GetImageDetails(s.Type) 23 | if err != nil { 24 | slog.Error("Failed to get image details for ssh command", "type", s.Type, "err", err) 25 | return fmt.Errorf("cannot resolve image details for %q: %w", s.Type, err) 26 | } 27 | 28 | conn, err := libvirt.Connect() 29 | if err != nil { 30 | slog.Error("Failed to connect to libvirt for ssh command", "err", err) 31 | return fmt.Errorf("cannot connect to libvirt: %w", err) 32 | } 33 | defer handling.CloseConnect(conn) 34 | 35 | dom, err := libvirt.GetDomain(conn, &image, true) 36 | if err != nil { 37 | slog.Error("Failed to lookup domain for ssh command", "image", image.Name, "err", err) 38 | return fmt.Errorf("cannot look up domain %q: %w", image.Name, err) 39 | } 40 | defer handling.FreeDomain(dom) 41 | 42 | ipAddr, err := libvirt.GetDomainIPAddress(dom, &image) 43 | if err != nil { 44 | slog.Error("Cannot retrieve guest IP address", "err", err) 45 | return fmt.Errorf("cannot retrieve guest IP address: %w", err) 46 | } 47 | 48 | args, err := buildSSHArgs([]string{ 49 | "-X", 50 | fmt.Sprintf("%s@%s", image.SSHUser, ipAddr), 51 | }) 52 | if err != nil { 53 | slog.Error("Failed to build SSH arguments", "err", err) 54 | return fmt.Errorf("cannot build SSH arguments: %w", err) 55 | } 56 | 57 | //#nosec G204 58 | err = syscall.Exec(args[0], args, os.Environ()) 59 | if err != nil { 60 | slog.Error("Cannot spawn SSH process", "err", err) 61 | return fmt.Errorf("failed to exec SSH client: %w", err) 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func buildSSHArgs(customArgs []string) ([]string, error) { 68 | sshPath, err := paths.GetCmdPath("ssh") 69 | if err != nil { 70 | slog.Error("ssh command not found", "err", err) 71 | return nil, fmt.Errorf("cannot locate ssh binary: %w", err) 72 | } 73 | keyPath, err := paths.GetDataFilePath(constants.SSHKeypairName) 74 | if err != nil { 75 | slog.Error("Failed to locate SSH keypair path", "err", err) 76 | return nil, fmt.Errorf("cannot locate ssh keypair: %w", err) 77 | } 78 | 79 | args := []string{ 80 | sshPath, 81 | "-i", keyPath, 82 | "-S", "none", 83 | "-o", "LogLevel=ERROR", 84 | "-o", "StrictHostKeyChecking=no", 85 | "-o", "UserKnownHostsFile=/dev/null", 86 | } 87 | args = append(args, customArgs...) 88 | 89 | return args, nil 90 | } 91 | -------------------------------------------------------------------------------- /internal/images/parrot.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "regexp" 9 | "runtime" 10 | "strings" 11 | "time" 12 | 13 | rawLibvirt "libvirt.org/go/libvirt" 14 | 15 | "github.com/eikendev/hackenv/internal/network" 16 | ) 17 | 18 | func findParrotChecksumLine(scanner *bufio.Scanner) (string, error) { 19 | const ( 20 | sha256Section = "sha256" 21 | sha384Section = "sha384" 22 | ) 23 | 24 | var inSha256Section bool 25 | 26 | for scanner.Scan() { 27 | line := scanner.Text() 28 | 29 | switch { 30 | case strings.Contains(line, sha256Section): 31 | inSha256Section = true 32 | continue 33 | case strings.Contains(line, sha384Section): 34 | inSha256Section = false 35 | continue 36 | case inSha256Section && 37 | strings.Contains(line, "Parrot-security") && 38 | strings.Contains(line, "_"+runtime.GOARCH+".iso"): 39 | return strings.TrimSpace(line), nil 40 | } 41 | } 42 | 43 | return "", errors.New("cannot find checksum in file") 44 | } 45 | 46 | func parrotInfoRetriever(url string, versionRegex *regexp.Regexp) (*DownloadInfo, error) { 47 | resp, err := network.GetResponse(url) 48 | if err != nil { 49 | slog.Error("Failed to fetch Parrot checksum file", "url", url, "err", err) 50 | return nil, fmt.Errorf("failed to get response: %w", err) 51 | } 52 | defer func() { 53 | if err := resp.Body.Close(); err != nil { 54 | slog.Warn("Failed to close response body", "err", err) 55 | } 56 | }() 57 | 58 | line, err := findParrotChecksumLine(bufio.NewScanner(resp.Body)) 59 | if err != nil { 60 | slog.Error("Failed to locate Parrot checksum line", "err", err) 61 | return nil, fmt.Errorf("cannot find Parrot checksum line: %w", err) 62 | } 63 | 64 | return parseChecksumLine(line, versionRegex) 65 | } 66 | 67 | func parrotBootInitializer(dom *rawLibvirt.Domain) error { 68 | if err := genericBootInitializer(dom); err != nil { 69 | slog.Error("Failed to boot Parrot image", "err", err) 70 | return fmt.Errorf("failed to boot Parrot image: %w", err) 71 | } 72 | return nil 73 | } 74 | 75 | func parrotSSHStarter(dom *rawLibvirt.Domain) error { 76 | time.Sleep(5 * time.Second) 77 | if err := switchToTTY(dom); err != nil { 78 | slog.Error("Failed to switch Parrot console to TTY", "err", err) 79 | return fmt.Errorf("failed to switch Parrot console to TTY: %w", err) 80 | } 81 | 82 | time.Sleep(1 * time.Second) 83 | if err := enablePasswordSSH(dom); err != nil { 84 | slog.Error("Failed to enable password SSH on Parrot image", "err", err) 85 | return fmt.Errorf("failed to enable password SSH on Parrot image: %w", err) 86 | } 87 | 88 | if err := systemdRestartSSH(dom); err != nil { 89 | slog.Error("Failed to restart SSH on Parrot image", "err", err) 90 | return fmt.Errorf("failed to restart SSH on Parrot image: %w", err) 91 | } 92 | 93 | if err := switchFromTTY(dom); err != nil { 94 | slog.Error("Failed to switch Parrot console back from TTY", "err", err) 95 | return fmt.Errorf("failed to switch Parrot console from TTY: %w", err) 96 | } 97 | 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /internal/commands/gui.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | "os/exec" 8 | 9 | "github.com/eikendev/hackenv/internal/constants" 10 | "github.com/eikendev/hackenv/internal/handling" 11 | "github.com/eikendev/hackenv/internal/images" 12 | "github.com/eikendev/hackenv/internal/libvirt" 13 | "github.com/eikendev/hackenv/internal/options" 14 | "github.com/eikendev/hackenv/internal/paths" 15 | ) 16 | 17 | const ( 18 | virtViewerBin = "virt-viewer" 19 | remminaBin = "remmina" 20 | ) 21 | 22 | // GuiCommand represents the options specific to the gui command. 23 | type GuiCommand struct { 24 | Viewer string `name:"viewer" env:"HACKENV_VIEWER" default:"virt-viewer" enum:"virt-viewer,remmina" help:"The viewer to use to connect to the VM"` 25 | Fullscreen bool `short:"f" name:"fullscreen" env:"HACKENV_FULLSCREEN" help:"Start GUI in fullscreen (virt-viewer only)"` 26 | } 27 | 28 | // Run is the function for the gui command. 29 | func (c *GuiCommand) Run(s *options.Options) error { 30 | image, err := images.GetImageDetails(s.Type) 31 | if err != nil { 32 | slog.Error("Failed to get image details for GUI command", "type", s.Type, "err", err) 33 | return fmt.Errorf("cannot resolve image details for %q: %w", s.Type, err) 34 | } 35 | 36 | conn, err := libvirt.Connect() 37 | if err != nil { 38 | slog.Error("Failed to connect to libvirt for GUI command", "err", err) 39 | return fmt.Errorf("cannot connect to libvirt: %w", err) 40 | } 41 | defer handling.CloseConnect(conn) 42 | 43 | // Check if the domain is up. 44 | dom, err := libvirt.GetDomain(conn, &image, true) 45 | if err != nil { 46 | slog.Error("Failed to lookup domain for GUI command", "image", image.Name, "err", err) 47 | return fmt.Errorf("cannot look up domain %q: %w", image.Name, err) 48 | } 49 | defer handling.FreeDomain(dom) 50 | 51 | args, err := c.viewerArgs(&image) 52 | if err != nil { 53 | slog.Error("Failed to resolve viewer arguments", "viewer", c.Viewer, "err", err) 54 | return fmt.Errorf("cannot resolve viewer %q: %w", c.Viewer, err) 55 | } 56 | 57 | cwd, err := os.Getwd() 58 | if err != nil { 59 | slog.Warn("Cannot get current working directory", "err", err) 60 | } 61 | 62 | cmd := exec.Command(args[0], args[1:]...) //#nosec G204 63 | cmd.Dir = cwd 64 | cmd.Env = os.Environ() 65 | 66 | err = cmd.Start() 67 | if err != nil { 68 | slog.Error("Cannot spawn viewer process", "err", err) 69 | return fmt.Errorf("cannot start viewer %q: %w", args[0], err) 70 | } 71 | defer handling.ReleaseProcess(cmd.Process) 72 | 73 | return nil 74 | } 75 | 76 | func (c *GuiCommand) viewerArgs(image *images.Image) ([]string, error) { 77 | switch c.Viewer { 78 | case virtViewerBin: 79 | virtViewerPath, err := paths.GetCmdPath(virtViewerBin) 80 | if err != nil { 81 | slog.Error("Unable to locate viewer", "viewer", c.Viewer, "err", err) 82 | return nil, fmt.Errorf("unable to locate %s", c.Viewer) 83 | } 84 | 85 | args := []string{ 86 | virtViewerPath, 87 | "--connect", 88 | constants.ConnectURI, 89 | image.Name, 90 | } 91 | if c.Fullscreen { 92 | args = append(args, "--full-screen") 93 | } 94 | return args, nil 95 | case remminaBin: 96 | remminaPath, err := paths.GetCmdPath(remminaBin) 97 | if err != nil { 98 | slog.Error("Unable to locate viewer", "viewer", c.Viewer, "err", err) 99 | return nil, fmt.Errorf("unable to locate %s", c.Viewer) 100 | } 101 | 102 | return []string{ 103 | remminaPath, 104 | "-c", 105 | "SPICE://localhost", 106 | }, nil 107 | default: 108 | slog.Error("Unsupported viewer", "viewer", c.Viewer) 109 | return nil, fmt.Errorf("cannot use viewer %s: unsupported", c.Viewer) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /internal/images/generic.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | rawLibvirt "libvirt.org/go/libvirt" 11 | ) 12 | 13 | type genericVersionComparer struct{} 14 | 15 | func getGenericVersionComparer() *genericVersionComparer { 16 | return &genericVersionComparer{} 17 | } 18 | 19 | func (vc genericVersionComparer) Lt(a, b string) bool { 20 | aParts := strings.Split(a, ".") 21 | bParts := strings.Split(b, ".") 22 | 23 | if len(aParts) == 0 || len(bParts) == 0 || len(aParts) != len(bParts) { 24 | slog.Error("Cannot compare versions with different parts", "a", a, "b", b) 25 | panic(fmt.Sprintf("Cannot compare versions %s and %s", a, b)) 26 | } 27 | 28 | for i := range aParts { 29 | aPart, err := strconv.Atoi(aParts[i]) 30 | if err != nil { 31 | slog.Error("Cannot convert version part to number", "value", aParts[i], "err", err) 32 | panic(fmt.Sprintf("Cannot convert version part %s to number", aParts[i])) 33 | } 34 | 35 | bPart, err := strconv.Atoi(bParts[i]) 36 | if err != nil { 37 | slog.Error("Cannot convert version part to number", "value", bParts[i], "err", err) 38 | panic(fmt.Sprintf("Cannot convert version part %s to number", bParts[i])) 39 | } 40 | 41 | if aPart < bPart { 42 | return true 43 | } 44 | if aPart > bPart { 45 | return false 46 | } 47 | } 48 | 49 | return false 50 | } 51 | 52 | func (vc genericVersionComparer) Eq(a, b string) bool { 53 | return a == b 54 | } 55 | 56 | func (vc genericVersionComparer) Gt(a, b string) bool { 57 | return !vc.Lt(a, b) && !vc.Eq(a, b) 58 | } 59 | 60 | func genericBootInitializer(dom *rawLibvirt.Domain) error { 61 | time.Sleep(1 * time.Second) 62 | return sendKeys(dom, []uint{KEY_ENTER}) 63 | } 64 | 65 | func switchToTTY(dom *rawLibvirt.Domain) error { 66 | if err := sendKeys(dom, []uint{KEY_LEFTCTRL, KEY_LEFTALT, KEY_F1}); err != nil { 67 | slog.Error("Failed to send key sequence to switch to TTY", "err", err) 68 | return fmt.Errorf("failed to switch console to TTY: %w", err) 69 | } 70 | time.Sleep(500 * time.Millisecond) 71 | return nil 72 | } 73 | 74 | func switchFromTTY(dom *rawLibvirt.Domain) error { 75 | if err := sendKeys(dom, []uint{KEY_LEFTCTRL, KEY_LEFTALT, KEY_F7}); err != nil { 76 | slog.Error("Failed to send key sequence to switch from TTY", "err", err) 77 | return fmt.Errorf("failed to switch console from TTY: %w", err) 78 | } 79 | time.Sleep(500 * time.Millisecond) 80 | return nil 81 | } 82 | 83 | func enablePasswordSSH(dom *rawLibvirt.Domain) error { 84 | // sudo sed -i '/.assword.uthentication/s/no/yes/' /etc/ssh/sshd* 85 | 86 | keys := []uint{ 87 | KEY_S, KEY_U, KEY_D, KEY_O, KEY_SPACE, KEY_S, KEY_E, KEY_D, KEY_SPACE, 88 | KEY_MINUS, KEY_I, KEY_SPACE, KEY_APOSTROPHE, KEY_SLASH, KEY_DOT, KEY_A, 89 | KEY_S, KEY_S, KEY_W, KEY_O, KEY_R, KEY_D, KEY_DOT, KEY_U, KEY_T, KEY_H, 90 | KEY_E, KEY_N, KEY_T, KEY_I, KEY_C, KEY_A, KEY_T, KEY_I, KEY_O, KEY_N, 91 | KEY_SLASH, KEY_S, KEY_SLASH, KEY_N, KEY_O, KEY_SLASH, KEY_Y, KEY_E, 92 | KEY_S, KEY_SLASH, KEY_APOSTROPHE, KEY_SPACE, KEY_SLASH, KEY_E, KEY_T, 93 | KEY_C, KEY_SLASH, KEY_S, KEY_S, KEY_H, KEY_SLASH, KEY_S, KEY_S, KEY_H, 94 | KEY_D, KEY_TAB, KEY_ENTER, 95 | } 96 | 97 | for _, key := range keys { 98 | if err := sendKeys(dom, []uint{key}); err != nil { 99 | slog.Error("Failed to send key while enabling password SSH", "err", err, "key", key) 100 | return fmt.Errorf("failed to enable password SSH: %w", err) 101 | } 102 | } 103 | 104 | time.Sleep(500 * time.Millisecond) 105 | return nil 106 | } 107 | 108 | func systemdRestartSSH(dom *rawLibvirt.Domain) error { 109 | // sudo systemctl restart ssh 110 | 111 | keys := []uint{ 112 | KEY_S, KEY_U, KEY_D, KEY_O, KEY_SPACE, KEY_S, KEY_Y, KEY_S, KEY_T, 113 | KEY_E, KEY_M, KEY_C, KEY_T, KEY_L, KEY_SPACE, KEY_R, KEY_E, KEY_S, 114 | KEY_T, KEY_A, KEY_R, KEY_T, KEY_SPACE, KEY_S, KEY_S, KEY_H, KEY_ENTER, 115 | } 116 | 117 | for _, key := range keys { 118 | if err := sendKeys(dom, []uint{key}); err != nil { 119 | slog.Error("Failed to send key while restarting SSH via systemd", "err", err, "key", key) 120 | return fmt.Errorf("failed to restart SSH via systemd: %w", err) 121 | } 122 | } 123 | 124 | time.Sleep(1500 * time.Millisecond) 125 | return nil 126 | } 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

hackenv

3 |

4 | Access your Kali Linux and Parrot Security instances with ease. 5 |

6 |

hackenv lets you comfortably manage your hacking environment from the terminal.

7 |
8 | 9 |

10 | Build status  11 | License  12 |

13 | 14 | ## 🚀 Installation 15 | 16 | The **preferred way** is to download the binary from the [latest release](https://github.com/eikendev/hackenv/releases). 17 | 18 | Alternatively, install the [required dependencies](#dependencies) and build it yourself: 19 | ```bash 20 | go install github.com/eikendev/hackenv/cmd/...@latest 21 | ``` 22 | 23 | Ensure that the libvirt daemon is running, and a socket created. On Fedora, this can be done as follows: 24 | ``` 25 | sudo systemctl start libvirtd 26 | sudo systemctl start virtqemud.socket 27 | ``` 28 | 29 | ## 🤘 Features 30 | 31 | - Support for Kali Linux and Parrot Security. 32 | - Create short-lived virtual machines within seconds. 33 | - Download the latest official live image conveniently. 34 | - **Simple and intuitive** command line interface. 35 | - Configure instant SSH access based on **public-key authentication**. 36 | - Set up a **shared directory** between host and guest. 37 | - Set the same **keyboard layout** in the guest as on the host. 38 | 39 | ## 📄 Usage 40 | 41 | First, make sure you have the [required dependencies](#dependencies) installed. 42 | Also, you will need a bridge interface [as described below](#creating-a-bridge-interface). 43 | This can be as simple as running `hackenv fix create-bridge` (or `./bin/hackenv_createbridge`). 44 | 45 | Then, download an image using `hackenv get`. 46 | This will download a live image from the official mirrors. 47 | The download can take a while, so sit back and enjoy some tea. 48 | 49 | | :warning: **If you run SELinux, you must label the new image yourself. Check [the section on file sharing](#file-sharing) for more information.** | 50 | |-----------------------------------------------------------------| 51 | 52 | Next, run `hackenv up` to boot the virtual machine. 53 | Once this command is finished, the VM is running and fully configured. 54 | The VM will be short-lived (volatile), meaning any data and configuration stored outside the shared directory will be removed once the machine shuts down. 55 | This is by design and admittedly opinionated. 56 | 57 | You can now start an SSH session with `hackenv ssh` or spin up a GUI with `hackenv gui`. 58 | 59 | Note that by default, hackenv will operate with Kali Linux, and respectively download its image. 60 | If you want to operate with Parrot Security instead, specify `hackenv --type=parrot`, or check out [the configuration](#configuration). 61 | 62 | ### File Sharing 63 | 64 | hackenv will automatically set up a shared directory between the host and the virtual machine. 65 | On the host side the directory is `~/.local/share/hackenv/shared`, while on the guest side it is located at `/shared`. 66 | 67 | If SELinux denies access to the shared directory, you have to adjust the context of the directory. 68 | You can run `hackenv fix apply-labels` (or `./bin/hackenv_applylabels`) if you are on Fedora or similar. 69 | The script will also relabel the downloaded images. 70 | If you add new files to the shared directory from outside, do not forget to label them as well. 71 | 72 | ### Creating a Bridge Interface 73 | 74 | hackenv uses a bridge so that you can reach the guest from the host for SSH, while the guest can access the Internet. 75 | You can create this bridge by running `hackenv fix create-bridge` (or `./bin/hackenv_createbridge`). 76 | Note that this script **will request privileges** so it can create an interface. 77 | 78 | Of course, please adapt the script to your specific needs. 79 | The interface is expected to have the name `virbr0` by default, but this can be changed using command line flags. 80 | 81 | ## ⚙ Configuration 82 | 83 | The tool currently does not support configuration via files. 84 | However, some options can be set using environment variables. 85 | Check out the help (`--help`) of a command to see what options support this. 86 | 87 | For instance, to operate with Parrot Security by default, you can set `$HACKENV_TYPE=parrot`. 88 | If you work with both operating systems, then I recommend using shell aliases: 89 | ```bash 90 | alias kali='hackenv --type=kali' 91 | alias parrot='hackenv --type=parrot' 92 | ``` 93 | 94 | ## 🥙 Dependencies 95 | 96 | - [libvirt](https://libvirt.org/) (virsh) 97 | - [OpenSSH](https://www.openssh.com/) (ssh and ssh-keygen) 98 | - setxkbmap 99 | - [virt-viewer](https://virt-manager.org/) or [Remmina](https://remmina.org/) 100 | 101 | To build the binary yourself, you also need the development files of libvirt, usually called `libvirt-dev` or `libvirt-devel`. 102 | 103 | ## 💡 Alternatives 104 | 105 | If you do not like this tool, the following options are worth checking out: 106 | - [Vagrant](https://www.vagrantup.com/) in combination with [VirtualBox](https://www.virtualbox.org/) 107 | - [Docker](https://www.docker.com/) or [Podman](https://podman.io/) 108 | -------------------------------------------------------------------------------- /internal/images/images.go: -------------------------------------------------------------------------------- 1 | // Package images provides utilities to access image information and manage images. 2 | package images 3 | 4 | import ( 5 | "fmt" 6 | "log/slog" 7 | "path/filepath" 8 | "regexp" 9 | "time" 10 | 11 | "github.com/adrg/xdg" 12 | rawLibvirt "libvirt.org/go/libvirt" 13 | 14 | "github.com/eikendev/hackenv/internal/constants" 15 | ) 16 | 17 | type infoRetriever func(string, *regexp.Regexp) (*DownloadInfo, error) 18 | 19 | type bootInitializer func(*rawLibvirt.Domain) error 20 | 21 | type sshStarter func(*rawLibvirt.Domain) error 22 | 23 | type versionComparer interface { 24 | Lt(string, string) bool 25 | Gt(string, string) bool 26 | Eq(string, string) bool 27 | } 28 | 29 | // Image contains information about a stored image. 30 | type Image struct { 31 | Name string 32 | DisplayName string 33 | ArchiveURL string 34 | checksumPath string 35 | LocalImageName string 36 | VersionRegex *regexp.Regexp 37 | SSHUser string 38 | SSHPassword string 39 | MacAddress string 40 | infoRetriever infoRetriever 41 | bootInitializer bootInitializer 42 | sshStarter sshStarter 43 | ConfigurationCmds []string 44 | VersionComparer versionComparer 45 | } 46 | 47 | // DownloadInfo contains information about an image that can be downloaded. 48 | type DownloadInfo struct { 49 | Checksum string 50 | Version string 51 | Filename string 52 | } 53 | 54 | var images = map[string]Image{ 55 | "kali": { 56 | Name: "kali", 57 | DisplayName: "Kali Linux", 58 | ArchiveURL: "https://kali.download/base-images/current/", 59 | checksumPath: "/SHA256SUMS", 60 | LocalImageName: "kali-%s.iso", 61 | VersionRegex: regexp.MustCompile(`\d\d\d\d\.\d+`), 62 | SSHUser: "kali", 63 | SSHPassword: "kali", 64 | MacAddress: "52:54:00:08:f9:e8", 65 | infoRetriever: kaliInfoRetriever, 66 | bootInitializer: kaliBootInitializer, 67 | sshStarter: kaliSSHStarter, 68 | ConfigurationCmds: kaliConfigurationCmds, 69 | VersionComparer: getGenericVersionComparer(), 70 | }, 71 | "parrot": { 72 | Name: "parrot", 73 | DisplayName: "Parrot Security", 74 | ArchiveURL: "https://deb.parrot.sh/parrot/iso/current", 75 | checksumPath: "/signed-hashes.txt", 76 | LocalImageName: "parrot-%s.iso", 77 | VersionRegex: regexp.MustCompile(`\d+\.\d+(?:\.\d+)?`), 78 | SSHUser: "user", 79 | SSHPassword: "parrot", 80 | MacAddress: "52:54:00:08:f9:e9", 81 | infoRetriever: parrotInfoRetriever, 82 | bootInitializer: parrotBootInitializer, 83 | sshStarter: parrotSSHStarter, 84 | ConfigurationCmds: []string{}, 85 | VersionComparer: getGenericVersionComparer(), 86 | }, 87 | } 88 | 89 | // GetDownloadInfo retreives the necessary information to download an image. 90 | func (i *Image) GetDownloadInfo(strict bool) (*DownloadInfo, error) { 91 | info, err := i.infoRetriever(i.ArchiveURL+i.checksumPath, i.VersionRegex) 92 | if err != nil { 93 | slog.Error("Cannot retrieve latest image details", "image", i.DisplayName, "err", err, "strict", strict) 94 | return nil, fmt.Errorf("cannot retrieve latest image details for %s: %w", i.DisplayName, err) 95 | } 96 | 97 | return info, nil 98 | } 99 | 100 | // Boot executes the necessary steps to boot a downloaded image. 101 | func (i *Image) Boot(dom *rawLibvirt.Domain, version string) error { 102 | slog.Info("Booting image", "image", i.DisplayName, "version", version) 103 | return i.bootInitializer(dom) 104 | } 105 | 106 | // StartSSH executes the necessary steps to start SSH on a booted image. 107 | func (i *Image) StartSSH(dom *rawLibvirt.Domain) error { 108 | slog.Info("Bootstrapping SSH", "image", i.DisplayName) 109 | return i.sshStarter(dom) 110 | } 111 | 112 | // GetLocalPath builds the full path of a downloaded image based on a given version. 113 | func (i *Image) GetLocalPath(version string) (string, error) { 114 | filename := fmt.Sprintf(i.LocalImageName, version) 115 | 116 | path, err := xdg.DataFile(filepath.Join(constants.XdgAppname, filename)) 117 | if err != nil { 118 | slog.Error("Cannot access data directory", "err", err, "file", filename) 119 | return "", fmt.Errorf("cannot resolve data path for %s: %w", filename, err) 120 | } 121 | 122 | return path, nil 123 | } 124 | 125 | // GetLatestPath returns the full path of the image with the greatest version. 126 | func (i *Image) GetLatestPath() (string, error) { 127 | imageGlob := fmt.Sprintf(i.LocalImageName, "*") 128 | 129 | path, err := xdg.DataFile(filepath.Join(constants.XdgAppname, imageGlob)) 130 | if err != nil { 131 | slog.Error("Cannot access data directory", "err", err, "pattern", imageGlob) 132 | return "", fmt.Errorf("cannot resolve data path for pattern %s: %w", imageGlob, err) 133 | } 134 | 135 | matches, err := filepath.Glob(path) 136 | if err != nil { 137 | slog.Error("Cannot find image", "image", i.DisplayName, "err", err) 138 | return "", fmt.Errorf("cannot glob images for %s: %w", i.DisplayName, err) 139 | } 140 | if len(matches) == 0 { 141 | slog.Error("Cannot find image", "image", i.DisplayName) 142 | return "", fmt.Errorf("found no images for %s", i.DisplayName) 143 | } 144 | 145 | latestPath := matches[0] 146 | latestVersion := i.FileVersion(latestPath) 147 | 148 | for _, path := range matches { 149 | slog.Info("Found image path", "path", path) 150 | if newVersion := i.FileVersion(path); i.VersionComparer.Gt(newVersion, latestVersion) { 151 | latestPath = path 152 | latestVersion = newVersion 153 | } 154 | } 155 | 156 | return latestPath, nil 157 | } 158 | 159 | // GetImageDetails returns detailed information about a given image. 160 | func GetImageDetails(name string) (Image, error) { 161 | image, ok := images[name] 162 | if !ok { 163 | slog.Error("Image not supported", "image", name) 164 | return Image{}, fmt.Errorf("cannot use image %s: not supported", name) 165 | } 166 | return image, nil 167 | } 168 | 169 | // GetAllImages returns a map of all available images. 170 | func GetAllImages() map[string]Image { 171 | return images 172 | } 173 | 174 | // FileVersion returns the version of the image given its full path. 175 | func (i *Image) FileVersion(path string) string { 176 | return i.VersionRegex.FindString(path) 177 | } 178 | 179 | func sendKeys(dom *rawLibvirt.Domain, keys []uint) error { 180 | err := dom.SendKey(uint(rawLibvirt.KEYCODE_SET_LINUX), 10, keys, 0) 181 | if err != nil { 182 | slog.Error("Cannot send keys", "err", err) 183 | return fmt.Errorf("cannot send keys: %w", err) 184 | } 185 | 186 | time.Sleep(20 * time.Millisecond) 187 | return nil 188 | } 189 | -------------------------------------------------------------------------------- /internal/commands/get.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "crypto/sha256" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | "net/http" 10 | "os" 11 | 12 | progressbar "github.com/schollz/progressbar/v3" 13 | 14 | "github.com/eikendev/hackenv/internal/handling" 15 | "github.com/eikendev/hackenv/internal/images" 16 | "github.com/eikendev/hackenv/internal/options" 17 | ) 18 | 19 | // GetCommand represents the options specific to the get command. 20 | type GetCommand struct { 21 | Force bool `short:"f" name:"force" help:"Force to download the new image"` 22 | Update bool `short:"u" name:"update" help:"Allow update to the latest image"` 23 | } 24 | 25 | // https://golang.org/pkg/crypto/sha256/#example_New_file 26 | func calculateFileChecksum(path string) (string, error) { 27 | slog.Info("Calculating checksum", "path", path) 28 | 29 | f, err := os.Open(path) //#nosec G304 30 | if err != nil { 31 | slog.Error("Failed to open file", "path", path, "err", err) 32 | return "", fmt.Errorf("cannot open %s: %w", path, err) 33 | } 34 | defer handling.Close(f) 35 | 36 | h := sha256.New() 37 | if _, err := io.Copy(h, f); err != nil { 38 | slog.Error("Failed to copy file content", "path", path, "err", err) 39 | return "", fmt.Errorf("cannot read %s for checksum: %w", path, err) 40 | } 41 | 42 | return fmt.Sprintf("%x", h.Sum(nil)), nil 43 | } 44 | 45 | // https://stackoverflow.com/a/11693049 46 | func downloadImage(path, url string) error { 47 | slog.Info("Downloading image", "path", path, "url", url) 48 | 49 | out, err := os.Create(path) //#nosec G304 50 | if err != nil { 51 | slog.Error("Cannot create image file", "path", path, "err", err) 52 | return fmt.Errorf("cannot create %s: %w", path, err) 53 | } 54 | defer handling.Close(out) 55 | 56 | resp, err := http.Get(url) //#nosec G107 57 | if err != nil { 58 | slog.Error("Cannot download image file", "url", url, "err", err) 59 | return fmt.Errorf("cannot download %s: %w", url, err) 60 | } 61 | if resp == nil { 62 | return fmt.Errorf("received nil response") 63 | } 64 | defer handling.Close(resp.Body) 65 | 66 | if resp.StatusCode != http.StatusOK { 67 | slog.Error("Cannot download image file: bad status", "status", resp.Status) 68 | return fmt.Errorf("cannot download %s: status %s", url, resp.Status) 69 | } 70 | 71 | bar := progressbar.DefaultBytes( 72 | resp.ContentLength, 73 | "downloading", 74 | ) 75 | 76 | _, err = io.Copy(io.MultiWriter(out, bar), resp.Body) 77 | if err != nil { 78 | slog.Error("Cannot write image file", "path", path, "err", err) 79 | return fmt.Errorf("cannot write %s: %w", path, err) 80 | } 81 | 82 | slog.Info("Download successful") 83 | return nil 84 | } 85 | 86 | func validateChecksum(localPath, checksum string) error { 87 | newChecksum, err := calculateFileChecksum(localPath) 88 | if err != nil { 89 | slog.Error("Failed to calculate checksum for downloaded image", "path", localPath, "err", err) 90 | return fmt.Errorf("failed to calculate checksum for %s: %w", localPath, err) 91 | } 92 | 93 | if newChecksum != checksum { 94 | removeErr := os.Remove(localPath) 95 | if removeErr != nil { 96 | slog.Error("Downloaded image has bad checksum and cannot be removed", "path", localPath, "expected", checksum, "actual", newChecksum, "err", removeErr) 97 | return fmt.Errorf("failed to remove image with bad checksum (expected %s, actual %s): %w", checksum, newChecksum, removeErr) 98 | } 99 | 100 | slog.Error("Downloaded image has bad checksum and was removed", "path", localPath, "expected", checksum, "actual", newChecksum) 101 | return fmt.Errorf("detected bad checksum for downloaded image: expected %s, actual %s", checksum, newChecksum) 102 | } 103 | 104 | slog.Info("Checksum validated successfully") 105 | return nil 106 | } 107 | 108 | // Run is the function for the get command. 109 | func (c *GetCommand) Run(s *options.Options) error { 110 | image, err := images.GetImageDetails(s.Type) 111 | if err != nil { 112 | slog.Error("Failed to get image details for get command", "type", s.Type, "err", err) 113 | return fmt.Errorf("cannot resolve image details for %q: %w", s.Type, err) 114 | } 115 | info, err := image.GetDownloadInfo(true) 116 | if err != nil { 117 | slog.Error("Failed to get download info for image", "image", image.DisplayName, "err", err) 118 | return fmt.Errorf("cannot fetch download info for %s: %w", image.DisplayName, err) 119 | } 120 | if info == nil { 121 | slog.Error("Download info is nil", "image", image.DisplayName) 122 | return fmt.Errorf("failed to get download information") 123 | } 124 | 125 | slog.Info("Found image to download", "filename", info.Filename, "checksum", info.Checksum) 126 | 127 | localPath, err := image.GetLocalPath(info.Version) 128 | if err != nil { 129 | slog.Error("Failed to resolve local image path", "image", image.DisplayName, "version", info.Version, "err", err) 130 | return fmt.Errorf("cannot resolve local path for %s %s: %w", image.DisplayName, info.Version, err) 131 | } 132 | 133 | skipDownload, err := c.shouldSkipDownload(localPath, image, info) 134 | if err != nil { 135 | slog.Error("Failed to determine if download should be skipped", "path", localPath, "err", err) 136 | return fmt.Errorf("cannot decide whether to download %s: %w", localPath, err) 137 | } 138 | if skipDownload { 139 | return nil 140 | } 141 | 142 | err = downloadImage(localPath, image.ArchiveURL+"/"+info.Filename) 143 | if err != nil { 144 | slog.Error("Failed to download image", "path", localPath, "url", image.ArchiveURL+"/"+info.Filename, "err", err) 145 | return fmt.Errorf("cannot download image %s/%s: %w", image.ArchiveURL, info.Filename, err) 146 | } 147 | 148 | err = validateChecksum(localPath, info.Checksum) 149 | if err != nil { 150 | slog.Error("Checksum validation failed", "path", localPath, "err", err) 151 | return fmt.Errorf("failed to validate checksum for %s: %w", localPath, err) 152 | } 153 | 154 | slog.Info("When using SELinux, label the image with the fix command before proceeding") 155 | 156 | return nil 157 | } 158 | 159 | func (c *GetCommand) shouldSkipDownload(localPath string, image images.Image, info *images.DownloadInfo) (bool, error) { 160 | // https://stackoverflow.com/a/12518877 161 | _, err := os.Stat(localPath) 162 | if err == nil { 163 | if !c.Update && !c.Force { 164 | slog.Info("An image is already installed; use --update to refresh") 165 | return true, nil 166 | } 167 | 168 | localVersion := image.FileVersion(localPath) 169 | 170 | if !c.Force && image.VersionComparer.Eq(info.Version, localVersion) { 171 | slog.Info("Latest image is already installed; use --force to overwrite") 172 | return true, nil 173 | } 174 | return false, nil 175 | } 176 | 177 | if errors.Is(err, os.ErrNotExist) { 178 | return false, nil 179 | } 180 | 181 | slog.Error("Unable to get file information", "path", localPath, "err", err) 182 | return false, fmt.Errorf("cannot stat %s: %w", localPath, err) 183 | } 184 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= 2 | github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= 3 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 4 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 5 | github.com/alecthomas/kong v1.13.0 h1:5e/7XC3ugvhP1DQBmTS+WuHtCbcv44hsohMgcvVxSrA= 6 | github.com/alecthomas/kong v1.13.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= 7 | github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= 8 | github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 9 | github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= 10 | github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 15 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 16 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 17 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 18 | github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 19 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 20 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 21 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 22 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 23 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 24 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 25 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 26 | github.com/melbahja/goph v1.4.0 h1:z0PgDbBFe66lRYl3v5dGb9aFgPy0kotuQ37QOwSQFqs= 27 | github.com/melbahja/goph v1.4.0/go.mod h1:uG+VfK2Dlhk+O32zFrRlc3kYKTlV6+BtvPWd/kK7U68= 28 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= 29 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= 30 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 31 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 32 | github.com/pkg/sftp v1.13.5/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg= 33 | github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= 34 | github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= 35 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 36 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 37 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 38 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 39 | github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA= 40 | github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= 41 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 42 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 43 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 44 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 45 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 46 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 47 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 48 | golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 49 | golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 50 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 51 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 52 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 53 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 54 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 55 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 56 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 57 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 58 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 59 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 60 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 61 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 62 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 63 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 65 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 66 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 67 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 68 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 69 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 70 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 71 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 72 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 73 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 74 | golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= 75 | golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 76 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 77 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 78 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 79 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 80 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 81 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 82 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 83 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 84 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 85 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 86 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 87 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 88 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 89 | libvirt.org/go/libvirt v1.11006.0 h1:xzF87ptj/7cp1h4T62w1ZMBVY8m0mQukSCstMgeiVLs= 90 | libvirt.org/go/libvirt v1.11006.0/go.mod h1:1WiFE8EjZfq+FCVog+rvr1yatKbKZ9FaFMZgEqxEJqQ= 91 | -------------------------------------------------------------------------------- /internal/commands/up.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | "os/exec" 8 | "syscall" 9 | "time" 10 | 11 | "github.com/melbahja/goph" 12 | rawLibvirt "libvirt.org/go/libvirt" 13 | 14 | "github.com/eikendev/hackenv/internal/banner" 15 | "github.com/eikendev/hackenv/internal/constants" 16 | "github.com/eikendev/hackenv/internal/handling" 17 | "github.com/eikendev/hackenv/internal/host" 18 | "github.com/eikendev/hackenv/internal/images" 19 | "github.com/eikendev/hackenv/internal/libvirt" 20 | "github.com/eikendev/hackenv/internal/options" 21 | "github.com/eikendev/hackenv/internal/paths" 22 | ) 23 | 24 | const ( 25 | sharedDir = "shared" 26 | connectTries = 60 27 | xmlTemplate = ` 28 | 29 | %s 30 | %d 31 | %d 32 | 33 | hvm 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 68 | 69 | 70 | 71 | 72 | /dev/urandom 73 | 74 | 75 | 76 | ` 77 | ) 78 | 79 | // UpCommand represents the options specific to the up command. 80 | type UpCommand struct { 81 | Cores int `name:"cores" env:"HACKENV_CORES" default:"2" help:"How many virtual CPU cores to assign to the VM"` 82 | Memory int `name:"memory" env:"HACKENV_MEMORY" default:"2097152" help:"How much RAM to assign to the VM (KiB)"` 83 | Interface string `name:"iface" env:"HACKENV_IFACE" default:"virbr0" help:"The network interface to use as a bridge"` 84 | DisplaySize string `name:"display_size" env:"HACKENV_DISPLAY_SIZE" default:"1920x1080" help:"The resolution of the VM's display"` 85 | } 86 | 87 | func buildXML(c *UpCommand, image images.Image, path string) (string, error) { 88 | sharedPath, err := paths.GetDataFilePath(sharedDir) 89 | if err != nil { 90 | slog.Error("Failed to resolve shared directory path", "err", err) 91 | return "", fmt.Errorf("cannot resolve shared directory path: %w", err) 92 | } 93 | 94 | if err := paths.EnsureDirExists(sharedPath); err != nil { 95 | slog.Error("Failed to ensure shared directory exists", "path", sharedPath, "err", err) 96 | return "", fmt.Errorf("cannot ensure shared directory at %s: %w", sharedPath, err) 97 | } 98 | 99 | return fmt.Sprintf( 100 | xmlTemplate, 101 | image.Name, 102 | c.Memory, 103 | c.Cores, 104 | path, 105 | sharedPath, 106 | image.MacAddress, 107 | c.Interface, 108 | ), nil 109 | } 110 | 111 | func waitBootComplete(dom *rawLibvirt.Domain, image *images.Image) (string, error) { 112 | for i := 1; i <= connectTries; i++ { 113 | slog.Info("Waiting for VM to become active", "attempt", i, "maxAttempts", connectTries, "image", image.Name) 114 | 115 | ipAddr, err := libvirt.GetDomainIPAddress(dom, image) 116 | if err == nil { 117 | slog.Info("VM is up", "ip", ipAddr, "image", image.Name) 118 | return ipAddr, nil 119 | } 120 | 121 | time.Sleep(2 * time.Second) 122 | } 123 | 124 | slog.Error("VM did not become active", "image", image.Name, "attempts", connectTries) 125 | return "", fmt.Errorf("failed to detect active VM within %d attempts", connectTries) 126 | } 127 | 128 | func provisionClient(_ *UpCommand, image *images.Image, guestIPAddr string) error { 129 | sharedPath, err := paths.GetDataFilePath(sharedDir) 130 | if err != nil { 131 | slog.Error("Failed to locate shared path for provisioning", "err", err) 132 | return fmt.Errorf("cannot locate shared directory: %w", err) 133 | } 134 | 135 | if paths.DoesPostbootExist(sharedPath) { 136 | args, err := buildSSHArgs([]string{ 137 | fmt.Sprintf("%s@%s", image.SSHUser, guestIPAddr), 138 | fmt.Sprintf("/shared/%s", constants.PostbootFile), 139 | }) 140 | if err != nil { 141 | slog.Error("Failed to build SSH arguments for provisioning", "err", err) 142 | return fmt.Errorf("cannot build provisioning SSH args: %w", err) 143 | } 144 | 145 | slog.Info("Provisioning VM", "image", image.Name) 146 | 147 | //#nosec G204 148 | err = syscall.Exec(args[0], args, os.Environ()) 149 | if err != nil { 150 | slog.Error("Cannot provision VM", "err", err) 151 | return fmt.Errorf("failed to execute provisioning command: %w", err) 152 | } 153 | } 154 | 155 | return nil 156 | } 157 | 158 | func configureClient(c *UpCommand, _ *rawLibvirt.Domain, image *images.Image, guestIPAddr string, keymap string) error { 159 | client, err := goph.NewUnknown(image.SSHUser, guestIPAddr, goph.Password(image.SSHPassword)) 160 | if err != nil { 161 | slog.Error("Failed to create SSH client for guest configuration", "image", image.Name, "err", err) 162 | return fmt.Errorf("cannot create SSH client for %s: %w", image.Name, err) 163 | } 164 | if client == nil { 165 | slog.Error("SSH client for guest configuration is nil", "image", image.Name) 166 | return fmt.Errorf("encountered nil SSH client for %s", image.Name) 167 | } 168 | 169 | publicKeyPath, err := paths.GetDataFilePath(constants.SSHKeypairName + ".pub") 170 | if err != nil { 171 | slog.Error("Failed to locate public key for provisioning", "err", err) 172 | return fmt.Errorf("cannot locate public key: %w", err) 173 | } 174 | 175 | publicKey, err := os.ReadFile(publicKeyPath) //#nosec G304 176 | if err != nil { 177 | slog.Error("Failed to read public key for provisioning", "path", publicKeyPath, "err", err) 178 | return fmt.Errorf("cannot read public key %s: %w", publicKeyPath, err) 179 | } 180 | 181 | publicKeyStr := string(publicKey) 182 | 183 | if keymap == "" { 184 | keymap, err = host.GetHostKeyboardLayout() 185 | if err != nil { 186 | slog.Error("Failed to detect host keyboard layout", "err", err) 187 | return fmt.Errorf("cannot detect host keyboard layout: %w", err) 188 | } 189 | } 190 | 191 | cmds := image.ConfigurationCmds 192 | cmds = append(cmds, []string{ 193 | // Add the SSH key to authorized_keys. 194 | "mkdir ~/.ssh", 195 | "chmod 700 ~/.ssh", 196 | "printf '" + publicKeyStr + "' >> ~/.ssh/authorized_keys", 197 | "chmod 660 ~/.ssh/authorized_keys", 198 | 199 | // Disable password authentication on SSH. 200 | "sudo sed -i '/PasswordAuthentication/s/yes/no/' /etc/ssh/sshd_config", 201 | "sudo systemctl reload ssh", 202 | 203 | // Setup a shared directory. 204 | "sudo mkdir /shared", 205 | "sudo mount -t 9p -o trans=virtio,version=9p2000.L /shared /shared", 206 | 207 | // Set screen size to Full HD. 208 | fmt.Sprintf("DISPLAY=:0 xrandr --size %s", c.DisplaySize), 209 | 210 | // Set keyboard layout. 211 | fmt.Sprintf("DISPLAY=:0 setxkbmap %s", keymap), 212 | }...) 213 | 214 | for _, cmd := range cmds { 215 | _, err := client.Run(cmd) 216 | if err != nil { 217 | slog.Error("Failed to run guest configuration command", "command", cmd, "err", err) 218 | return fmt.Errorf("failed to run command '%s' over SSH: %s", cmd, err) 219 | } 220 | } 221 | 222 | return nil 223 | } 224 | 225 | func ensureSSHKeypairExists() error { 226 | sshKeypairPath, err := paths.GetDataFilePath(constants.SSHKeypairName) 227 | if err != nil { 228 | slog.Error("Failed to determine SSH keypair path", "err", err) 229 | return fmt.Errorf("cannot determine SSH keypair path: %w", err) 230 | } 231 | 232 | if _, err := os.Stat(sshKeypairPath); err == nil { 233 | // SSH keypair already exists. 234 | return nil 235 | } 236 | 237 | sshKeygenPath, err := paths.GetCmdPath("ssh-keygen") 238 | if err != nil { 239 | slog.Error("ssh-keygen command not found", "err", err) 240 | return fmt.Errorf("cannot locate ssh-keygen: %w", err) 241 | } 242 | 243 | cmd := exec.Command( 244 | sshKeygenPath, 245 | "-f", 246 | sshKeypairPath, 247 | "-t", 248 | "ed25519", 249 | "-C", 250 | constants.SSHKeypairName, 251 | "-q", 252 | "-N", 253 | "", // Password is empty so no typing is required. 254 | ) //#nosec G204 255 | 256 | if err := cmd.Start(); err != nil { 257 | slog.Error("Failed to start ssh-keygen", "err", err) 258 | return fmt.Errorf("cannot start ssh-keygen: %w", err) 259 | } 260 | 261 | if err := cmd.Wait(); err != nil { 262 | slog.Error("ssh-keygen exited with error", "err", err) 263 | return fmt.Errorf("failed to finish ssh-keygen: %w", err) 264 | } 265 | 266 | return nil 267 | } 268 | 269 | // Run is the function for the up command. 270 | func (c *UpCommand) Run(s *options.Options) error { 271 | banner.PrintBanner() 272 | 273 | image, localPath, localVersion, err := c.resolveImage(s) 274 | if err != nil { 275 | slog.Error("Failed to resolve image for up command", "type", s.Type, "err", err) 276 | return fmt.Errorf("cannot resolve image data: %w", err) 277 | } 278 | 279 | xml, err := buildXML(c, image, localPath) 280 | if err != nil { 281 | slog.Error("Failed to build domain XML", "image", image.Name, "err", err) 282 | return fmt.Errorf("cannot build domain definition: %w", err) 283 | } 284 | 285 | conn, err := libvirt.Connect() 286 | if err != nil { 287 | slog.Error("Failed to connect to libvirt for up command", "err", err) 288 | return fmt.Errorf("cannot connect to libvirt: %w", err) 289 | } 290 | defer handling.CloseConnect(conn) 291 | 292 | dom, err := conn.DomainCreateXML(xml, 0) 293 | if dom != nil { 294 | defer handling.FreeDomain(dom) 295 | } 296 | 297 | if err != nil { 298 | if s.Provision { 299 | return c.provisionExistingDomain(conn, &image) 300 | } 301 | 302 | slog.Error("Cannot create domain. Try running 'hackenv fix all'.", "err", err) 303 | return fmt.Errorf("cannot create domain %q: %w", image.Name, err) 304 | } 305 | 306 | return c.bootAndConfigure(dom, &image, localVersion, s) 307 | } 308 | 309 | func (c *UpCommand) resolveImage(s *options.Options) (images.Image, string, string, error) { 310 | image, err := images.GetImageDetails(s.Type) 311 | if err != nil { 312 | slog.Error("Failed to get image details for up command", "type", s.Type, "err", err) 313 | return images.Image{}, "", "", fmt.Errorf("cannot resolve image details for %q: %w", s.Type, err) 314 | } 315 | 316 | localPath, err := image.GetLatestPath() 317 | if err != nil { 318 | slog.Error("Failed to resolve latest image path", "image", image.DisplayName, "err", err) 319 | return images.Image{}, "", "", fmt.Errorf("cannot resolve latest image path for %s: %w", image.DisplayName, err) 320 | } 321 | 322 | localVersion := image.FileVersion(localPath) 323 | 324 | if info, err := image.GetDownloadInfo(false); err == nil && info != nil { 325 | if !image.VersionComparer.Eq(info.Version, localVersion) { 326 | slog.Info("New image version available", "image", image.DisplayName, "version", info.Version) 327 | } 328 | } else if err != nil { 329 | slog.Warn("Unable to determine latest upstream image version", "image", image.DisplayName, "err", err) 330 | } 331 | 332 | if err := ensureSSHKeypairExists(); err != nil { 333 | slog.Error("Cannot create SSH keypair", "err", err) 334 | return images.Image{}, "", "", fmt.Errorf("failed to ensure SSH keypair exists: %w", err) 335 | } 336 | 337 | return image, localPath, localVersion, nil 338 | } 339 | 340 | func (c *UpCommand) provisionExistingDomain(conn *rawLibvirt.Connect, image *images.Image) error { 341 | slog.Info("Domain already running, provisioning instead", "image", image.DisplayName) 342 | 343 | dom, err := libvirt.GetDomain(conn, image, true) 344 | if err != nil { 345 | slog.Error("Failed to lookup existing domain for provisioning", "image", image.DisplayName, "err", err) 346 | return fmt.Errorf("cannot look up running domain %q: %w", image.DisplayName, err) 347 | } 348 | defer handling.FreeDomain(dom) 349 | 350 | guestIPAddr, err := waitBootComplete(dom, image) 351 | if err != nil { 352 | slog.Error("Existing domain did not become ready for provisioning", "image", image.DisplayName, "err", err) 353 | return fmt.Errorf("failed while waiting for running domain %q: %w", image.DisplayName, err) 354 | } 355 | 356 | if err := provisionClient(c, image, guestIPAddr); err != nil { 357 | slog.Error("Provisioning of existing domain failed", "image", image.DisplayName, "err", err) 358 | return fmt.Errorf("failed to provision running domain %q: %w", image.DisplayName, err) 359 | } 360 | 361 | return nil 362 | } 363 | 364 | func (c *UpCommand) bootAndConfigure(dom *rawLibvirt.Domain, image *images.Image, version string, opts *options.Options) error { 365 | if err := image.Boot(dom, version); err != nil { 366 | slog.Error("Failed during image boot sequence", "image", image.DisplayName, "err", err) 367 | return fmt.Errorf("failed to boot image %s: %w", image.DisplayName, err) 368 | } 369 | 370 | guestIPAddr, err := waitBootComplete(dom, image) 371 | if err != nil { 372 | slog.Error("Domain did not become ready after boot", "image", image.DisplayName, "err", err) 373 | return fmt.Errorf("failed while waiting for domain %s: %w", image.DisplayName, err) 374 | } 375 | 376 | if err := image.StartSSH(dom); err != nil { 377 | slog.Error("Failed to start SSH on guest", "image", image.DisplayName, "err", err) 378 | return fmt.Errorf("failed to start SSH on %s: %w", image.DisplayName, err) 379 | } 380 | 381 | if err := configureClient(c, dom, image, guestIPAddr, opts.Keymap); err != nil { 382 | slog.Error("Cannot configure client", "err", err) 383 | return fmt.Errorf("cannot configure guest %s: %w", image.DisplayName, err) 384 | } 385 | 386 | slog.Info("VM is ready to use", "image", image.DisplayName) 387 | 388 | if opts.Provision { 389 | if err := provisionClient(c, image, guestIPAddr); err != nil { 390 | return fmt.Errorf("failed to provision guest %s: %w", image.DisplayName, err) 391 | } 392 | } 393 | 394 | return nil 395 | } 396 | -------------------------------------------------------------------------------- /internal/images/keycodes.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | //revive:disable 4 | 5 | const ( 6 | KEY_RESERVED uint = 0x0 7 | KEY_ESC uint = 0x1 8 | KEY_1 uint = 0x2 9 | KEY_2 uint = 0x3 10 | KEY_3 uint = 0x4 11 | KEY_4 uint = 0x5 12 | KEY_5 uint = 0x6 13 | KEY_6 uint = 0x7 14 | KEY_7 uint = 0x8 15 | KEY_8 uint = 0x9 16 | KEY_9 uint = 0xa 17 | KEY_0 uint = 0xb 18 | KEY_MINUS uint = 0xc 19 | KEY_EQUAL uint = 0xd 20 | KEY_BACKSPACE uint = 0xe 21 | KEY_TAB uint = 0xf 22 | KEY_Q uint = 0x10 23 | KEY_W uint = 0x11 24 | KEY_E uint = 0x12 25 | KEY_R uint = 0x13 26 | KEY_T uint = 0x14 27 | KEY_Y uint = 0x15 28 | KEY_U uint = 0x16 29 | KEY_I uint = 0x17 30 | KEY_O uint = 0x18 31 | KEY_P uint = 0x19 32 | KEY_LEFTBRACE uint = 0x1a 33 | KEY_RIGHTBRACE uint = 0x1b 34 | KEY_ENTER uint = 0x1c 35 | KEY_LEFTCTRL uint = 0x1d 36 | KEY_A uint = 0x1e 37 | KEY_S uint = 0x1f 38 | KEY_D uint = 0x20 39 | KEY_F uint = 0x21 40 | KEY_G uint = 0x22 41 | KEY_H uint = 0x23 42 | KEY_J uint = 0x24 43 | KEY_K uint = 0x25 44 | KEY_L uint = 0x26 45 | KEY_SEMICOLON uint = 0x27 46 | KEY_APOSTROPHE uint = 0x28 47 | KEY_GRAVE uint = 0x29 48 | KEY_LEFTSHIFT uint = 0x2a 49 | KEY_BACKSLASH uint = 0x2b 50 | KEY_Z uint = 0x2c 51 | KEY_X uint = 0x2d 52 | KEY_C uint = 0x2e 53 | KEY_V uint = 0x2f 54 | KEY_B uint = 0x30 55 | KEY_N uint = 0x31 56 | KEY_M uint = 0x32 57 | KEY_COMMA uint = 0x33 58 | KEY_DOT uint = 0x34 59 | KEY_SLASH uint = 0x35 60 | KEY_RIGHTSHIFT uint = 0x36 61 | KEY_KPASTERISK uint = 0x37 62 | KEY_LEFTALT uint = 0x38 63 | KEY_SPACE uint = 0x39 64 | KEY_CAPSLOCK uint = 0x3a 65 | KEY_F1 uint = 0x3b 66 | KEY_F2 uint = 0x3c 67 | KEY_F3 uint = 0x3d 68 | KEY_F4 uint = 0x3e 69 | KEY_F5 uint = 0x3f 70 | KEY_F6 uint = 0x40 71 | KEY_F7 uint = 0x41 72 | KEY_F8 uint = 0x42 73 | KEY_F9 uint = 0x43 74 | KEY_F10 uint = 0x44 75 | KEY_NUMLOCK uint = 0x45 76 | KEY_SCROLLLOCK uint = 0x46 77 | KEY_KP7 uint = 0x47 78 | KEY_KP8 uint = 0x48 79 | KEY_KP9 uint = 0x49 80 | KEY_KPMINUS uint = 0x4a 81 | KEY_KP4 uint = 0x4b 82 | KEY_KP5 uint = 0x4c 83 | KEY_KP6 uint = 0x4d 84 | KEY_KPPLUS uint = 0x4e 85 | KEY_KP1 uint = 0x4f 86 | KEY_KP2 uint = 0x50 87 | KEY_KP3 uint = 0x51 88 | KEY_KP0 uint = 0x52 89 | KEY_KPDOT uint = 0x53 90 | KEY_ZENKAKUHANKAKU uint = 0x55 91 | KEY_102ND uint = 0x56 92 | KEY_F11 uint = 0x57 93 | KEY_F12 uint = 0x58 94 | KEY_RO uint = 0x59 95 | KEY_KATAKANA uint = 0x5a 96 | KEY_HIRAGANA uint = 0x5b 97 | KEY_HENKAN uint = 0x5c 98 | KEY_KATAKANAHIRAGANA uint = 0x5d 99 | KEY_MUHENKAN uint = 0x5e 100 | KEY_KPJPCOMMA uint = 0x5f 101 | KEY_KPENTER uint = 0x60 102 | KEY_RIGHTCTRL uint = 0x61 103 | KEY_KPSLASH uint = 0x62 104 | KEY_SYSRQ uint = 0x63 105 | KEY_RIGHTALT uint = 0x64 106 | KEY_LINEFEED uint = 0x65 107 | KEY_HOME uint = 0x66 108 | KEY_UP uint = 0x67 109 | KEY_PAGEUP uint = 0x68 110 | KEY_LEFT uint = 0x69 111 | KEY_RIGHT uint = 0x6a 112 | KEY_END uint = 0x6b 113 | KEY_DOWN uint = 0x6c 114 | KEY_PAGEDOWN uint = 0x6d 115 | KEY_INSERT uint = 0x6e 116 | KEY_DELETE uint = 0x6f 117 | KEY_MACRO uint = 0x70 118 | KEY_MUTE uint = 0x71 119 | KEY_VOLUMEDOWN uint = 0x72 120 | KEY_VOLUMEUP uint = 0x73 121 | KEY_POWER uint = 0x74 122 | KEY_KPEQUAL uint = 0x75 123 | KEY_KPPLUSMINUS uint = 0x76 124 | KEY_PAUSE uint = 0x77 125 | KEY_SCALE uint = 0x78 126 | KEY_KPCOMMA uint = 0x79 127 | KEY_HANGEUL uint = 0x7a 128 | KEY_HANJA uint = 0x7b 129 | KEY_YEN uint = 0x7c 130 | KEY_LEFTMETA uint = 0x7d 131 | KEY_RIGHTMETA uint = 0x7e 132 | KEY_COMPOSE uint = 0x7f 133 | KEY_STOP uint = 0x80 134 | KEY_AGAIN uint = 0x81 135 | KEY_PROPS uint = 0x82 136 | KEY_UNDO uint = 0x83 137 | KEY_FRONT uint = 0x84 138 | KEY_COPY uint = 0x85 139 | KEY_OPEN uint = 0x86 140 | KEY_PASTE uint = 0x87 141 | KEY_FIND uint = 0x88 142 | KEY_CUT uint = 0x89 143 | KEY_HELP uint = 0x8a 144 | KEY_MENU uint = 0x8b 145 | KEY_CALC uint = 0x8c 146 | KEY_SETUP uint = 0x8d 147 | KEY_SLEEP uint = 0x8e 148 | KEY_WAKEUP uint = 0x8f 149 | KEY_FILE uint = 0x90 150 | KEY_SENDFILE uint = 0x91 151 | KEY_DELETEFILE uint = 0x92 152 | KEY_XFER uint = 0x93 153 | KEY_PROG1 uint = 0x94 154 | KEY_PROG2 uint = 0x95 155 | KEY_WWW uint = 0x96 156 | KEY_MSDOS uint = 0x97 157 | KEY_SCREENLOCK uint = 0x98 158 | KEY_DIRECTION uint = 0x99 159 | KEY_CYCLEWINDOWS uint = 0x9a 160 | KEY_MAIL uint = 0x9b 161 | KEY_BOOKMARKS uint = 0x9c 162 | KEY_COMPUTER uint = 0x9d 163 | KEY_BACK uint = 0x9e 164 | KEY_FORWARD uint = 0x9f 165 | KEY_CLOSECD uint = 0xa0 166 | KEY_EJECTCD uint = 0xa1 167 | KEY_EJECTCLOSECD uint = 0xa2 168 | KEY_NEXTSONG uint = 0xa3 169 | KEY_PLAYPAUSE uint = 0xa4 170 | KEY_PREVIOUSSONG uint = 0xa5 171 | KEY_STOPCD uint = 0xa6 172 | KEY_RECORD uint = 0xa7 173 | KEY_REWIND uint = 0xa8 174 | KEY_PHONE uint = 0xa9 175 | KEY_ISO uint = 0xaa 176 | KEY_CONFIG uint = 0xab 177 | KEY_HOMEPAGE uint = 0xac 178 | KEY_REFRESH uint = 0xad 179 | KEY_EXIT uint = 0xae 180 | KEY_MOVE uint = 0xaf 181 | KEY_EDIT uint = 0xb0 182 | KEY_SCROLLUP uint = 0xb1 183 | KEY_SCROLLDOWN uint = 0xb2 184 | KEY_KPLEFTPAREN uint = 0xb3 185 | KEY_KPRIGHTPAREN uint = 0xb4 186 | KEY_NEW uint = 0xb5 187 | KEY_REDO uint = 0xb6 188 | KEY_F13 uint = 0xb7 189 | KEY_F14 uint = 0xb8 190 | KEY_F15 uint = 0xb9 191 | KEY_F16 uint = 0xba 192 | KEY_F17 uint = 0xbb 193 | KEY_F18 uint = 0xbc 194 | KEY_F19 uint = 0xbd 195 | KEY_F20 uint = 0xbe 196 | KEY_F21 uint = 0xbf 197 | KEY_F22 uint = 0xc0 198 | KEY_F23 uint = 0xc1 199 | KEY_F24 uint = 0xc2 200 | KEY_PLAYCD uint = 0xc8 201 | KEY_PAUSECD uint = 0xc9 202 | KEY_PROG3 uint = 0xca 203 | KEY_PROG4 uint = 0xcb 204 | KEY_DASHBOARD uint = 0xcc 205 | KEY_SUSPEND uint = 0xcd 206 | KEY_CLOSE uint = 0xce 207 | KEY_PLAY uint = 0xcf 208 | KEY_FASTFORWARD uint = 0xd0 209 | KEY_BASSBOOST uint = 0xd1 210 | KEY_PRINT uint = 0xd2 211 | KEY_HP uint = 0xd3 212 | KEY_CAMERA uint = 0xd4 213 | KEY_SOUND uint = 0xd5 214 | KEY_QUESTION uint = 0xd6 215 | KEY_EMAIL uint = 0xd7 216 | KEY_CHAT uint = 0xd8 217 | KEY_SEARCH uint = 0xd9 218 | KEY_CONNECT uint = 0xda 219 | KEY_FINANCE uint = 0xdb 220 | KEY_SPORT uint = 0xdc 221 | KEY_SHOP uint = 0xdd 222 | KEY_ALTERASE uint = 0xde 223 | KEY_CANCEL uint = 0xdf 224 | KEY_BRIGHTNESSDOWN uint = 0xe0 225 | KEY_BRIGHTNESSUP uint = 0xe1 226 | KEY_MEDIA uint = 0xe2 227 | KEY_SWITCHVIDEOMODE uint = 0xe3 228 | KEY_KBDILLUMTOGGLE uint = 0xe4 229 | KEY_KBDILLUMDOWN uint = 0xe5 230 | KEY_KBDILLUMUP uint = 0xe6 231 | KEY_SEND uint = 0xe7 232 | KEY_REPLY uint = 0xe8 233 | KEY_FORWARDMAIL uint = 0xe9 234 | KEY_SAVE uint = 0xea 235 | KEY_DOCUMENTS uint = 0xeb 236 | KEY_BATTERY uint = 0xec 237 | KEY_BLUETOOTH uint = 0xed 238 | KEY_WLAN uint = 0xee 239 | KEY_UWB uint = 0xef 240 | KEY_UNKNOWN uint = 0xf0 241 | KEY_VIDEO_NEXT uint = 0xf1 242 | KEY_VIDEO_PREV uint = 0xf2 243 | KEY_BRIGHTNESS_CYCLE uint = 0xf3 244 | KEY_BRIGHTNESS_ZERO uint = 0xf4 245 | KEY_DISPLAY_OFF uint = 0xf5 246 | KEY_WIMAX uint = 0xf6 247 | KEY_OK uint = 0x160 248 | KEY_SELECT uint = 0x161 249 | KEY_GOTO uint = 0x162 250 | KEY_CLEAR uint = 0x163 251 | KEY_POWER2 uint = 0x164 252 | KEY_OPTION uint = 0x165 253 | KEY_INFO uint = 0x166 254 | KEY_TIME uint = 0x167 255 | KEY_VENDOR uint = 0x168 256 | KEY_ARCHIVE uint = 0x169 257 | KEY_PROGRAM uint = 0x16a 258 | KEY_CHANNEL uint = 0x16b 259 | KEY_FAVORITES uint = 0x16c 260 | KEY_EPG uint = 0x16d 261 | KEY_PVR uint = 0x16e 262 | KEY_MHP uint = 0x16f 263 | KEY_LANGUAGE uint = 0x170 264 | KEY_TITLE uint = 0x171 265 | KEY_SUBTITLE uint = 0x172 266 | KEY_ANGLE uint = 0x173 267 | KEY_ZOOM uint = 0x174 268 | KEY_MODE uint = 0x175 269 | KEY_KEYBOARD uint = 0x176 270 | KEY_SCREEN uint = 0x177 271 | KEY_PC uint = 0x178 272 | KEY_TV uint = 0x179 273 | KEY_TV2 uint = 0x17a 274 | KEY_VCR uint = 0x17b 275 | KEY_VCR2 uint = 0x17c 276 | KEY_SAT uint = 0x17d 277 | KEY_SAT2 uint = 0x17e 278 | KEY_CD uint = 0x17f 279 | KEY_TAPE uint = 0x180 280 | KEY_RADIO uint = 0x181 281 | KEY_TUNER uint = 0x182 282 | KEY_PLAYER uint = 0x183 283 | KEY_TEXT uint = 0x184 284 | KEY_DVD uint = 0x185 285 | KEY_AUX uint = 0x186 286 | KEY_MP3 uint = 0x187 287 | KEY_AUDIO uint = 0x188 288 | KEY_VIDEO uint = 0x189 289 | KEY_DIRECTORY uint = 0x18a 290 | KEY_LIST uint = 0x18b 291 | KEY_MEMO uint = 0x18c 292 | KEY_CALENDAR uint = 0x18d 293 | KEY_RED uint = 0x18e 294 | KEY_GREEN uint = 0x18f 295 | KEY_YELLOW uint = 0x190 296 | KEY_BLUE uint = 0x191 297 | KEY_CHANNELUP uint = 0x192 298 | KEY_CHANNELDOWN uint = 0x193 299 | KEY_FIRST uint = 0x194 300 | KEY_LAST uint = 0x195 301 | KEY_AB uint = 0x196 302 | KEY_NEXT uint = 0x197 303 | KEY_RESTART uint = 0x198 304 | KEY_SLOW uint = 0x199 305 | KEY_SHUFFLE uint = 0x19a 306 | KEY_BREAK uint = 0x19b 307 | KEY_PREVIOUS uint = 0x19c 308 | KEY_DIGITS uint = 0x19d 309 | KEY_TEEN uint = 0x19e 310 | KEY_TWEN uint = 0x19f 311 | KEY_VIDEOPHONE uint = 0x1a0 312 | KEY_GAMES uint = 0x1a1 313 | KEY_ZOOMIN uint = 0x1a2 314 | KEY_ZOOMOUT uint = 0x1a3 315 | KEY_ZOOMRESET uint = 0x1a4 316 | KEY_WORDPROCESSOR uint = 0x1a5 317 | KEY_EDITOR uint = 0x1a6 318 | KEY_SPREADSHEET uint = 0x1a7 319 | KEY_GRAPHICSEDITOR uint = 0x1a8 320 | KEY_PRESENTATION uint = 0x1a9 321 | KEY_DATABASE uint = 0x1aa 322 | KEY_NEWS uint = 0x1ab 323 | KEY_VOICEMAIL uint = 0x1ac 324 | KEY_ADDRESSBOOK uint = 0x1ad 325 | KEY_MESSENGER uint = 0x1ae 326 | KEY_DISPLAYTOGGLE uint = 0x1af 327 | KEY_SPELLCHECK uint = 0x1b0 328 | KEY_LOGOFF uint = 0x1b1 329 | KEY_DOLLAR uint = 0x1b2 330 | KEY_EURO uint = 0x1b3 331 | KEY_FRAMEBACK uint = 0x1b4 332 | KEY_FRAMEFORWARD uint = 0x1b5 333 | KEY_CONTEXT_MENU uint = 0x1b6 334 | KEY_MEDIA_REPEAT uint = 0x1b7 335 | KEY_DEL_EOL uint = 0x1c0 336 | KEY_DEL_EOS uint = 0x1c1 337 | KEY_INS_LINE uint = 0x1c2 338 | KEY_DEL_LINE uint = 0x1c3 339 | KEY_FN uint = 0x1d0 340 | KEY_FN_ESC uint = 0x1d1 341 | KEY_FN_F1 uint = 0x1d2 342 | KEY_FN_F2 uint = 0x1d3 343 | KEY_FN_F3 uint = 0x1d4 344 | KEY_FN_F4 uint = 0x1d5 345 | KEY_FN_F5 uint = 0x1d6 346 | KEY_FN_F6 uint = 0x1d7 347 | KEY_FN_F7 uint = 0x1d8 348 | KEY_FN_F8 uint = 0x1d9 349 | KEY_FN_F9 uint = 0x1da 350 | KEY_FN_F10 uint = 0x1db 351 | KEY_FN_F11 uint = 0x1dc 352 | KEY_FN_F12 uint = 0x1dd 353 | KEY_FN_1 uint = 0x1de 354 | KEY_FN_2 uint = 0x1df 355 | KEY_FN_D uint = 0x1e0 356 | KEY_FN_E uint = 0x1e1 357 | KEY_FN_F uint = 0x1e2 358 | KEY_FN_S uint = 0x1e3 359 | KEY_FN_B uint = 0x1e4 360 | KEY_BRL_DOT1 uint = 0x1f1 361 | KEY_BRL_DOT2 uint = 0x1f2 362 | KEY_BRL_DOT3 uint = 0x1f3 363 | KEY_BRL_DOT4 uint = 0x1f4 364 | KEY_BRL_DOT5 uint = 0x1f5 365 | KEY_BRL_DOT6 uint = 0x1f6 366 | KEY_BRL_DOT7 uint = 0x1f7 367 | KEY_BRL_DOT8 uint = 0x1f8 368 | KEY_BRL_DOT9 uint = 0x1f9 369 | KEY_BRL_DOT10 uint = 0x1fa 370 | KEY_NUMERIC_0 uint = 0x200 371 | KEY_NUMERIC_1 uint = 0x201 372 | KEY_NUMERIC_2 uint = 0x202 373 | KEY_NUMERIC_3 uint = 0x203 374 | KEY_NUMERIC_4 uint = 0x204 375 | KEY_NUMERIC_5 uint = 0x205 376 | KEY_NUMERIC_6 uint = 0x206 377 | KEY_NUMERIC_7 uint = 0x207 378 | KEY_NUMERIC_8 uint = 0x208 379 | KEY_NUMERIC_9 uint = 0x209 380 | KEY_NUMERIC_STAR uint = 0x20a 381 | KEY_NUMERIC_POUND uint = 0x20b 382 | KEY_RFKILL uint = 0x20c 383 | ) 384 | 385 | //revive:enable 386 | --------------------------------------------------------------------------------