개요

pypiserver는 Python 패키지를 업로드할 수 있는 인덱스 구현체 중 하나다. pip3 install <package name>을 실행하면 어딘가에서 패키지를 다운로드할 수 있는데, 이 과정에서 패키지 인덱스에서 검색하고 다운로드할 수 있게 된다. 패키지 개발자들은 보통 개발한 패키지를 공용 패키지 인덱스인 pypi.org 에 올린다. 그러나 개인이나 조직에서 개발한 패키지들을 공용 패키지 인덱스에 올릴 수는 없으므로 사설 패키지 인덱스를 설정하려고 한다. 또한 이 과정에서 발생한 사소한(?) 문제들에 대한 troubleshooting을 다룬다.

설치 환경

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

Apache2 + mod_proxy는 어떤가요?

ProxyPass와 ProxyPassReserved 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 설정

사실 이 포스팅을 하게 된 이유기도 하다. 공식 문서 에는

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가 발생하게 되고, Apache2 로그를 살펴보면 엉뚱한 파일들을 접근하고 있는 것을 본다.
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이 일상이기 때문에 발생하는 문제다.