How to make your Homelab Accessible via Pangolin

If you are hosting services on a server in your homelab, then the easiest way to setup access to your services is just by port opening and forwarding the necessary ports (mainly ports 80 and 443) from your router to your home server.

However, that may not always be possible or desirable, for example in the following cases:

  • You're behind CGNAT from your ISP
  • Your ISP doesn't allow port forwarding through their firewall
  • You don't have access to your ISP's router
  • You don't have a fixed IPv4 / IPv6 address from your ISP (it might still be possible via services such as DynDNS)
  • You would like to protect the resources you're exposing additionally (they might not have full authentication implemented, or might not have 2FA authentication, or you might require fine-granular authorization via user roles)
  • You'd like SSO (Single Sign On) for your resources
  • You'd like to make some resources only available temporarily
  • You'd like to manage different home labs / servers from one entry point
  • The service doesn't have a https version, but you still need to access the service securely
  • You don't want to give out VPN access to everyone who wants to use your service.

While big providers such as Cloudflare and Tailscale provide solutions for this; Pangolin can do the same via a self-hosted approach. Whether you setup Pangolin in a cheap VPS in the cloud, or in a DMZ in your homelab, it'll be able to connect the dots of your homelab and manage entry to your services.

In the following, I will describe how I've setup pangolin for my use case and the pitfalls I've faced.

Before Pangolin, I've just forwarded all the ports from my router directly to my kubernetes cluster. Therefore everything available on my cluster could be accessed without any further authentication layer, even if I didn't necessarily want that.

Now I'm not forwarding any ports from my firewall anymore. My domains point to a small Infomaniak VPS Lite and from there the traffic is redirected via Pangolin to the appropriate servers. I can control who has access to which resources via Pangolin, as well as quickly enable / disable access. I've also enabled the crowdsec traefik plugin for intrusion detection and prevention. It works so well that I've even locked myself out a couple of times ;-)

So far it seems pretty stable, with more than 15GB traffic in/out since the installation and an uptime of 97-99% as measured with Uptime Kuma.

Addendum

Crowdsec Banning Pangolin Users Due To Misconfiguration Issue

In earlier versions of pangolin, the installer created a wrong health check for the crowdsec container, which was overloading crowdsec's infrastructure. See also the pangolin discussion and the crowdsec issue for more information.

Crowdsec took the step to ban offending IPs, resulting in a 403 error.

It is therefore imperative to update the config as follows (see further below for the full configuration) by replacing capi with lapi in the health check:

healthcheck:
  test:
    - CMD
    - cscli
    - lapi
    - status
  interval: 10s
  timeout: 5s
  retries: 3
  start_period: 30s

Log Size Issue

I recently encountered an issue where the space of my VPS (20GB) was getting full. I checked, and the reason was that my traefik log size hat grown to almost 10GB.

To prevent that, I've setup logrotate as follows - and the 7G log file compressed down to 423MB:

sudo apt install logrotate
sudo nano /etc/logrotate.d/traefik

With the following config for the traefik logs:

/home/debian/pangolin/config/traefik/logs/access.log {
        compress
        size 1G
        rotate 7
        prerotate
                sudo service pangolin stop
        endscript
        postrotate
                sudo service pangolin start
        endscript
}

Updating pangolin

I've updated pangolin to version 1.15.1. So far I haven't encountered any issues.

Installation

See also the official install guide. Additionally, I setup a systemd service to always start pangolin when the server boots (and to be able to easily manage the service). I'm using the lowest tier of infomaniak's VPS Lite. It's sufficient, and uses usually less than 30% of the CPU and around 80% of the system RAM.

sudo apt install -y docker.io
mkdir pangolin && cd pangolin
wget -O installer "https://github.com/fosrl/pangolin/releases/download/1.6.2/installer_linux_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" && chmod +x ./installer
sudo ./installer
sudo nano docker-compose.yml # customize, ex. to add api keys for dns challenge or additional ports
sudo nano config/config.yml # customize, ex. to add additional domains
sudo nano config/traefik/traefik_config.yml # customize to add additional ports
sudo nano /etc/systemd/system/pangolin.service # see below for content
sudo service pangolin enable
sudo service pangolin start

