blob: ec0d2bcfcb31d0c7dcf95089dd82d0493316d3dc [file] [log] [blame]
// This program generates passwordless Remmina[1] RDP/VNC connection profiles to connect to Skolo
// Windows machines from a gLinux workstation (physical or cloudtop). It asks the user to type the
// chrome-bot password and passes it to Remmina via a stdin pipe. Remmina securely stores this
// password in the gnome-keyring or kwallet.
//
// Important: The connection profiles establish an SSH tunnel through the corresponding jumphost
// using passwordless public key authentication. Make sure you're able to passwordlessly SSH into
// rack1, rack2, etc. before using the generated connection profiles.
//
// Usage (from the repository root):
//
// $ bazel run //skolo/go/generate_remmina_profiles -- skolo/ansible/hosts.yml
//
// Then, launch Remmina and double-click on a connection profile to initiate a session.
//
// Invoke with --help to see additional options such as overriding RDP/VNC port numbers, wiping
// the existing contents of the directory where Remmina profiles live (~/.local/share/remmina),
// customizing said directory, etc.
//
// Note that there is nothing specific to Windows about this program, so in theory we could extend
// it to generate connection profiles for Macs or any other OSes that can run a VNC or RDP server.
//
// [1] https://remmina.org
package main
import (
"flag"
"fmt"
"html/template"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"syscall"
"golang.org/x/term"
)
type Protocol string
const (
RDP = Protocol("RDP")
VNC = Protocol("VNC")
)
// connProfile is used to populate a connection profile template.
type connProfile struct {
Name string // e.g. "skia-e-win-150 (VNC)"
Server string // e.g. "skia-e-win-150:5900"
SSHTunnelServer string // e.g. "rack1"
}
const rdpConnProfileTmpl = `[remmina]
password=.
gateway_username=
notes_text=
vc=
window_height=768
preferipv6=0
ssh_tunnel_loopback=0
serialname=
websockets=0
printer_overrides=
name={{.Name}}
colordepth=99
security=
precommand=
disable_fastpath=0
left-handed=0
postcommand=
multitransport=0
group=
server={{.Server}}
ssh_tunnel_certfile=
glyph-cache=0
ssh_tunnel_enabled=1
disableclipboard=0
parallelpath=
audio-output=
monitorids=
cert_ignore=1
serialpermissive=0
gateway_server=
protocol=RDP
ssh_tunnel_password=
old-license=0
resolution_mode=2
pth=
loadbalanceinfo=
disableautoreconnect=0
clientbuild=
clientname=
resolution_width=0
drive=
relax-order-checks=0
username=chrome-bot
base-cred-for-gw=0
gateway_domain=
network=none
rdp2tcp=
gateway_password=
rdp_reconnect_attempts=
domain=
serialdriver=
restricted-admin=0
multimon=0
serialpath=
exec=
smartcardname=
enable-autostart=0
usb=
shareprinter=0
ssh_tunnel_passphrase=
shareparallel=0
disablepasswordstoring=0
quality=0
span=0
viewmode=1
parallelname=
ssh_tunnel_auth=3
keymap=
ssh_tunnel_username=chrome-bot
execpath=
shareserial=0
resolution_height=0
timeout=
useproxyenv=0
sharesmartcard=0
freerdp_log_filters=
microphone=
dvc=
ssh_tunnel_privatekey=
gwtransp=http
ssh_tunnel_server={{.SSHTunnelServer}}
ignore-tls-errors=1
window_maximize=0
disable-smooth-scrolling=0
gateway_usage=0
window_width=1024
freerdp_log_level=INFO
sound=off
`
const vncConnProfileTmpl = `[remmina]
password=.
gateway_username=
notes_text=
vc=
window_height=768
preferipv6=0
ssh_tunnel_loopback=0
serialname=
sound=off
disableserverbell=0
printer_overrides=
name={{.Name}}
console=0
colordepth=32
security=
precommand=
disable_fastpath=0
tightencoding=0
left-handed=0
postcommand=
multitransport=0
group=
server={{.Server}}
viewonly=0
ssh_tunnel_certfile=
glyph-cache=0
ssh_tunnel_enabled=1
disableclipboard=0
parallelpath=
audio-output=
monitorids=
cert_ignore=0
serialpermissive=0
gateway_server=
protocol=VNC
ssh_tunnel_password=
old-license=0
resolution_mode=2
pth=
loadbalanceinfo=
disableautoreconnect=0
clientbuild=
clientname=
disablesmoothscrolling=0
resolution_width=0
drive=
relax-order-checks=0
username=
base-cred-for-gw=0
gateway_domain=
network=none
rdp2tcp=
gateway_password=
rdp_reconnect_attempts=
domain=
serialdriver=
restricted-admin=0
multimon=0
serialpath=
exec=
smartcardname=
disableserverinput=0
enable-autostart=0
usb=
shareprinter=0
ssh_tunnel_passphrase=
shareparallel=0
proxy=
disablepasswordstoring=0
quality=0
span=0
viewmode=1
parallelname=
ssh_tunnel_auth=3
keymap=
ssh_tunnel_username=chrome-bot
execpath=
shareserial=0
resolution_height=0
timeout=
useproxyenv=0
sharesmartcard=0
freerdp_log_filters=
microphone=
dvc=
ssh_tunnel_privatekey=
gwtransp=http
ssh_tunnel_server={{.SSHTunnelServer}}
ignore-tls-errors=1
window_maximize=0
showcursor=0
disable-smooth-scrolling=0
gateway_usage=0
disableencryption=0
window_width=1024
encodings=
websockets=0
freerdp_log_level=INFO
`
var (
rackRegexp = regexp.MustCompile(`^rack\d+$`)
winMachineRegexp = regexp.MustCompile(`^skia-e-win-\d+$`)
)
func main() {
outputDir := flag.String("output-dir", filepath.Join(os.Getenv("HOME"), ".local", "share", "remmina"), "Output directory.")
wipeOutputDir := flag.Bool("wipe", false, "Wipe contents of output directory before generating connection profiles.")
genRDPProfiles := flag.Bool("rdp", true, "Generate RDP connection profiles.")
genVNCProfiles := flag.Bool("vnc", true, "Generate VNC connection profiles.")
rdpPort := flag.Int("rdp-port", 3389, "TCP port used for RDP connections.")
vncPort := flag.Int("vnc-port", 5900, "TCP port used for VNC connections.")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [OPTIONS] <path to Ansible inventory file>\n", os.Args[0])
flag.PrintDefaults()
}
flag.Parse()
if flag.NArg() != 1 {
flag.Usage()
os.Exit(1)
}
ansibleInventoryFilePath := flag.Arg(0)
// If running via "bazel run", change into the directory where Bazel was invoked. This is
// necessary to correctly compute the absolute path of the Ansible inventory file.
if os.Getenv("BUILD_WORKING_DIRECTORY") != "" {
ifErrThenDie(os.Chdir(os.Getenv("BUILD_WORKING_DIRECTORY")))
}
if *rdpPort < 1 || *rdpPort > 65535 {
die("Invalid RDP port.")
}
if *vncPort < 1 || *vncPort > 65535 {
die("Invalid VNC port.")
}
machinesByJumphost, err := getMachinesByJumphost(ansibleInventoryFilePath)
ifErrThenDie(err)
// Read in the chrome-bot password. Remmina will encrypt it with gnome-keyring or kwallet.
fmt.Printf("Please enter the chrome-bot password: ")
chromeBotPassword, err := term.ReadPassword(int(syscall.Stdin))
ifErrThenDie(err)
fmt.Println() // term.ReadPassword does not produce a newline when the user presses enter.
// Create the output directory if it does not exist.
if _, err := os.Stat(*outputDir); err != nil {
if os.IsNotExist(err) {
ifErrThenDie(os.Mkdir(*outputDir, 0755))
} else {
die(err)
}
}
// Optionally wipe the contents of the output directory.
if *wipeOutputDir {
files, err := os.ReadDir(*outputDir)
ifErrThenDie(err)
for _, file := range files {
ifErrThenDie(os.RemoveAll(filepath.Join(*outputDir, file.Name())))
}
}
// Prepare the requested (protocol, port) pairs.
type protocolPortPair struct {
name Protocol
port int
connProfileTmpl string
}
var protocols []protocolPortPair
if *genRDPProfiles {
protocols = append(protocols, protocolPortPair{RDP, *rdpPort, rdpConnProfileTmpl})
}
if *genVNCProfiles {
protocols = append(protocols, protocolPortPair{VNC, *vncPort, vncConnProfileTmpl})
}
// Generate connection profiles for all known machines.
for jumphost, machines := range machinesByJumphost {
for _, machine := range machines {
for _, protocol := range protocols {
// Generate Remmina profile.
profilePath := filepath.Join(*outputDir, fmt.Sprintf("%s-%d-%s.remmina", machine, protocol.port, protocol.name))
profile := connProfile{
Name: fmt.Sprintf("%s:%d (%s)", machine, protocol.port, protocol.name),
Server: fmt.Sprintf("%s:%d", machine, protocol.port),
SSHTunnelServer: jumphost,
}
tmpl, err := template.New("connProfileTmpl").Parse(protocol.connProfileTmpl)
ifErrThenDie(err)
f, err := os.Create(profilePath)
ifErrThenDie(err)
ifErrThenDie(tmpl.Execute(f, profile))
ifErrThenDie(f.Close())
fmt.Printf("Generated: %s\n", profilePath)
// Safely set and store the chrome-bot password. Remmina will read the plain-text
// password from stdin, and will encrypt it with gnome-keyring or kwallet.
cmd := exec.Command("remmina", "--update-profile", profilePath, "--set-option", "password")
stdin, err := cmd.StdinPipe()
ifErrThenDie(err)
go func() {
if _, err := io.WriteString(stdin, string(chromeBotPassword)); err != nil {
_ = stdin.Close()
die(err)
}
if err := stdin.Close(); err != nil {
die(err)
}
}()
fmt.Printf("Executing: %s\n", strings.Join(append([]string{cmd.Path}, cmd.Args...), " "))
out, err := cmd.CombinedOutput()
if err != nil {
if execerr, ok := err.(*exec.ExitError); ok {
fmt.Fprintf(os.Stderr, "Remmina returned a non-zero exit code: %d. Output:\n%s\n", execerr.ExitCode(), string(out))
os.Exit(1)
}
die(err)
}
}
}
}
}
func getMachinesByJumphost(ansibleInventoryFilePath string) (map[string][]string, error) {
racks, err := queryAnsibleInventory(ansibleInventoryFilePath, "all", rackRegexp)
if err != nil {
return nil, err
}
machinesByJumphost := map[string][]string{}
for _, rack := range racks {
hosts, err := queryAnsibleInventory(ansibleInventoryFilePath, rack+"_win", winMachineRegexp)
ifErrThenDie(err)
machinesByJumphost[rack] = hosts
}
return machinesByJumphost, nil
}
// queryAnsibleInventory returns a list hosts that match the given Ansible pattern (e.g. "all",
// "rack1", etc.) and the given regular expression.
func queryAnsibleInventory(ansibleInventoryFilePath, pattern string, hostsFilter *regexp.Regexp) ([]string, error) {
cmd := exec.Command("ansible", "--inventory", ansibleInventoryFilePath, "--list-hosts", pattern)
outBytes, err := cmd.CombinedOutput()
if err != nil {
return nil, err
}
rawLines := strings.Split(string(outBytes), "\n")
// The output looks like so:
//
// $ ansible --inventory skolo/ansible/hosts.yml --list-hosts all
// hosts (275):
// rack1
// rack2
// rack3
// ...
//
// Thus, we discard the first line and trim any leading/trailing spaces on the remaining lines.
rawLines = rawLines[1:]
var hosts []string
for _, line := range rawLines {
line = strings.TrimSpace(line)
if hostsFilter.MatchString(line) {
hosts = append(hosts, line)
}
}
return hosts, nil
}
func ifErrThenDie(err error) {
if err != nil {
die(err)
}
}
func die(msg interface{}) {
fmt.Fprintf(os.Stderr, "%v\n", msg)
os.Exit(1)
}