Tor Onion 서비스 구축

Qdon은 예전부터 onion 주소를 가진 서비스들과의 연동을 지원했습니다. onion 주소 게시물에 첨부하면 미리보기 카드도 띄워주고 onion 주소가 메인인 다른 ActivityPub 사이트들과의 연동도 되었습니다. 이에 대해서 Hidden service에 대해서만 tor 프록시를 거치도록 수정한 것도 있지만 그것은 다른 글에서 다루기로 하고 오늘은 큐돈 자체의 onion 주소를 붙인 과정에 대해서 써볼까 합니다.

기본편

일단 Tor의 설정인 torrc를 수정해야 합니다.

HiddenServiceDir /var/lib/tor/qdon/
HiddenServicePort 80 127.1.1.1:8080

HiddenServiceNonAnonymousMode 1
HiddenServiceSingleHopMode 1 

SocksPort 0
ExcludeNodes {kr},{ru}

처음 두 줄은 onion 주소에 서비스를 하기 위한 설정이고 그 아래의 두 줄은 어차피 큐돈은 익명의 누군가가 만든 서비스임을 유지할 필요가 없고 익명의 사용자가 onion 주소를 사용하게 하기 위함이므로 Rendezvous Point까지 3홉을 사용해 성능을 더 저하시키지 않고 1hop만 사용하도록 하는 설정입니다. 사용자는 RP까지 3홉을 그대로 이용하므로 총 6홉 대신 4홉을 사용하게 됩니다. SocksPort 0 설정은 비익명모드를 사용하면 Socks 프록시 기능은 사용을 못하기 때문에 명시적으로 비활성화를 하지 않으면 실행이 에러가 뜨고 실행이 안 되기 때문에 넣습니다. ExcludeNodes 설정은 몇몇 노드들을 제외하고 사용하기 위해 넣습니다.

이렇게 설정하면 /var/lib/tor/qdon/hostname 파일에 onion 주소가 생성됩니다. ed25519 키는 백업해 둡니다.

심화편

Tor

우분투나 데비안 계열에서 tor는 유용한 스크립트들을 제공하고 보안에 도움을 줍니다. tor-instance-create qdon 명령을 통해서 qdon 이름을 가진 tor 인스턴스를 하나 설정합니다. 이 명령은 다음의 행동들을 합니다.

  • _tor-qdon 유저와 그룹을 생성합니다
  • /etc/tor/instances/qdon/torrc 파일을 생성합니다

그리고 보안을 위해 systemd의 서비스파일에 제한이 꽤나 걸려 있습니다. 자세한 내용은 /lib/systemd/system/tor@.service 파일을 열어보면 대략적으로 다음과 같은 일을 합니다.

  • 설정파일 검증
  • defaults-torrc 추가
  • 건드릴 수 있는 파일시스템 경로 제한
  • CAP_SETUID 등의 특수 권한 제한
  • tmp, devfs 등의 제한

이 경우 tor@qdon.service는 /var/lib/tor에 접근할 수 없기 때문에 HiddenServiceDir 설정을 /var/lib/tor-instances/qdon으로 지정해 주어야 합니다.

Nginx

앞선 torrc 설정에선 80포트에 연 onion 서비스를 로컬호스트의 80 포트로 가게 하지 않고 8080포트로 가게 하였습니다. 이유는 80포트로 연결하도록 하면 큐돈의 다른 서비스들까지 해당 onion 주소를 통해 접근을 할 수 있기 때문입니다. 여기에선 nginx를 8080포트에 따로 하나 더 띄워서 리버스프록시를 사용해 큐돈을 프록싱 하도록 설정합니다.

upstream qdon-hidden {
  server 127.1.1.1:80 fail_timeout=0;
}

server {
  listen 127.1.1.1:8080;
  listen [::1]:8080;
  server_name ~~~.onion;
  root /var/www/html;

  access_log /var/log/nginx/qdon-hidden-access.log;
  error_log /var/log/nginx/qdon-hidden-error.log;

  keepalive_timeout 30;

  location / {
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP "0.0.0.0";
    proxy_set_header X-Forwarded-For "0.0.0.0";
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Proxy "";
    proxy_pass_header Server;

    proxy_pass http://qdon-hidden;
    proxy_buffering on;
    proxy_redirect off;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    tcp_nodelay on;
  }

  location /api/v1/streaming {
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP "0.0.0.0";
    proxy_set_header X-Forwarded-For "0.0.0.0";
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Proxy "";

    proxy_pass http://qdon-hidden;
    proxy_buffering off;
    proxy_redirect off;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    tcp_nodelay on;
  }

}

