Search code examples
pythondockerflaskcontainersopenssh

How do I use subprocess.getoutput to properly execute an ssh command to retrieve the contents of a file


END GOAL: Build a flask app that can ssh into a remote machine that then remotes to a remote device (via agent forwarding) and fetches the value of a config file on that device.

PROBLEM: I have a script that works when I run the flask app on my local machine. It fetches and displays the values of the config from the remote device. But when I build a docker container to host the flask app, I only ever receive a Permission denied (publickey). error from the api. I have the docker container set up in a way that I am able to execute the exact same ssh script to fetch the config from the container's terminal. But even if I curl the api it gives me the same permission denied error.

This is my little flask api and script to fetch the contents of the config.

from flask import Flask, jsonify, request

app = Flask(__name__)

@app.get('/api/v1/remote/config')
def ssh_show_config():
    user = request.args.get('user')
    host = request.args.get('host')
    node = request.args.get('node')
    return jsonify(get_config_via_ssh(user, host, node))

def get_config_via_ssh(user, host, tunnel_ip):
    try:
        cmd2 = f"ssh -A -tt {user}@{host} -tt \"ssh root@{tunnel_ip} 'cat /storage/config'\""
        process = subprocess.getoutput(cmd2, encoding='utf-8')
        # ConfigCleaner is just a tool that cleans the data in the config
        cleaner = ConfigCleaner()
        return cleaner.clean(process)
    except Exception as e:
        print(e)
        print("[!] Cannot connect to the device SSH Server")
        exit()

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

This is my Dockerfile

FROM python:3.11-slim-bullseye

# update/install necessary alpine linux libraries
RUN apt-get -y update
RUN apt-get install -y openssh-server
RUN apt-get install -y openssh-client
RUN apt-get install -y curl
RUN apt-get install -y sudo

RUN adduser tester
RUN adduser tester sudo

RUN pip3 install poetry

# create project directory
WORKDIR /root/config-app
COPY . .

EXPOSE 5000
EXPOSE 22

# install required python libraries via poetry
RUN poetry config virtualenvs.create false
RUN poetry install

USER tester

# switch to app directory and run app
WORKDIR /root/config-app/src/config_fetcher

CMD poetry run flask --app app run --host 0.0.0.0 --port 5000

This is my docker-compose

version: '3.8'
services:
  config-fetcher:
    image: config-app
    user: root
    volumes:
      - ~/.ssh/config:/root/.ssh/config:rw
      - ~/.ssh/id_ed25519:/root/.ssh/id_ed25519:rw
      - ~/.ssh/id_ed25519.pub:/root/.ssh/id_ed25519.pub:rw
    build:
      context: .
      dockerfile: ./Dockerfile
    ports:
      - "5000:5000"
      - "22:22"

After I run docker compose up, I execute the following command in the container's terminal in order to 1. add a blank known_hosts file and a sockets directory and 2. start the ssh agent and add my mounted key to it in the container:

touch ~/.ssh/known_hosts && mkdir ~/.ssh/sockets && eval ssh-agent && ssh-add ~/.ssh/id_ed25519

After all of that, I am able to execute

ssh -A -tt user@host.domain.com -tt ssh root@device.ip cat /storage/config

inside the container's terminal and it will successfully return the content of the config file (just as I've designed the flask api to do). However, if I curl the api endpoint I created for my flask app (from within the same container), it gives me the permission error. None of this is a problem if I run the flask app on my local machine without the use of docker.

I'm really beating myself over the head on this one.


Solution

  • There are a bunch of problems here.

    1. Your use of ssh-agent is ineffective.

      Because you're only starting ssh-agent as part of your interactive docker exec session, the agent isn't going to be visible to the ssh process spawned by your flask application.

    2. I'm suspicious of your ssh command line, because you've got three levels of quoting going on.

      By ditching getoutput in favor of check_output and specifying the command as a list instead of a string, we can simplify things.

      We can further simplify things by using the -J option to ssh to specify a jump host.

    3. EXPOSE in a Dockerfile does nothing (it's just informative).


    We can fix your code by writing it like this:

    import subprocess
    from flask import Flask, jsonify, request
    
    app = Flask(__name__)
    
    
    @app.get("/api/v1/remote/config")
    def ssh_show_config():
        user = request.args.get("user")
        host = request.args.get("host")
        node = request.args.get("node")
        return jsonify(get_config_via_ssh(user, host, node))
    
    
    def get_config_via_ssh(user, host, tunnel_ip):
        try:
            cmd = [
                "ssh",
                "-A",
                "-J", f"{user}@{host}",
                f"root@{tunnel_ip}",
                "cat /tmp/config",
            ]
            config = subprocess.check_output(cmd, encoding="utf-8", stderr=subprocess.PIPE)
            return {"config": config}
        exception Exception as e:
            return {"error": f"Cannot connect to the device SSH Server: {e}"}
    
    
    if __name__ == "__main__":
        app.run(host="0.0.0.0", port=5000)
    

    I've rewritten the Dockerfile using pipenv instead of poetry (because I'm not familiar with poetry) and removed a bunch of extraneous code that wasn't relevant to this example.

    I've also modified the Dockerfile to run your application under the control of ssh-agent, with an explicit agent socket so that we can interact with the same agent from an interactive session:

    FROM python:3.11-slim-bullseye
    
    # update/install necessary alpine linux libraries
    RUN apt-get -y update
    RUN apt-get install -y openssh-client
    
    RUN pip install pipenv
    
    # create project directory
    WORKDIR /app
    COPY requirements.txt ./
    RUN pipenv install -r requirements.txt
    COPY . ./
    RUN mkdir -m 700 -p /root/.ssh && cp ssh_config /root/.ssh/config && chmod 600 /root/.ssh/config
    
    ENV SSH_AUTH_SOCK=/tmp/agent.sock
    
    CMD ["ssh-agent", "-a", "/tmp/agent.sock", "pipenv", "run", "flask", \
        "--app", "app", "run", "--host", "0.0.0.0", "--port", "5000"]
    

    Lastly, I've updated the compose.yaml to mount an ssh key as a docker secret:

    services:
      config-fetcher:
        image: flaskapp
        user: root
        build:
          context: .
        ports:
          - "5000:5000"
        secrets:
          - sshkey
    
    secrets:
      sshkey:
        file: ~/.ssh/id_rsa
    

    This means that when the container runs, my ssh key will be available as /run/secrets/sshkey.


    With all of this in place, if I docker compose up the container, I can then start an interactive session and load the key:

    $ docker compose exec config-fetcher ssh-add /run/secrets/sshkey
    Enter passphrase for /run/secrets/sshkey:
    Identity added: /run/secrets/sshkey (/run/secrets/sshkey)
    

    And then the following request works:

    $ curl localhost:5000/api/v1/remote/config'?user=lars&host=172.20.0.1&node=127.0.0.1'
    {"config":"this is a test\n"}