Reputation: 21
I'm currently building a system that connects a Docker container into a websocket, where the client can then interact with this container through the websocket. I want to give the client the ability to interrupt the process that's running inside the Docker container using keyboard interrupt.
So far my attempt has been as follows:
.........
pty, tty = pypty.openpty()
async def interact_docker(pty, websocket):
while True:
data = os.read(pty, 10240)
print(data)
# using python's pty as I/O, everytime client gives an input,
# the pty reflects this input and duplicates the input into the output.
# ex: command "ls" inputted by client will output "ls\r\n" and the result of "ls".
# to prevent duplicated output from input, save the length of the input string
# and add 2 to account for \r\n. Then, for each output from pty, check the first
# (len(input) + 2) string, and check if the string ends in \r\n. If it does, it means
# the output is a duplicate of the input, so emit this duplicate.
check_duplicate = data[:offset]
if check_duplicate[(offset - 2):] == "\r\n".encode():
data = data[offset:]
try:
await websocket.send_text(data.decode())
except UnicodeDecodeError:
print("decoding error")
await websocket.send_text(data.decode("latin-1"))
container = subprocess.Popen(['docker', 'run', '--name', str(instance.id), '-it', '--rm', '--privileged', '-e', 'DISPLAY', '-v', '/tmp/.X11-unix:/tmp/.X11-unix', '-v', '/lib/modules:/lib/modules', 'iwaseyusuke/mininet'], stdin=tty, stdout=tty, stderr=tty)
loop = asyncio.get_running_loop()
loop.run_in_executor(None, lambda: asyncio.run(interact_docker(pty, websocket)))
while True:
data = await websocket.receive_text()
if data == "^C":
print("keyboard interrupt")
container.send_signal(signal.SIGINT)
correct_inp = process_input(db, instance, data, expected_inp)
if correct_inp and len(expected_inp) == 0 and not finished_lab:
message = "\x1b[1;92m \nSelamat! Anda telah menyelesaikan lab ini.\nAnda bisa melanjutkan penggunaan lab atau anda bisa kembali ke menu utama untuk menghentikan pengerjaan lab. \x1b[0m\n\n"
await websocket.send_text(message)
user.finished_labs.append(lab)
db.commit()
offset = len(data) + 2
data = data + "\n"
os.write(pty, data.encode())
def process_input(db, instance, inp, expected_inp):
if len(expected_inp) == 0:
return True
if expected_inp[0] == inp:
expected_inp.pop(0)
crud.update_lab_instance_expected_input(db, instance, expected_inp)
return True
#TODO: Implement interchangeable order for certain expected inputs
#TODO: Implement optional input
#TODO: Implement "or" inputs
return False
Basically what's happening here, is I'm running a Docker container using Python's Subprocess with the command subprocess.popen()
. This subprocess is connected with a pty that I've set up using Python's pty. Using this pty, clients can interact with the container's bash to send commands like "ls -al" and get the response of "ls -al" from the container's bash. The issue here, is that with a command like ping
that runs indefinitely, the client is basically stuck with ping
output because they currently have no way to interrupt ping
.
So, to summarize, I want to send interrupt signals into the running process of a docker container bash, which in turn is running inside Python's subprocess.
There's some lines of code being omitted for clearance, but the main idea of the script is there. Currently this attempt using container.send_signal(signal.SIGINT)
just kills the container. Sending "^C" to the pty doesn't send a SIGINT into the process inside the container, instead it just sends the literal string.
The full function is as follows:
@router.websocket("/interact")
async def interact_with_instance(websocket: WebSocket, token: Annotated[str | None, Cookie()] = None, db: Session = Depends(get_db)):
pty, tty = pypty.openpty()
await manager.connect(websocket)
if not token:
message = "\x1b[1;91mKoneksi gagal!\nPastikan anda sudah memiliki instance untuk mengerjakan suatu lab.\n \x1b[0m \n \n"
await websocket.send_text(message)
manager.disconnect(websocket)
raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION, reason="Unauthorized")
try:
user_id = utils.read_token(token).get("id")
instance = crud.get_lab_instance_by_user(db, user_id)
except jwt.ExpiredSignatureError:
message = "\x1b[1;91mKoneksi gagal!\nPastikan anda sudah memiliki instance untuk mengerjakan suatu lab.\n \x1b[0m \n \n"
await websocket.send_text(message)
manager.disconnect(websocket)
raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION, reason="Unauthorized")
if not instance:
message = "\x1b[1;91mKoneksi gagal!\nPastikan anda sudah memiliki instance untuk mengerjakan suatu lab.\n \x1b[0m \n \n"
await websocket.send_text(message)
raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION, reason="Unauthorized")
offset = 0
async def interact_docker(pty, websocket):
while True:
data = os.read(pty, 10240)
print(data)
# using python's pty as I/O, everytime client gives an input,
# the pty reflects this input and duplicates the input into the output.
# ex: command "ls" inputted by client will output "ls\r\n" and the result of "ls".
# to prevent duplicated output from input, save the length of the input string
# and add 2 to account for \r\n. Then, for each output from pty, check the first
# (len(input) + 2) string, and check if the string ends in \r\n. If it does, it means
# the output is a duplicate of the input, so emit this duplicate.
check_duplicate = data[:offset]
if check_duplicate[(offset - 2):] == "\r\n".encode():
data = data[offset:]
try:
await websocket.send_text(data.decode())
except UnicodeDecodeError:
print("decoding error")
await websocket.send_text(data.decode("latin-1"))
container = subprocess.Popen(['docker', 'run', '--name', str(instance.id), '-it', '--rm', '--privileged', '-e', 'DISPLAY', '-v', '/tmp/.X11-unix:/tmp/.X11-unix', '-v', '/lib/modules:/lib/modules', 'iwaseyusuke/mininet'], stdin=tty, stdout=tty, stderr=tty)
lab_id = instance.lab_id
user_id = instance.user_id
lab = lab_crud.get_lab(db, lab_id)
user = auth_crud.get_user(db, user_id)
expected_inp = instance.expected_input
finished_lab = lab in user.finished_labs
instantiated = False
try:
while True:
print("init")
data = os.read(pty, 10240) # docker will initialize and sends a bunch of initialization output, don't send this back to the client
print(data)
print(data.decode()[0:6] == "docker")
if data.decode()[0:6] == "docker": # detect if docker run runs into an error that the container name already exists, run the existing container
container = subprocess.Popen(['docker', 'exec', '-it', str(instance.id), 'bash'], stdin=tty, stdout=tty, stderr=tty)
instantiated = True
if data.decode()[0:17] == "\x1b[?2004h\x1b]0;root@" and not instantiated: # detect that initialization has finished, send the output into the websocket
print("INTO B")
path = f"temp/{instance.id}.py"
container_path = f"{instance.id}:/tmp/script.py"
with open(path, "w+") as f:
f.write(lab.script)
container = subprocess.run(["docker", "cp", path, container_path])
os.remove(path)
command = "python3 /tmp/script.py\n"
os.write(pty, command.encode())
break
if data.decode()[0:17] == "\x1b[?2004h\x1b]0;root@" and instantiated:
print("INTO C")
command = "python3 /tmp/script.py\n"
os.write(pty, command.encode())
break
data = os.read(pty, 10240)
print(data)
await websocket.send_text("\x1b[1;93m")
while True:
data = os.read(pty, 10240)
print(data)
if data.decode()[0:17] == "\x1b[?2004h\x1b]0;root@" or data.decode()[-9:] == "mininet> ":
break
await websocket.send_text(data.decode())
if finished_lab:
message = "\n\x1b[1;92mSelamat! Anda sudah pernah menyelesaikan lab ini. \nAnda tetap bisa melanjutkan pemakaian lab, tetapi tidak akan ada penilaian lebih lanjut untuk pengerjaan lab ini. \x1b[0m \n"
await websocket.send_text(message)
await websocket.send_text("\x1b[0m")
await websocket.send_text("\n")
await websocket.send_text(data.decode())
loop = asyncio.get_running_loop()
loop.run_in_executor(None, lambda: asyncio.run(interact_docker(pty, websocket)))
while True:
data = await websocket.receive_text()
if data == "^C":
print("keyboard interrupt")
cmd = f"docker exec {instance.id} kill -INT 1"
os.write(pty, cmd.encode())
correct_inp = process_input(db, instance, data, expected_inp)
if correct_inp and len(expected_inp) == 0 and not finished_lab:
message = "\x1b[1;92m \nSelamat! Anda telah menyelesaikan lab ini.\nAnda bisa melanjutkan penggunaan lab atau anda bisa kembali ke menu utama untuk menghentikan pengerjaan lab. \x1b[0m\n\n"
await websocket.send_text(message)
user.finished_labs.append(lab)
db.commit()
# TODO: add lab finished column to lab and user model, and add lab to finished
# plus save docker log for manual verification
# TODO: docker logs only logs input of the first window. Subsequent windows are not recorded in docker logs
offset = len(data) + 2
data = data + "\n"
os.write(pty, data.encode())
except:
loop.close()
manager.disconnect(websocket)
os.close(pty)
def process_input(db, instance, inp, expected_inp):
if len(expected_inp) == 0:
return True
if expected_inp[0] == inp:
expected_inp.pop(0)
crud.update_lab_instance_expected_input(db, instance, expected_inp)
return True
#TODO: Implement interchangeable order for certain expected inputs
#TODO: Implement optional input
#TODO: Implement "or" inputs
return False
Upvotes: 0
Views: 401
Reputation: 51820
If you somehow have a server running inside the container, send that server a specific message, and add code on the server side to trigger a SIGINT.
If you want to send a signal to a a process inside the container, use docker exec
:
docker exec <container_name> kill -INT [pid]
If that process is the main process of your container, it has pid 1:
# don't run this at home ;)
docker exec <container_name> kill -INT 1
I was looking for more details about shells and terminals, I found this answer (on unix.stackexchange.com) which explains quite a bit on how to manage things from bash alone:
trying to make my own shell handle ctrl+c properly
Upvotes: 1