위 설정은 기존의 nginx로 리버스 프록싱을 하는 설정이지만 마스토돈에서 제공하는 dist/nginx.conf 파일을 수정해 직접 web, streaming 서비스를 향하도록 할 수도 있습니다.

X-Real-IPX-Forwarded-For 헤더는 $remote_addr가 아닌 0.0.0.0으로 해주었습니다. 127.0.0.1이 될 경우 인증을 거치지 않거나 제한을 우회할 수 있기 때문입니다.

Nginx/dokku, Onion-Location

dokku에서는 nginx.conf.sigil을 사용해 nginx의 템플릿을 작성할 수 있습니다. 하지만 무조건적으로 https로 리디렉션을 하는 설정이었고 Onion-Location 헤더를 추가하기 위해 템플릿을 살짝 수정하였습니다.

Onion-Location 헤더는 Tor 브라우저를 사용할 때 “우리는 onion 주소를 제공합니다” 하고 알리는 역할입니다. 기존 주소로 접속했을 때 Onion-Location 헤더가 존재하면 주소 입력칸 우측에 .onion available이라고 뜹니다.

nginx.conf.sigil은 원본에서 살짝 수정해 ONION_ADDRESS 환경변수 지원을 추가해 만약 있다면 80 → 443 리디렉션을 끄고 Onion-Location 헤더를 추가하도록 하였습니다. 기존 https로만 제공되어야 하는 주소로 접근했을 땐 Rails 프레임워크가 알아서 X-Forwarded-Proto 헤더를 감지하고 443/TLS로 리디렉션을 강제하기 때문에 문제가 없습니다.

{{ $cache_name := printf "%s%s" $.APP "-cache" }}
proxy_cache_path /tmp/{{ $cache_name }} levels=1:2 keys_zone={{ $cache_name }}:10m inactive=7d;

map $http_upgrade $connection_upgrade {
  default upgrade;
  ''      close;
}

{{ $proxy_cache_settings := `
    proxy_cache {{ $cache_name }};
    proxy_cache_valid 200 7d;
    proxy_cache_valid 410 24h;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    add_header X-Cached $upstream_cache_status;
   ` | replace `{{ $cache_name }}` $cache_name }}

{{ range $port_map := .PROXY_PORT_MAP | split " " }}
{{   $port_map_list := $port_map | split ":" }}
{{   $scheme := index $port_map_list 0 }}
{{   $listen_port := index $port_map_list 1 }}

