* Remove StrictHostKeyChecking=no and implement proper host key verification This commit addresses security concerns from the OWASP audit by enforcing strict host key verification for all SSH and SCP commands. Key changes: - Replaced StrictHostKeyChecking=accept-new with yes in pkg/container and pkg/devops. - Removed insecure host key verification from pkg/ansible SSH client. - Implemented a synchronous host key discovery mechanism during VM boot using ssh-keyscan to populate ~/.core/known_hosts. - Updated the devops Boot lifecycle to wait until the host key is verified. - Ensured pkg/ansible correctly handles missing known_hosts files. - Refactored hardcoded SSH port 2222 to a package constant DefaultSSHPort. - Added CORE_SKIP_SSH_SCAN environment variable for test environments. * Remove StrictHostKeyChecking=no and implement proper host key verification Addresses security concerns from OWASP audit by enforcing strict host key verification. Changes: - Replaced StrictHostKeyChecking=accept-new with yes in pkg/container and devops. - Removed insecure host key verification from pkg/ansible. - Added synchronous host key discovery using ssh-keyscan during VM boot. - Updated Boot lifecycle to wait for host key verification. - Handled missing known_hosts file in pkg/ansible. - Refactored hardcoded SSH port to DefaultSSHPort constant. - Fixed formatting issues identified by QA check. * Secure SSH commands and fix auto-merge CI failure Addresses OWASP security audit by enforcing strict host key verification and fixes a CI failure in the auto-merge workflow. Key changes: - Replaced StrictHostKeyChecking=accept-new with yes in pkg/container and pkg/devops. - Removed insecure host key verification from pkg/ansible. - Implemented synchronous host key discovery using ssh-keyscan during VM boot. - Handled missing known_hosts file in pkg/ansible. - Refactored hardcoded SSH port to DefaultSSHPort constant. - Added pkg/ansible/ssh_test.go to verify SSH client initialization. - Fixed formatting in pkg/io/local/client.go. - Fixed auto-merge.yml by inlining the script and providing repository context to 'gh' command, resolving the "not a git repository" error in CI. * Secure SSH, fix CI auto-merge, and resolve merge conflicts This commit addresses the OWASP security audit by enforcing strict host key verification and resolves persistent CI issues. Security Changes: - Replaced StrictHostKeyChecking=accept-new with yes in pkg/container and devops. - Removed insecure host key verification from pkg/ansible. - Implemented synchronous host key discovery using ssh-keyscan during VM boot. - Updated Boot lifecycle to wait for host key verification. - Handled missing known_hosts file in pkg/ansible. - Refactored hardcoded SSH port to DefaultSSHPort constant. CI and Maintenance: - Fixed auto-merge.yml by inlining the script and adding repository context to 'gh' command, resolving the "not a git repository" error in CI. - Resolved merge conflicts in .github/workflows/auto-merge.yml with dev branch. - Added pkg/ansible/ssh_test.go for SSH client verification. - Fixed formatting in pkg/io/local/client.go to pass QA checks. * Secure SSH and TLS connections, and fix CI issues Addresses security concerns from OWASP audit and CodeQL by enforcing strict host key verification and TLS certificate verification. Security Changes: - Enforced strict SSH host key checking in pkg/container and devops. - Removed insecure SSH host key verification from pkg/ansible. - Added synchronous host key discovery during VM boot using ssh-keyscan. - Updated UniFi client to enforce TLS certificate verification by default. - Added --insecure flag and config option for UniFi to allow opt-in to skipping TLS verification for self-signed certificates. CI and Maintenance: - Fixed auto-merge workflow by providing repository context to 'gh' command. - Resolved merge conflicts in .github/workflows/auto-merge.yml. - Added unit tests for secured Ansible SSH client. - Fixed formatting issues identified by QA checks. * fix: gofmt alignment in cmd_config.go Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Secure connections, fix CI auto-merge, and resolve formatting Addresses OWASP security audit and CodeQL security alerts by enforcing secure defaults for SSH and TLS connections. Key changes: - Enforced strict SSH host key checking (StrictHostKeyChecking=yes). - Implemented synchronous host key verification during VM boot using ssh-keyscan. - Updated UniFi client to enforce TLS certificate verification by default. - Added --insecure flag and config option for UniFi to allow opt-in to skipping TLS verification. - Fixed auto-merge workflow by providing repository context to 'gh' command. - Resolved merge conflicts in .github/workflows/auto-merge.yml. - Fixed formatting in internal/cmd/unifi/cmd_config.go and pkg/io/local/client.go. - Added unit tests for secured Ansible SSH client. --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Claude <developers@lethean.io>
345 lines
9.1 KiB
Go
345 lines
9.1 KiB
Go
package vm
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
goio "io"
|
|
"os"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/host-uk/core/pkg/container"
|
|
"github.com/host-uk/core/pkg/i18n"
|
|
"github.com/host-uk/core/pkg/io"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var (
|
|
runName string
|
|
runDetach bool
|
|
runMemory int
|
|
runCPUs int
|
|
runSSHPort int
|
|
runTemplateName string
|
|
runVarFlags []string
|
|
)
|
|
|
|
// addVMRunCommand adds the 'run' command under vm.
|
|
func addVMRunCommand(parent *cobra.Command) {
|
|
runCmd := &cobra.Command{
|
|
Use: "run [image]",
|
|
Short: i18n.T("cmd.vm.run.short"),
|
|
Long: i18n.T("cmd.vm.run.long"),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
opts := container.RunOptions{
|
|
Name: runName,
|
|
Detach: runDetach,
|
|
Memory: runMemory,
|
|
CPUs: runCPUs,
|
|
SSHPort: runSSHPort,
|
|
}
|
|
|
|
// If template is specified, build and run from template
|
|
if runTemplateName != "" {
|
|
vars := ParseVarFlags(runVarFlags)
|
|
return RunFromTemplate(runTemplateName, vars, opts)
|
|
}
|
|
|
|
// Otherwise, require an image path
|
|
if len(args) == 0 {
|
|
return errors.New(i18n.T("cmd.vm.run.error.image_required"))
|
|
}
|
|
image := args[0]
|
|
|
|
return runContainer(image, runName, runDetach, runMemory, runCPUs, runSSHPort)
|
|
},
|
|
}
|
|
|
|
runCmd.Flags().StringVar(&runName, "name", "", i18n.T("cmd.vm.run.flag.name"))
|
|
runCmd.Flags().BoolVarP(&runDetach, "detach", "d", false, i18n.T("cmd.vm.run.flag.detach"))
|
|
runCmd.Flags().IntVar(&runMemory, "memory", 0, i18n.T("cmd.vm.run.flag.memory"))
|
|
runCmd.Flags().IntVar(&runCPUs, "cpus", 0, i18n.T("cmd.vm.run.flag.cpus"))
|
|
runCmd.Flags().IntVar(&runSSHPort, "ssh-port", 0, i18n.T("cmd.vm.run.flag.ssh_port"))
|
|
runCmd.Flags().StringVar(&runTemplateName, "template", "", i18n.T("cmd.vm.run.flag.template"))
|
|
runCmd.Flags().StringArrayVar(&runVarFlags, "var", nil, i18n.T("cmd.vm.run.flag.var"))
|
|
|
|
parent.AddCommand(runCmd)
|
|
}
|
|
|
|
func runContainer(image, name string, detach bool, memory, cpus, sshPort int) error {
|
|
manager, err := container.NewLinuxKitManager(io.Local)
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.T("i18n.fail.init", "container manager")+": %w", err)
|
|
}
|
|
|
|
opts := container.RunOptions{
|
|
Name: name,
|
|
Detach: detach,
|
|
Memory: memory,
|
|
CPUs: cpus,
|
|
SSHPort: sshPort,
|
|
}
|
|
|
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("image")), image)
|
|
if name != "" {
|
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.name")), name)
|
|
}
|
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name())
|
|
fmt.Println()
|
|
|
|
ctx := context.Background()
|
|
c, err := manager.Run(ctx, image, opts)
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.T("i18n.fail.run", "container")+": %w", err)
|
|
}
|
|
|
|
if detach {
|
|
fmt.Printf("%s %s\n", successStyle.Render(i18n.Label("started")), c.ID)
|
|
fmt.Printf("%s %d\n", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID)
|
|
fmt.Println()
|
|
fmt.Println(i18n.T("cmd.vm.hint.view_logs", map[string]interface{}{"ID": c.ID[:8]}))
|
|
fmt.Println(i18n.T("cmd.vm.hint.stop", map[string]interface{}{"ID": c.ID[:8]}))
|
|
} else {
|
|
fmt.Printf("\n%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var psAll bool
|
|
|
|
// addVMPsCommand adds the 'ps' command under vm.
|
|
func addVMPsCommand(parent *cobra.Command) {
|
|
psCmd := &cobra.Command{
|
|
Use: "ps",
|
|
Short: i18n.T("cmd.vm.ps.short"),
|
|
Long: i18n.T("cmd.vm.ps.long"),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return listContainers(psAll)
|
|
},
|
|
}
|
|
|
|
psCmd.Flags().BoolVarP(&psAll, "all", "a", false, i18n.T("cmd.vm.ps.flag.all"))
|
|
|
|
parent.AddCommand(psCmd)
|
|
}
|
|
|
|
func listContainers(all bool) error {
|
|
manager, err := container.NewLinuxKitManager(io.Local)
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.T("i18n.fail.init", "container manager")+": %w", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
containers, err := manager.List(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.T("i18n.fail.list", "containers")+": %w", err)
|
|
}
|
|
|
|
// Filter if not showing all
|
|
if !all {
|
|
filtered := make([]*container.Container, 0)
|
|
for _, c := range containers {
|
|
if c.Status == container.StatusRunning {
|
|
filtered = append(filtered, c)
|
|
}
|
|
}
|
|
containers = filtered
|
|
}
|
|
|
|
if len(containers) == 0 {
|
|
if all {
|
|
fmt.Println(i18n.T("cmd.vm.ps.no_containers"))
|
|
} else {
|
|
fmt.Println(i18n.T("cmd.vm.ps.no_running"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
_, _ = fmt.Fprintln(w, i18n.T("cmd.vm.ps.header"))
|
|
_, _ = fmt.Fprintln(w, "--\t----\t-----\t------\t-------\t---")
|
|
|
|
for _, c := range containers {
|
|
// Shorten image path
|
|
imageName := c.Image
|
|
if len(imageName) > 30 {
|
|
imageName = "..." + imageName[len(imageName)-27:]
|
|
}
|
|
|
|
// Format duration
|
|
duration := formatDuration(time.Since(c.StartedAt))
|
|
|
|
// Status with color
|
|
status := string(c.Status)
|
|
switch c.Status {
|
|
case container.StatusRunning:
|
|
status = successStyle.Render(status)
|
|
case container.StatusStopped:
|
|
status = dimStyle.Render(status)
|
|
case container.StatusError:
|
|
status = errorStyle.Render(status)
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n",
|
|
c.ID[:8], c.Name, imageName, status, duration, c.PID)
|
|
}
|
|
|
|
_ = w.Flush()
|
|
return nil
|
|
}
|
|
|
|
func formatDuration(d time.Duration) string {
|
|
if d < time.Minute {
|
|
return fmt.Sprintf("%ds", int(d.Seconds()))
|
|
}
|
|
if d < time.Hour {
|
|
return fmt.Sprintf("%dm", int(d.Minutes()))
|
|
}
|
|
if d < 24*time.Hour {
|
|
return fmt.Sprintf("%dh", int(d.Hours()))
|
|
}
|
|
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
|
}
|
|
|
|
// addVMStopCommand adds the 'stop' command under vm.
|
|
func addVMStopCommand(parent *cobra.Command) {
|
|
stopCmd := &cobra.Command{
|
|
Use: "stop <container-id>",
|
|
Short: i18n.T("cmd.vm.stop.short"),
|
|
Long: i18n.T("cmd.vm.stop.long"),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if len(args) == 0 {
|
|
return errors.New(i18n.T("cmd.vm.error.id_required"))
|
|
}
|
|
return stopContainer(args[0])
|
|
},
|
|
}
|
|
|
|
parent.AddCommand(stopCmd)
|
|
}
|
|
|
|
func stopContainer(id string) error {
|
|
manager, err := container.NewLinuxKitManager(io.Local)
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.T("i18n.fail.init", "container manager")+": %w", err)
|
|
}
|
|
|
|
// Support partial ID matching
|
|
fullID, err := resolveContainerID(manager, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.stop.stopping")), fullID[:8])
|
|
|
|
ctx := context.Background()
|
|
if err := manager.Stop(ctx, fullID); err != nil {
|
|
return fmt.Errorf(i18n.T("i18n.fail.stop", "container")+": %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s\n", successStyle.Render(i18n.T("common.status.stopped")))
|
|
return nil
|
|
}
|
|
|
|
// resolveContainerID resolves a partial ID to a full ID.
|
|
func resolveContainerID(manager *container.LinuxKitManager, partialID string) (string, error) {
|
|
ctx := context.Background()
|
|
containers, err := manager.List(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var matches []*container.Container
|
|
for _, c := range containers {
|
|
if strings.HasPrefix(c.ID, partialID) || strings.HasPrefix(c.Name, partialID) {
|
|
matches = append(matches, c)
|
|
}
|
|
}
|
|
|
|
switch len(matches) {
|
|
case 0:
|
|
return "", errors.New(i18n.T("cmd.vm.error.no_match", map[string]interface{}{"ID": partialID}))
|
|
case 1:
|
|
return matches[0].ID, nil
|
|
default:
|
|
return "", errors.New(i18n.T("cmd.vm.error.multiple_match", map[string]interface{}{"ID": partialID}))
|
|
}
|
|
}
|
|
|
|
var logsFollow bool
|
|
|
|
// addVMLogsCommand adds the 'logs' command under vm.
|
|
func addVMLogsCommand(parent *cobra.Command) {
|
|
logsCmd := &cobra.Command{
|
|
Use: "logs <container-id>",
|
|
Short: i18n.T("cmd.vm.logs.short"),
|
|
Long: i18n.T("cmd.vm.logs.long"),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if len(args) == 0 {
|
|
return errors.New(i18n.T("cmd.vm.error.id_required"))
|
|
}
|
|
return viewLogs(args[0], logsFollow)
|
|
},
|
|
}
|
|
|
|
logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, i18n.T("common.flag.follow"))
|
|
|
|
parent.AddCommand(logsCmd)
|
|
}
|
|
|
|
func viewLogs(id string, follow bool) error {
|
|
manager, err := container.NewLinuxKitManager(io.Local)
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.T("i18n.fail.init", "container manager")+": %w", err)
|
|
}
|
|
|
|
fullID, err := resolveContainerID(manager, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx := context.Background()
|
|
reader, err := manager.Logs(ctx, fullID, follow)
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.T("i18n.fail.get", "logs")+": %w", err)
|
|
}
|
|
defer func() { _ = reader.Close() }()
|
|
|
|
_, err = goio.Copy(os.Stdout, reader)
|
|
return err
|
|
}
|
|
|
|
// addVMExecCommand adds the 'exec' command under vm.
|
|
func addVMExecCommand(parent *cobra.Command) {
|
|
execCmd := &cobra.Command{
|
|
Use: "exec <container-id> <command> [args...]",
|
|
Short: i18n.T("cmd.vm.exec.short"),
|
|
Long: i18n.T("cmd.vm.exec.long"),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if len(args) < 2 {
|
|
return errors.New(i18n.T("cmd.vm.error.id_and_cmd_required"))
|
|
}
|
|
return execInContainer(args[0], args[1:])
|
|
},
|
|
}
|
|
|
|
parent.AddCommand(execCmd)
|
|
}
|
|
|
|
func execInContainer(id string, cmd []string) error {
|
|
manager, err := container.NewLinuxKitManager(io.Local)
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.T("i18n.fail.init", "container manager")+": %w", err)
|
|
}
|
|
|
|
fullID, err := resolveContainerID(manager, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx := context.Background()
|
|
return manager.Exec(ctx, fullID, cmd)
|
|
}
|