Skip to content

Commit

Permalink
Replace websocat on target machine with Python script
Browse files Browse the repository at this point in the history
We want to minimize the requirements on the target machines. websocat
is a great tool, but not packaged anywhere, and downloading straight
from GitHub isn't appropriate for production.

Thus replace this with a simple Python connector which uses the builtin
asyncio module, and the external websockets one. To avoid having to
install the latter, build a pyz application bundle. We can then also
test/CI this before shipping.

The connector only supports basic auth for now. Eventually it will need
to do TLS client cert auth, but we'll get to that when we have the
necessary test harness.

Stop putting the connector into the webconsoleserver image. Eventually
it will be sent to the target machine through an Ansible script. Thus
for testing, send it to the container as a volume instead, which from
the point of the connector amounts to the same thing.

Fixes #41
  • Loading branch information
martinpitt authored and jelly committed Sep 23, 2022
1 parent 2a62a1e commit dc60123
Show file tree
Hide file tree
Showing 7 changed files with 114 additions and 15 deletions.
1 change: 1 addition & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
[flake8]
max-line-length = 120
exclude = tmp/*
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
3scale/certs
__pycache__
*.pyz
3scale/certs
tmp/
16 changes: 14 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,24 @@ CONTAINER_NAME = webconsoleapp
SERVER_CONTAINER_NAME = webconsoleserver
PORT_3SCALE = 8443

build: 3scale/certs/service-chain.pem containers
build: 3scale/certs/service-chain.pem server/cockpit-bridge-websocket-connector.pyz containers

3scale/certs/service-chain.pem:
mkdir -p 3scale/certs && cd 3scale/certs && sscg --subject-alt-name localhost --subject-alt-name host.containers.internal
cat 3scale/certs/service.pem 3scale/certs/ca.crt > $@

# bundle https://pypi.org/project/websockets; it's packaged everywhere, but we
# don't want to install anything on target machines
# chmod is a hack around https://github.com/python/cpython/issues/96867
server/cockpit-bridge-websocket-connector.pyz: server/cockpit-bridge-websocket-connector
rm -rf tmp/pyz
mkdir -p tmp/pyz
cp $< tmp/pyz/cockpit_bridge_websocket_connector.py
python3 -m pip install --no-compile --target tmp/pyz/ websockets
find tmp/pyz/ -name '*.c' -or -name '*.so' -delete
python3 -m zipapp --python="/usr/bin/env python3" --compress --output $@ --main cockpit_bridge_websocket_connector:main tmp/pyz
chmod a+x $@

containers:
podman build -t $(CONTAINER_NAME) appservice
podman build -t $(SERVER_CONTAINER_NAME) server
Expand All @@ -31,7 +43,7 @@ clean:
podman network rm --force $(NETWORK); \
fi

check:
check: server/cockpit-bridge-websocket-connector.pyz
python3 -m unittest discover -vs test

.PHONY: containers run clean build
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ This requires `podman` and `sscg` to be available on the host.

## Usage

- Build the credentials and custom container:
- Build the credentials, custom container, and connector zipapps:
```
make
```
Expand All @@ -39,10 +39,10 @@ This requires `podman` and `sscg` to be available on the host.

- Pick some target machine/VM on which you want to get a Cockpit session; this can just be a local VM.
It needs to have Cockpit ≥ 275 installed, at least the `cockpit-system` and `cockpit-bridge` packages.
You also need to install [websocat](https://github.com/vi/websocat) for the time being:
You also need to copy `server/cockpit-bridge-websocket-connector.pyz` to the target machine (in the
final product this will be transmitted through Ansible):
```
curl -L -o /tmp/websocat https://github.com/vi/websocat/releases/download/v1.10.0/websocat.x86_64-unknown-linux-musl
chmod a+x /tmp/websocat
scp server/cockpit-bridge-websocket-connector.pyz target_machine:/tmp/
```

- Connect the target machine to the ws session container. In a VM with a
Expand All @@ -52,7 +52,7 @@ This requires `podman` and `sscg` to be available on the host.
`SESSION_ID` with the UUID that the `/new` call returned.
Run this command as the user for which you want to get a Cockpit session:
```
/tmp/websocat --basic-auth admin:foobar -b -k wss://_gateway:8443/wss/webconsole/v1/sessions/SESSION_ID/ws cmd:cockpit-bridge
/tmp/cockpit-bridge-websocket-connector.pyz --basic-auth admin:foobar -k wss://_gateway:8443/wss/webconsole/v1/sessions/SESSION_ID/ws
```

- Open Cockpit in a browser:
Expand Down
7 changes: 3 additions & 4 deletions server/Containerfile
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
# Test container image that represents a remote managed server which connects
# to consoledot. This illustrates the minimum dependencies.
FROM debian:bookworm

RUN apt-get update && \
apt-get install -y cockpit-bridge cockpit-system curl procps && \
apt-get install -y cockpit-bridge cockpit-system ca-certificates python3 && \
apt-get clean

RUN curl -L -o /usr/local/bin/websocat https://github.com/vi/websocat/releases/download/v1.10.0/websocat.x86_64-unknown-linux-musl && \
chmod a+x /usr/local/bin/websocat
83 changes: 83 additions & 0 deletions server/cockpit-bridge-websocket-connector
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env python3

import argparse
import asyncio
import asyncio.subprocess
import base64
import logging
import ssl

import websockets

logger = logging.getLogger(__name__)

BRIDGE = 'cockpit-bridge'


def parse_args():
parser = argparse.ArgumentParser(description='Connect cockpit-bridge to a websocket URL')
parser.add_argument('-d', '--debug', action='store_true', help='Enable debug logging')
parser.add_argument('--extra-ca-cert', help='Additional CA certificate')
parser.add_argument('-k', '--insecure', action='store_true',
help='Accept invalid certificates and hostnames while connecting to TLS')
parser.add_argument('--basic-auth', metavar="USER:PASSWORD",
help='Authenticate with user/password (for testing)')
parser.add_argument('url', help='Connect to this ws:// or wss:// URL')
return parser.parse_args()


async def ws2bridge(ws, bridge_input):
try:
async for message in ws:
bridge_input.write(message)
logger.debug('ws -> bridge: %s', message)
await bridge_input.drain()
except websockets.exceptions.ConnectionClosedError as e:
logger.debug('ws2bridge: websocket connection got closed: %s', e)
return


async def bridge2ws(bridge_output, ws):
while True:
message = await bridge_output.read(4096)
if not message:
break
logger.debug('bridge -> ws: %s', message)
await ws.send(message)


async def bridge(args):
headers = {}
ssl_context = ssl.create_default_context()

if args.basic_auth:
headers['Authorization'] = 'Basic ' + base64.b64encode(args.basic_auth.encode()).decode()

if args.extra_ca_cert:
ssl_context.load_verify_locations(args.extra_ca_cert)

if args.insecure:
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE

async with websockets.connect(args.url, extra_headers=headers, ssl=ssl_context) as websocket:
p_bridge = await asyncio.create_subprocess_exec(
BRIDGE, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE)
logger.debug('Started %s: pid %i', BRIDGE, p_bridge.pid)

ws2bridge_task = asyncio.create_task(ws2bridge(websocket, p_bridge.stdin))
bridge2ws_task = asyncio.create_task(bridge2ws(p_bridge.stdout, websocket))
_done, pending = await asyncio.wait([ws2bridge_task, bridge2ws_task],
return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()


def main():
args = parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
asyncio.run(bridge(args))


if __name__ == '__main__':
main()
8 changes: 5 additions & 3 deletions test/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,14 @@ def newSession(self):
# API URL is on the container host's localhost; translate for the container DNS
websocket_url = self.api_url.replace('localhost', 'host.containers.internal').replace('https:', 'wss:')
podman = ['podman', 'run', '-d', '--pod', 'webconsoleapp',
'--volume', './3scale/certs/ca.crt:/usr/local/share/ca-certificates/3scale-ca.crt',
'--volume', './3scale/certs/ca.crt:/usr/local/share/ca-certificates/3scale-ca.crt:ro',
# in production, the bridge connector gets sent to target system via Ansible
'--volume', './server:/server:ro',
'--network', 'consoledot', 'localhost/webconsoleserver']
cmd = ['sh', '-exc',
f'update-ca-certificates; '
f'websocat --basic-auth admin:foobar -b {websocket_url}{config.ROUTE_WSS}/sessions/{sessionid}/ws '
f'cmd:cockpit-bridge']
f'/server/cockpit-bridge-websocket-connector.pyz --basic-auth admin:foobar'
f' {websocket_url}{config.ROUTE_WSS}/sessions/{sessionid}/ws']

subprocess.check_call(podman + cmd)

Expand Down

0 comments on commit dc60123

Please sign in to comment.