Crowdsec (optional)

sudo docker exec crowdsec cscli bouncers add crowdsec-traefik-bouncer
sudo docker exec crowdsec cscli console enroll -e context ___value___ # optional
sudo docker exec crowdsec cscli decisions delete -i 9.9.9.9 # unban an ip
sudo docker exec -it crowdsec cscli decisions list # see banned clients

# create a allowlist to prevent certain ips from being banned.
# in my experience I've banned myself quite quickly...
sudo docker exec crowdsec cscli allowlist create allowlist1 -d 'description of whitelist'
sudo docker exec crowdsec cscli allowlist add allowlist1 9.9.9.9
sudo docker exec crowdsec cscli allowlist inspect allowlist1

Hints / Gotchas

  • In the docker-compose, some ports need to be quoted (ex: ssh) in order to work.
  • The installer can be run in an existing directory and it'll update / merge the config. This can be used to upgrade to a newer version of pangolin, or add additional features such as crowdsec.

Debugging

Systemd Service

 journalctl -xeu pangolin

Docker

sudo docker ps
sudo docker logs $container
sudo docker exec -it $container $command

Crowdsec

sudo docker exec -it crowdsec cscli decisions list
sudo docker exec crowdsec cscli allowlist inspect allowlist1

Setup

Systemd Service

[Unit]
Description=Pangolin via Docker compose
After=docker.service
Requires=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/bash -c "docker-compose -f /home/debian/pangolin/docker-compose.yml up --detach"
ExecStop=/bin/bash -c "docker-compose -f /home/debian/pangolin/docker-compose.yml stop"

[Install]
WantedBy=multi-user.target

docker-compose.yml

networks:
  default:
    driver: bridge
    name: pangolin
