Skip to content

Commit

Permalink
Add SCB support for ssh operations
Browse files Browse the repository at this point in the history
Shell Control Box (SCB) is an activity monitoring appliance from Balabit (now One Identity) that controls privileged access to remote servers.
Added support for using ops with SCB for the following operations: ssh, tunnel, proxy, ansible play, run and sync
  • Loading branch information
Alexandru Salajan committed Oct 5, 2021
1 parent ce7da77 commit b6fa056
Show file tree
Hide file tree
Showing 13 changed files with 251 additions and 26 deletions.
2 changes: 2 additions & 0 deletions src/ops/cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,7 @@ def configure_common_ansible_args(parser):
help='Ask sudo pass for commands that need sudo')
parser.add_argument('--limit', type=str,
help='Limit run to a specific server subgroup. Eg: --limit newton-dcs')
parser.add_argument('--noscb', action='store_false', dest='use_scb',
help='Disable use of Shell Control Box (SCB) even it is enabled in the cluster config')

return parser
9 changes: 7 additions & 2 deletions src/ops/cli/playbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@

from .parser import SubParserConfig
from .parser import configure_common_ansible_args, configure_common_arguments
from ops.inventory.sshconfig import SshConfigGenerator
import getpass
import logging

logger = logging.getLogger(__name__)


