컴퓨터 일반

pypiserver on Apache2 + WSGI

Folivora 2024. 2. 25. 22:04

개요

pypiserver는 Python 패키지를 업로드할 수 있는 인덱스 구현체 중 하나다. pip3 install <package name>을 실행하면 어딘가에서 패키지를 다운로드할 수 있는데, 이 과정에서 패키지 인덱스에서 검색하고 다운로드할 수 있게 된다. 패키지 개발자들은 보통 개발한 패키지를 공용 패키지 인덱스인 https://pypi.org/ 올린다. 그러나 개인이나 조직에서 개발한 패키지들을 공용 패키지 인덱스에 올려서는 곤란하다. 이를 위해 개인이나 조직 내부에서 사용할 수 있는 사설 패키지 인덱스를 설정하려고 한다.

 

설치 환경

여러 가지 방식으로 설치할 수 있는데, Ubuntu 22.04 + Apache2 + wsgi 환경에서 설정하려고 한다. Apache2는 HTTPS를 사용한다.

 

Apache2 + mod_proxy는 어떤가요?

ProxyPass와 ProxyPassReversed Directives를 사용하면 동작은 한다. pypiserver에서 자체적으로 HTTPS를 지원하지는 않으므로 HTTPS 연결이 들어오면 HTTP로 Reverse Proxy를 설정하게 된다. 그런데 pypiserver에서 만들어내는 HTML 출력에는 http:// 로 들어가기 때문에 이 출력을 https:// 등으로 적절하게 변환해줘야하는 문제점이 생기게 된다. pypiserver에서 링크마다 base url을 설정할 수 있는 기능은 지원하지 않는 듯하다. 그래서 이 방법은 비추천한다.

 

디렉토리 구성

디렉토리 구성에는 정답은 없지만, 대략적으로

  • /srv/pypiserver/wsgi/
    • pypiserver.wsgi
  • /srv/pypiserver/auth/
    • .htpasswd
  • /srv/pypiserver/data/
    • (여기에 패키지 파일이 업로드 된다)
  • /srv/pypiserver/virtualenv/

네 개의 디렉토리로 나뉘게 된다. wsgi 와 auth, data와 파이썬 실행 환경이 있는 virtualenv (!=venv) 는 서로 섞지 않는 원칙을 정했다. 적어도 상대경로 참조를 통한 구닥다리 공격은 방지되었을 것이라는 가정이다.

 

Apache2 설정

/etc/apache2/sites-enabled/default-ssl.conf 을 수정할 것이다.

WSGIScriptAlias / /srv/pypiserver/wsgi/pypiserver.wsgi
WSGIDaemonProcess pypisrv user=pypisrv group=pypisrv umask=0007 \
                  processes=1 threads=5 maximum-requests=500 \
                  display-name=wsgi-pypisrv inactivity-timeout=300 \
                  python-home=/srv/pypiserver/virtualenv

<Directory /srv/pypiserver/wsgi >
	Require all granted
	WSGIProcessGroup        pypisrv
    WSGIApplicationGroup    %{GLOBAL}
	# Required for authentication (https://github.com/pypiserver/pypiserver/issues/288)
	WSGIPassAuthorization On
</Directory>

의 내용을 <VirtualHost *:443> ... </VirtualHost>에 끼워넣었다. wsgi에서 프로세스를 실행시키는 사용자와 그룹은 pypisrv:pypisrv로 정했다. (adduser 명령어 참조)

 

wsgi 설정

사실 이 포스팅을 하게 된 이유이기도 하다. 공식 문서 (https://github.com/pypiserver/pypiserver) 에는

import pypiserver

conf = pypiserver.default_config(
	root =          "/yoursite/packages",
	password_file = "/yoursite/htpasswd", )

application = pypiserver.app(**conf)

로 되어 있으나, pypiserver 2.0.1 버전에서는 default_config 가 존재하지 않는다. 문서가 업데이트가 되지 않은 것이다. ㅠㅠ

 

pypiserver.wsgi 내용

import pypiserver
application = pypiserver.app(roots=["/srv/pypiserver/data"], authenticate=["update", "download", "list"], password_file="/srv/pypiserver/auth/.htpasswd", overwrite=False)
  • authenticate의 기본값은 업로드 시에만 암호를 물어보는 ["update"] 이나, 여기서는 비공개 패키지이므로 ["update", "download", "list"] 까지 해서 조회, 다운로드, 업로드시 모두 암호를 물어보도록 설정했다.
  • 한번 업로드한 패키지에 대해서는 덮어쓰는 것을 불가능하도록 하였다. 실제로 내용이 다른데, 같은 버전 넘버를 사용해서는 안된다고 생각한다.
  • 다음으로 "roots" 인데, 여기에 str의 배열을 사용하지 않고 roots="/srv/pypiserver/data"를 입력하게 되면 파일 시스템 전체를 훑게 된다. 따라서 503 Internal Server Error가 발생하게 되고, 아파치 로그를 살펴보다보면 엉뚱한 파일들을 접근하고 있는 것을 보게 된다.

    return self._accessor.stat(self, follow_symlinks=follow_symlinks)
    [Sun Feb 25 20:56:38.478903 2024] [wsgi:error] [pid 2407370:tid 140154756912704] [remote ***] PermissionError: [Errno 13] Permission denied: '/opt/gitlab/embedded/service/gitlab-rails/config/redis.yml'

    왜 이런 현상이 발생했을까? "/srv/pypiserver/data" 를 ["/", "s", "r", "v", ..., "d", "a", "t", "a"] 처럼 쪼개버려 첫 "/" 에서 전체 파일 시스템을 읽어버리게 되는 것이다. roots=["/srv/pypiserver/data"] 와 같이 str 배열을 사용해야 의도한대로 동작한다. 이건 파이썬에서 duck typing이 일상이기 때문에 발생하는 문제다.