{{   if eq $scheme "https" }}
server {
  listen      [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }} ssl {{ if eq $.HTTP2_SUPPORTED "true" }}http2{{ else if eq $.SPDY_SUPPORTED "true" }}spdy{{ end }};
  listen      {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{end}}{{ $listen_port }} ssl {{ if eq $.HTTP2_SUPPORTED "true" }}http2{{ else if eq $.SPDY_SUPPORTED "true" }}spdy{{ end }};
  {{ if var "ONION_ADDRESS" }}
  listen      [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ or $.PROXY_PORT 80 }};
  listen      {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{ end }}{{ or $.PROXY_PORT 80 }};
  {{ end }}

  {{ if $.SSL_SERVER_NAME }}server_name {{ $.SSL_SERVER_NAME }}; {{ end }}
  {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }}
  access_log  {{ $.NGINX_ACCESS_LOG_PATH }}{{ if and ($.NGINX_ACCESS_LOG_FORMAT) (ne $.NGINX_ACCESS_LOG_PATH "off") }} {{ $.NGINX_ACCESS_LOG_FORMAT }}{{ end }};
  error_log   {{ $.NGINX_ERROR_LOG_PATH }};

  ssl_certificate     {{ $.APP_SSL_PATH }}/server.crt;
  ssl_certificate_key {{ $.APP_SSL_PATH }}/server.key;
  ssl_protocols             TLSv1.2 {{ if eq $.TLS13_SUPPORTED "true" }}TLSv1.3{{ end }};
  ssl_prefer_server_ciphers on;

  keepalive_timeout   70;
  {{ if and (eq $.SPDY_SUPPORTED "true") (ne $.HTTP2_SUPPORTED "true") }}add_header          Alternate-Protocol  {{ $.PROXY_SSL_PORT }}:npn-spdy/2;{{ end }}

  gzip on;
  gzip_vary on;
  gzip_proxied any;
  gzip_comp_level 6;
  gzip_buffers 16 8k;
  gzip_http_version 1.1;
  gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml image/x-icon;

  proxy_read_timeout {{ $.PROXY_READ_TIMEOUT }};
  proxy_buffer_size {{ $.PROXY_BUFFER_SIZE }};
  proxy_buffering {{ $.PROXY_BUFFERING }};
  proxy_buffers {{ $.PROXY_BUFFERS }};
  proxy_busy_buffers_size {{ $.PROXY_BUSY_BUFFERS_SIZE }};
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection $connection_upgrade;
  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Port {{ $.PROXY_X_FORWARDED_PORT }};
  proxy_set_header X-Forwarded-Proto $scheme;
  proxy_set_header X-Request-Start $msec;
  {{ if $.PROXY_X_FORWARDED_SSL }}proxy_set_header X-Forwarded-Ssl {{ $.PROXY_X_FORWARDED_SSL }};{{ end }}

  client_max_body_size {{ if $.CLIENT_MAX_BODY_SIZE }}{{ $.CLIENT_MAX_BODY_SIZE }}{{ else }}100M{{ end }};

  add_header Strict-Transport-Security "max-age=31536000";
  {{ if var "ONION_ADDRESS" }}add_header Onion-Location http://{{ var "ONION_ADDRESS" }}$request_uri;{{ end }}

  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header Proxy "";
  proxy_pass_header Server;

  proxy_redirect off;
  proxy_http_version 1.1;

  tcp_nodelay on;


  {{ if eq "true" (var "S3_ENABLED") }}
    {{ if var "S3_ALIAS_HOST" }}
  rewrite ^/system/(.*)$ https://{{ var "S3_ALIAS_HOST" }}/$1 permanent;
    {{ else }}
  rewrite ^/system/(.*)$ {{ var "S3_PROTOCOL" }}://{{ var "S3_HOSTNAME" }}/{{ var "S3_BUCKET" }}/$1 permanent;
    {{ end }}
  {{ end }}

  location / {
    {{ if eq $.HTTP2_PUSH_SUPPORTED "true" }}http2_push_preload on; {{ end }}
    {{ $proxy_cache_settings }}
    proxy_pass http://{{ $.APP }}-web;
  }

  location /gallery {
    alias /var/www/gallery;
    index index.html;

    location /gallery/static {
      add_header Cache-Control 'public, max-age=604800, immutable';
    }
  }

  location ~/(emoji|packs|system/accounts/avatars|system/site_uploads|system/media_attachments/files) {
    add_header Cache-Control "public, max-age=31536000, immutable";
    {{ $proxy_cache_settings }}
    proxy_pass http://{{ $.APP }}-web;
  }

  location /sw.js {
    add_header Cache-Control "public, max-age=0";
    {{ $proxy_cache_settings }}
    proxy_pass http://{{ $.APP }}-web;
  }

  {{ if $.DOKKU_APP_STREAMING_LISTENERS }}
  location /api/v1/streaming {
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Proxy "";

    proxy_pass http://{{ $.APP }}-streaming;
    proxy_buffering off;
    proxy_redirect off;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    tcp_nodelay on;
  }
  {{ end }}

  include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf;

  error_page 500 501 502 503 504 /500.html;
}
{{   else if and (eq $scheme "http") (not (var "ONION_ADDRESS")) }}
server {
  listen      [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }};
  listen      {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{ end }}{{ $listen_port }};
  {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }}
  access_log  {{ $.NGINX_ACCESS_LOG_PATH }}{{ if and ($.NGINX_ACCESS_LOG_FORMAT) (ne $.NGINX_ACCESS_LOG_PATH "off") }} {{ $.NGINX_ACCESS_LOG_FORMAT }}{{ end }};
  error_log   {{ $.NGINX_ERROR_LOG_PATH }};
{{     if (and (eq $listen_port "80") ($.SSL_INUSE)) }}
  include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf;
  location / {
    return 301 https://$host:{{ $.PROXY_SSL_PORT }}$request_uri;
  }
{{     else }}
{{     end }}
}
{{   end }}
{{ end }}

{{ if $.DOKKU_APP_WEB_LISTENERS }}

{{   range $upstream_port := $.PROXY_UPSTREAM_PORTS | split " " }}
upstream {{ $.APP }}-web {
{{     range $listeners := $.DOKKU_APP_WEB_LISTENERS | split " " }}
  server {{ $listeners }};
{{     end }}
}
{{   end }}
{{ end }}

{{ if $.DOKKU_APP_STREAMING_LISTENERS }}
upstream {{ $.APP }}-streaming {
{{   range $listeners := $.DOKKU_APP_STREAMING_LISTENERS | split " " }}
  server {{ $listeners }};
}
{{   end }}
{{ end }}

이렇게 큐돈의 onion 주소가 완성되었습니다.

주소는 http://nqt42rzz5ybtslld3yvfv7orovscbfpylv2aaybqiri6cpql3kxdcpad.onion/ 입니다.


Authors

Jeong Arm photo
Jeong Arm