class PlaybookParserConfig(SubParserConfig):
def get_name(self):
return 'play'
Expand Down Expand Up @@ -70,9 +72,12 @@ def __init__(self, ops_config, root_dir, inventory_generator,

def run(self, args, extra_args):
logger.info("Found extra_args %s", extra_args)
inventory_path, ssh_config_path = self.inventory_generator.generate()
inventory_path, ssh_config_paths = self.inventory_generator.generate()
ssh_config_path = SshConfigGenerator.get_ssh_config_path(self.cluster_config,
ssh_config_paths,
args.use_scb)
ssh_config = f"ANSIBLE_SSH_ARGS='-F {ssh_config_path}'"

ssh_config = "ANSIBLE_SSH_ARGS='-F %s'" % ssh_config_path
ansible_config = "ANSIBLE_CONFIG=%s" % self.ops_config.ansible_config_path

# default user: read from cluster then from ops config then local user
Expand Down
7 changes: 5 additions & 2 deletions src/ops/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import logging
from .parser import configure_common_ansible_args, SubParserConfig
from ops.inventory.sshconfig import SshConfigGenerator

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -65,9 +66,11 @@ def __init__(self, ops_config, root_dir, inventory_generator,

def run(self, args, extra_args):
logger.info("Found extra_args %s", extra_args)
inventory_path, ssh_config_path = self.inventory_generator.generate()
inventory_path, ssh_config_paths = self.inventory_generator.generate()
limit = args.host_pattern

ssh_config_path = SshConfigGenerator.get_ssh_config_path(self.cluster_config,
ssh_config_paths,
args.use_scb)
extra_args = ' '.join(args.extra_args)
command = """cd {root_dir}
ANSIBLE_SSH_ARGS='-F {ssh_config}' ANSIBLE_CONFIG={ansible_config_path} ansible -i {inventory_path} '{limit}' \\
Expand Down
70 changes: 57 additions & 13 deletions src/ops/cli/ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .parser import SubParserConfig
from .parser import configure_common_arguments
from ansible.inventory.host import Host
from ops.inventory.sshconfig import SshConfigGenerator

from . import err
import sys
Expand Down Expand Up @@ -67,18 +68,29 @@ def configure(self, parser):
parser.add_argument(
'--proxy',
action="store_true",
help="Use SSH proxy, must pass --local")
help="Use SSH proxy, must pass --local or when using scb, either --auto_scb_port or"
"no extra option which will use scb.proxy_port from cluster_config")
parser.add_argument(
'--nossh',
action="store_true",
help="Port tunnel a machine that does not have SSH. "
help="Port tunnel a machine that does not have SSH. "
"Implies --ipaddress, and --tunnel; requires --local and --remote"
)
parser.add_argument(
'--keygen',
action='store_true',
help='Create a ssh keys pair to use with this infrastructure')

parser.add_argument(
'--noscb',
action='store_false',
dest='use_scb',
help='Disable use of Shell Control Box (SCB) even it is enabled in the cluster config')
parser.add_argument(
'--auto_scb_port',
action='store_true',
help='When using Shell Control Box (SCB) and creating a proxy, a random port is generated, '
'which will be used in the ssh config for all playbook, run and sync operations'
)
def get_help(self):
return 'SSH or create an SSH tunnel to a server in the cluster'

Expand Down Expand Up @@ -162,9 +174,19 @@ def run(self, args, extra_args):
err('When using --tunnel or --nossh both the --local and --remote parameters are required')
sys.exit(2)

scb_settings = self.cluster_config.get('scb', {})
scb_enabled = scb_settings.get('enabled') and args.use_scb
scb_host = scb_settings.get('host') or self.ops_config.get('scb.host')
scb_proxy_port = scb_settings.get('proxy_port')

if scb_enabled and not(scb_host):
err('When scb is enabled scb_host is required!')
sys.exit(2)

if args.proxy:
if args.local is None:
err('When using --proxy the --local parameter is required')
if args.local is None and (args.auto_scb_port is False and not scb_proxy_port):
err('When using --proxy the --local parameter is required if not using --auto_scb_port '
'and scb.proxy_port is not configured in the cluster config')
sys.exit(2)

group = "%s,&%s" % (self.cluster_name, args.role)
Expand Down Expand Up @@ -203,12 +225,12 @@ def run(self, args, extra_args):
bastion = self.ansible_inventory.get_hosts(
'bastion')[0].vars.get('ansible_ssh_host')
host = Host(name=args.role)
ssh_host = '{}--{}'.format(bastion, host.name)
ssh_host = f'{bastion}--{host.name}'
ssh_user = self.cluster_config.get('ssh_user') or self.ops_config.get(
'ssh.user') or getpass.getuser()
if args.user:
ssh_user = args.user
if ssh_user and not '-l' in args.ssh_opts:
if ssh_user and '-l' not in args.ssh_opts:
args.ssh_opts.extend(['-l', ssh_user])

if args.nossh:
Expand All @@ -224,22 +246,44 @@ def run(self, args, extra_args):
ssh_config = args.ssh_config or self.ops_config.get(
'ssh.config') or self.ansible_inventory.get_ssh_config()

scb_ssh_host = None
if scb_enabled:
# scb->bastion->host vs scb->bastion
scb_delimiter = "--" if "--" in ssh_host else "@"
scb_ssh_host = f"{ssh_host}{scb_delimiter}{scb_host}"

if args.tunnel:
if args.ipaddress:
host_ip = host.vars.get('private_ip_address')
else:
host_ip = 'localhost'
command = "ssh -F %s %s -4 -N -L %s:%s:%d" % (
ssh_config, ssh_host, args.local, host_ip, args.remote)
if scb_enabled:
command = f"ssh -F {ssh_config} {ssh_user}@{scb_ssh_host} " \
f"-4 -T -L {args.local}:{host_ip}:{args.remote:d}"
else:
command = f"ssh -F {ssh_config} {ssh_host} " \
f"-4 -N -L {args.local}:{host_ip}:{args.remote:d}"
else:
command = "ssh -F %s %s" % (ssh_config, ssh_host)
if scb_enabled:
command = f"ssh -F {ssh_config} {ssh_user}@{scb_ssh_host}"
else:
command = f"ssh -F {ssh_config} {ssh_host}"

if args.proxy:
command = "ssh -F %s %s -4 -N -D %s -f -o 'ExitOnForwardFailure yes'" % (
ssh_config, ssh_host, args.local)
if scb_enabled:
proxy_port = args.local or SshConfigGenerator.generate_ssh_scb_proxy_port(
self.ansible_inventory.generated_path.rstrip("/inventory"),
args.auto_scb_port,
scb_proxy_port
)
command = f"ssh -F {ssh_config} {ssh_user}@{scb_ssh_host} " \
f"-4 -T -D {proxy_port} -o 'ExitOnForwardFailure yes'"
else:
command = f"ssh -F {ssh_config} {ssh_host} " \
f"-4 -N -D {args.local} -f -o 'ExitOnForwardFailure yes'"

if args.ssh_opts:
command += " " + " ".join(args.ssh_opts)
command = f"{command} {' '.join(args.ssh_opts)}"

# Check if optional sshpass is available and print info message
sshpass_path = os.path.expanduser("~/bin/sshpass")
Expand Down
9 changes: 8 additions & 1 deletion src/ops/cli/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@

from .parser import SubParserConfig
from . import *
from ops.inventory.sshconfig import SshConfigGenerator

logger = logging.getLogger(__name__)


class SyncParserConfig(SubParserConfig):
def configure(self, parser):
parser.add_argument(
Expand All @@ -26,6 +28,8 @@ def configure(self, parser):
help='Value for remote user that will be used for ssh')
parser.add_argument('src', type=str, help='Source dir')
parser.add_argument('dest', type=str, help='Dest dir')
parser.add_argument('--noscb', action='store_false', dest='use_scb',
help='Disable use of Shell Control Box (SCB) even it is enabled in the cluster config')
parser.add_argument(
'opts',
default=['-va --progress'],
Expand Down Expand Up @@ -68,10 +72,13 @@ def __init__(self, cluster_config, root_dir,

def run(self, args, extra_args):
logger.info("Found extra_args %s", extra_args)
inventory_path, ssh_config_path = self.inventory_generator.generate()
inventory_path, ssh_config_paths = self.inventory_generator.generate()
src = PathExpr(args.src)
dest = PathExpr(args.dest)

ssh_config_path = SshConfigGenerator.get_ssh_config_path(self.cluster_config,
ssh_config_paths,
args.use_scb)
if src.is_remote and dest.is_remote:
display(
'Too remote expressions are not allowed',
Expand Down
10 changes: 10 additions & 0 deletions src/ops/data/ssh/ssh.config
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ Host *
StrictHostKeyChecking no
TCPKeepAlive yes

Host *--*--*
ForwardAgent yes
LogLevel QUIET
ProxyCommand ssh -o StrictHostKeyChecking=no -o ForwardAgent=yes -A %r@$(echo %h | sed -e 's/--.*//g')@$(echo %h | sed -e 's/.*--//g') nc $(echo %h | sed -e 's/.*--\(.*\)--.*/\1/') %p
SendEnv LANG LC_*
ServerAliveCountMax 2
ServerAliveInterval 30
StrictHostKeyChecking no
TCPKeepAlive yes

Host *--*
ForwardAgent yes
LogLevel QUIET
Expand Down
17 changes: 17 additions & 0 deletions src/ops/data/ssh/ssh.scb.proxy.config.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Host *
ForwardAgent yes
SendEnv LANG LC_*
ServerAliveCountMax 2
ServerAliveInterval 30
StrictHostKeyChecking no
TCPKeepAlive yes

Host *--*
ForwardAgent yes
LogLevel QUIET
ProxyCommand /usr/bin/nc -X 5 -x 127.0.0.1:{scb_proxy_port} $(echo %h | sed -e 's/.*--//g') %p
SendEnv LANG LC_*
ServerAliveCountMax 2
ServerAliveInterval 30
StrictHostKeyChecking no
TCPKeepAlive yes
4 changes: 2 additions & 2 deletions src/ops/inventory/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ def __init__(self, cluster_config, ssh_config_generator,
self.cache_dir = ops_config.get('cache.dir')

self.generated_path = None
self.ssh_config_path = None
self.ssh_config_path = {}
self.errors = []

def generate(self):
Expand Down Expand Up @@ -322,4 +322,4 @@ def get_vars(self, host):
return self.inventory.get_vars(str(host))

def get_ssh_config(self):
return self.ssh_config_path
return self.ssh_config_path.get("ssh.config")
78 changes: 74 additions & 4 deletions src/ops/inventory/sshconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,88 @@
# OF ANY KIND, either express or implied. See the License for the specific language
# governing permissions and limitations under the License.

import os
import socketserver
from shutil import copy
from pathlib import Path
from ansible.playbook.play import display


class SshConfigGenerator(object):
SSH_CONFIG_FILE = "ssh.config"
SSH_SCB_PROXY_TPL_FILE = "ssh.scb.proxy.config.tpl"

def __init__(self, package_dir):
self.package_dir = package_dir
self.ssh_data_dir = self.package_dir + '/data/ssh'
self.ssh_config_files = [self.SSH_CONFIG_FILE, self.SSH_SCB_PROXY_TPL_FILE]

def generate(self, directory):
dest_ssh_config = directory + '/ssh_config'
copy(self._get_ssh_config(), dest_ssh_config)

dest_ssh_config = {}
for index, ssh_config in enumerate(self._get_ssh_config()):
ssh_config_file = self.ssh_config_files[index]
dest_ssh_config[ssh_config_file] = f"{directory}/{ssh_config_file.replace('.', '_')}"
copy(ssh_config, dest_ssh_config[ssh_config_file])
return dest_ssh_config

def _get_ssh_config(self):
return self.package_dir + '/data/ssh/ssh.config'
return [f"{self.ssh_data_dir}/{ssh_config_file}" for ssh_config_file in self.ssh_config_files]

@staticmethod
def get_ssh_config_path(cluster_config, ssh_config_paths, use_scb):
scb_settings = cluster_config.get('scb', {})
scb_enabled = scb_settings.get('enabled') and use_scb
if scb_enabled:
ssh_config_tpl_path = ssh_config_paths.get(SshConfigGenerator.SSH_SCB_PROXY_TPL_FILE)
scb_proxy_port = SshConfigGenerator.get_ssh_scb_proxy_port(ssh_config_tpl_path)
ssh_config_path = SshConfigGenerator.generate_ssh_scb_config(ssh_config_tpl_path,
scb_proxy_port)
display.display(f"Connecting via scb proxy at 127.0.0.1:{scb_proxy_port}.\n"
f"This proxy should have already been started and running in a different terminal window.\n"
f"If there are connection issues double check that the proxy is running.",
color='blue',
stderr=True)
else:
ssh_config_path = ssh_config_paths.get(SshConfigGenerator.SSH_CONFIG_FILE)
return ssh_config_path

@staticmethod
def generate_ssh_scb_proxy_port(ssh_config_path, auto_scb_port, scb_config_port):
ssh_config_port_path = f"{ssh_config_path}/ssh_scb_proxy_config_port"
if auto_scb_port:
with socketserver.TCPServer(("localhost", 0), None) as s:
generated_port = s.server_address[1]
display.display(f"Using auto generated port {generated_port} for scb proxy port",
color='blue',
stderr=True)
else:
generated_port = scb_config_port
display.display(f"Using port {generated_port} from cluster config for scb proxy port",
color='blue',
stderr=True)

with open(ssh_config_port_path, 'w') as f:
f.write(str(generated_port))
os.fchmod(f.fileno(), 0o644)

return generated_port


@staticmethod
def get_ssh_scb_proxy_port(ssh_config_path):
ssh_port_path = ssh_config_path.replace("_tpl", "_port")
ssh_scb_proxy_port = Path(ssh_port_path).read_text()
return ssh_scb_proxy_port

@staticmethod
def generate_ssh_scb_config(ssh_config_tpl_path, scb_proxy_port):
ssh_config_template = Path(ssh_config_tpl_path).read_text()
ssh_config_content = ssh_config_template.format(
scb_proxy_port=scb_proxy_port
)
ssh_config_path = ssh_config_tpl_path.rstrip("_tpl")
with open(ssh_config_path, 'w') as f:
f.write(ssh_config_content)
os.fchmod(f.fileno(), 0o644)

return ssh_config_path
2 changes: 1 addition & 1 deletion tests/e2e/fixture/ansible/clusters/test.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
inventory:
- directory: inventory
- directory: inventory
2 changes: 1 addition & 1 deletion tests/e2e/fixture/inventory/clusters/plugin_generator.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@

inventory:
- plugin: test_plugin
- plugin: test_plugin
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

inventory:
- plugin: test_plugin

scb:
enabled: true
host: "scb.example.com"
proxy_port: 2222

0 comments on commit b6fa056

Please sign in to comment.