services:
  crowdsec:
    command: -t
    container_name: crowdsec
    environment:
      ACQUIRE_FILES: /var/log/traefik/*.log
      COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
      ENROLL_INSTANCE_NAME: pangolin-crowdsec
      ENROLL_TAGS: docker
      GID: "1000"
      PARSERS: crowdsecurity/whitelists
    expose:
      - 6060
    healthcheck:
      interval: 10s
      retries: 7
      timeout: 10s
      start_period: 30s
      test:
        - CMD
        - cscli
        - lapi
        - status
    image: crowdsecurity/crowdsec:latest
    labels:
      - traefik.enable=false
    ports:
      - 6060:6060
    restart: unless-stopped
    volumes:
      - ./config/crowdsec:/etc/crowdsec
      - ./config/crowdsec/db:/var/lib/crowdsec/data
      - ./config/crowdsec_logs/auth.log:/var/log/auth.log:ro
      - ./config/crowdsec_logs/syslog:/var/log/syslog:ro
      - ./config/crowdsec_logs:/var/log
      - ./config/traefik/logs:/var/log/traefik
  gerbil:
    cap_add:
      - NET_ADMIN
      - SYS_MODULE
    command:
      - --reachableAt=http://gerbil:3003
      - --generateAndSaveKeyTo=/var/config/key
      - --remoteConfig=http://pangolin:3001/api/v1/gerbil/get-config
      - --reportBandwidthTo=http://pangolin:3001/api/v1/gerbil/receive-bandwidth
    container_name: gerbil
    depends_on:
      pangolin:
        condition: service_healthy
    image: fosrl/gerbil:1.3.0
    ports:
      - 51820:51820/udp
      - 443:443
      - 80:80
    #  - "22:22" # attention: needs to be wrapped in quotes
    #  - "25:25" # attention: needs to be wrapped in quotes
    #  - 110:110
    #  - 143:143
    #  - 465:465
    #  - 587:587
    #  - 993:993
    #  - 995:995
    restart: unless-stopped
    volumes:
      - ./config/:/var/config
  pangolin:
    container_name: pangolin
    healthcheck:
      interval: 10s
      timeout: 10s
      retries: 15
      test:
        - CMD
        - curl
        - -f
        - http://localhost:3001/api/v1/
      timeout: 3s
    image: fosrl/pangolin:1.15.1
    restart: unless-stopped
    volumes:
      - ./config:/app/config
  traefik:
    command:
      - --configFile=/etc/traefik/traefik_config.yml
    container_name: traefik
    depends_on:
      pangolin:
        condition: service_healthy
    image: traefik:v3.6.7
    network_mode: service:gerbil
    restart: unless-stopped
    volumes:
      - ./config/traefik:/etc/traefik:ro
      - ./config/letsencrypt:/letsencrypt
      - ./config/traefik/logs:/var/log/traefik
    environment:
      INFOMANIAK_ACCESS_TOKEN: "___VALUE___"
  newt:
    image: fosrl/newt:1.9.0
    container_name: newt
    restart: unless-stopped
    environment:
      - PANGOLIN_ENDPOINT=https://pangolin.example.com
      - NEWT_ID=___VALUE___
      - NEWT_SECRET=___VALUE___
    extra_hosts:
      - "host.docker.internal:host-gateway"

Pangolin config.yaml

app:
  dashboard_url: https://pangolin.example.com
  log_level: info
  save_logs: false
domains:
  domain1:
    base_domain: example.com
    cert_resolver: letsencrypt
  domain2:
    base_domain: foo.ai
    cert_resolver: letsencrypt
  domain3:
    base_domain: bar.in
    cert_resolver: letsencrypt
server:
  external_port: 3000
  internal_port: 3001
  next_port: 3002
  internal_hostname: pangolin
  session_cookie_name: p_session_token
  resource_access_token_param: p_token
  resource_access_token_headers:
    id: P-Access-Token-Id
    token: P-Access-Token
  resource_session_request_param: p_session_request
  cors:
    origins:
      - https://pangolin.example.com
    methods:
      - GET
      - POST
      - PUT
      - DELETE
      - PATCH
    headers:
      - X-CSRF-Token
      - Content-Type
    credentials: false
  secret: foo123
traefik:
  cert_resolver: letsencrypt
  http_entrypoint: web
  https_entrypoint: websecure
gerbil:
  start_port: 51820
  base_endpoint: pangolin.example.com
  use_subdomain: false
  block_size: 24
  site_block_size: 30
  subnet_group: 100.89.137.0/20
rate_limits:
  global:
    window_minutes: 1
    max_requests: 500
email:
  smtp_host: smtp.example.com
  smtp_port: 587
  smtp_user: foo
  smtp_pass: password123456
  no_reply: foo@example.com
flags:
  require_email_verification: true
  disable_signup_without_invite: true
  disable_user_create_org: false
  allow_raw_resources: true
  allow_base_domain_resources: true

Traefik traefik_config.yaml

accessLog:
  bufferingSize: 100
  fields:
    defaultMode: drop
    headers:
      defaultMode: drop
      names:
        Authorization: redact
        Content-Type: keep
        Cookie: redact
        User-Agent: keep
        X-Forwarded-For: keep
        X-Forwarded-Proto: keep
        X-Real-Ip: keep
    names:
      ClientAddr: keep
      ClientHost: keep
      DownstreamContentSize: keep
      DownstreamStatus: keep
      Duration: keep
      RequestMethod: keep
      RequestPath: keep
      RequestProtocol: keep
      RetryAttempts: keep
      ServiceName: keep
      StartUTC: keep
      TLSCipher: keep
      TLSVersion: keep
  filePath: /var/log/traefik/access.log
  filters:
    minDuration: 100ms
    retryAttempts: true
    statusCodes:
      - 200-299
      - 400-499
      - 500-599
  format: json
api:
  dashboard: true
  insecure: true
certificatesResolvers:
  letsencrypt:
    acme:
      caServer: https://acme-v02.api.letsencrypt.org/directory
      email: foo@example.com
      storage: /letsencrypt/acme.json
      dnsChallenge:
        provider: 'infomaniak'
entryPoints:
  tcp-22:
    address: :22/tcp
  tcp-25:
    address: :25/tcp
  tcp-110:
    address: :110/tcp
  tcp-143:
    address: :143/tcp
  tcp-465:
    address: :465/tcp
  tcp-587:
    address: :587/tcp
  tcp-993:
    address: :993/tcp
  tcp-995:
    address: :995/tcp
  web:
    address: :80
  websecure:
    address: :443
    http:
      middlewares:
        - crowdsec@file
      tls:
        certResolver: letsencrypt
    transport:
      respondingTimeouts:
        readTimeout: 30m
experimental:
  plugins:
    badger:
      moduleName: github.com/fosrl/badger
      version: v1.2.0
    crowdsec:
      moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
      version: v1.4.4
log:
  format: json
  level: INFO
providers:
  file:
    filename: /etc/traefik/dynamic_config.yml
  http:
    endpoint: http://pangolin:3001/api/v1/traefik-config
    pollInterval: 5s
serversTransport:
  insecureSkipVerify: true

Traefik dynamic_config.yaml

http:
  middlewares:
    crowdsec:
      plugin:
        crowdsec:
          clientTrustedIPs:
            - 10.0.0.0/8
            - 172.16.0.0/12
            - 192.168.0.0/16
            - 100.89.137.0/20
          crowdsecAppsecEnabled: true
          crowdsecAppsecFailureBlock: true
          crowdsecAppsecHost: crowdsec:7422
          crowdsecAppsecUnreachableBlock: true
          crowdsecLapiHost: crowdsec:8080
          crowdsecLapiKey: 'foo123'
          crowdsecLapiScheme: http
          crowdsecMode: live
          defaultDecisionSeconds: 15
          enabled: true
          forwardedHeadersTrustedIPs:
            - 0.0.0.0/0
          httpTimeoutSeconds: 10
          logLevel: INFO
          updateIntervalSeconds: 15
          updateMaxFailure: 0
    default-whitelist:
      ipWhiteList:
        sourceRange:
          - 10.0.0.0/8
          - 192.168.0.0/16
          - 172.16.0.0/12
    redirect-to-https:
      redirectScheme:
        scheme: https
    security-headers:
      headers:
        contentTypeNosniff: true
        customFrameOptionsValue: SAMEORIGIN
        customResponseHeaders:
          Server: ''
          X-Forwarded-Proto: https
          X-Powered-By: ''
        forceSTSHeader: true
        hostsProxyHeaders:
          - X-Forwarded-Host
        referrerPolicy: strict-origin-when-cross-origin
        sslProxyHeaders:
          X-Forwarded-Proto: https
        stsIncludeSubdomains: true
        stsPreload: true
        stsSeconds: 63072000
  routers:
    api-router:
      entryPoints:
        - websecure
      middlewares:
        - security-headers
      rule: Host(`pangolin.example.com`) && PathPrefix(`/api/v1`)
      service: api-service
      tls:
        certResolver: letsencrypt
    main-app-router-redirect:
      entryPoints:
        - web
      middlewares:
        - redirect-to-https
      rule: Host(`pangolin.example.com`)
      service: next-service
    next-router:
      entryPoints:
        - websecure
      middlewares:
        - security-headers
      rule: Host(`pangolin.example.com`) && !PathPrefix(`/api/v1`)
      service: next-service
      tls:
        certResolver: letsencrypt
    ws-router:
      entryPoints:
        - websecure
      middlewares:
        - security-headers
      rule: Host(`pangolin.example.com`)
      service: api-service
      tls:
        certResolver: letsencrypt
  services:
    api-service:
      loadBalancer:
        servers:
          - url: http://pangolin:3000
    next-service:
      loadBalancer:
        servers:
          - url: http://pangolin:3002