diff --git a/argocd/applications/invoice-ninja.yaml b/argocd/applications/invoice-ninja.yaml new file mode 100644 index 0000000..7c7293c --- /dev/null +++ b/argocd/applications/invoice-ninja.yaml @@ -0,0 +1,13 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: invoice-ninja + namespace: argocd +spec: + destination: + server: https://kubernetes.default.svc + project: default + source: + path: invoice-ninja + repoURL: https://git.pyrocufflink.blue/infra/kubernetes.git + targetRevision: master diff --git a/invoice-ninja/README.md b/invoice-ninja/README.md new file mode 100644 index 0000000..1e4364c --- /dev/null +++ b/invoice-ninja/README.md @@ -0,0 +1,72 @@ +# Invoice Ninja + +[Invoice Ninja][0] is a free invoice and customer management system. Tabitha +uses it to manage her tutoring and learning center billing and payments. + +[0]: https://www.invoiceninja.org/ + + +## Components + +*Invoice Ninja* is a web-based application, written in PHP. The official +container image only includes the application itself and PHP-FPM, but no HTTP +server, so a separate *nginx* container is necessary. The image is also of +dubious quality, doing weird things like copying "backup" files to persistent +storage at startup, then deleting them from the container filesystem. To +work around this, an init container is necessary to copy the application into +writable ephemeral storage. + +Persistent storage is handled in a somewhat ad-hoc way. There are three paths +that are expected to be persistent: + +* `/var/www/app/public` +* `/var/www/app/storage` +* `/var/www/app/public/storage` + +The distinction between these is not really clear. Both "public" directories +have to be served by the web server, as well. + +In addition to the main process, a "cron" process is required. This has to +run every minute, apparently. + +*Invoice Ninja* also requires a MySQL or MariaDB database. Supposedly, +PostgreSQL can be used as well, but it is not supported by upstream and +apparently requires patching some PHP code. + + +## Phone Home + +Although *Invoice Ninja* can be self hosed, it relies on some cloud services +for some features. Notably, generating PDF invoices makes a few connections to +external services: + +* *fonts.googleapis.com*: Fetches CSS resources +* *invoicing.io*: Fetches the *Invoice Ninja* logo to print at the bottom + +Both of these remote resources are hard-coded into the HTML document template +that is used to render the PDF. The former is probably innocent, but I suspect +the latter is some kind of "phone home," informing upstream of field deployments. +Additionally, when certain actions are performed in the web UI, the backend +makes requests to *www.google-analytics.com*, obviously for telemetry. +Further, the *Invoice Ninja* documentation lists some "terms of service" for +self-hosting, which include sending personally identifiable information to +the *Invoice Ninja*, including company name and contact information, email +addresses, etc. + +The point of self-hosting applications is not to avoid paying for them (in +fact, I pay for some cloud services offered by open source developers, even +though I self-host their software), but to avoid dependencies on cloud +services. For *Invoice Ninja*, that means we should be able to make invoices +any time, even if upstream ceases offering their cloud service. Including a +"phone home" in the invoice generation that can prevent the feature from +working, even if it is by accident, is unacceptable. + +To that end, I have neutered *Invoice Ninja*'s phone-home capabilities. First, +a script runs before the main container starts that replaces the hard-coded +URL of the *Invoice Ninja* logo with the URL to the same logo in the local +installation. Next, I have blocked all outbound communication from *Invoice +Ninja* pods using a NetworkPolicy, except for Kubernetes services and the +forward proxy on the firewall. Finally, I have configured the forward proxy +(Squid) on the firewall to *only* allow access to *fonts.googleapis.com*, so +that invoices render correctly, blocking all telemetry and other phone-home +communication. diff --git a/invoice-ninja/ingress.yaml b/invoice-ninja/ingress.yaml new file mode 100644 index 0000000..0406eb2 --- /dev/null +++ b/invoice-ninja/ingress.yaml @@ -0,0 +1,19 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: invoice-ninja + labels: + app.kubernetes.io/name: invoice-ninja + app.kubernetes.io/component: invoice-ninja +spec: + rules: + - host: invoiceninja.pyrocufflink.blue + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: invoice-ninja + port: + name: http diff --git a/invoice-ninja/init.sh b/invoice-ninja/init.sh new file mode 100644 index 0000000..324b74b --- /dev/null +++ b/invoice-ninja/init.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +set -e + +cp -r /var/www/app/. /app + +# The Invoice Ninja logo on PDF invoices is always loaded from upstream's +# server, despite the APP_URL setting. +sed -i \ + -e 's@invoicing.co/images/new_logo.png@invoiceninja.pyrocufflink.blue/images/logo.png@' \ + /app/app/Utils/HtmlEngine.php + +chown -R invoiceninja:invoiceninja /app + +if [ "$(stat -c %u /storage)" -ne "$(id -u invoiceninja)" ]; then + chown -R invoiceninja:invoiceninja /storage + chmod -R u=rwx,go= /storage +fi diff --git a/invoice-ninja/invoice-ninja.env b/invoice-ninja/invoice-ninja.env new file mode 100644 index 0000000..1c2fa79 --- /dev/null +++ b/invoice-ninja/invoice-ninja.env @@ -0,0 +1,16 @@ +APP_LOGO=https://invoiceninja.pyrocufflink.blue/images/logo.png +APP_URL=https://invoiceninja.pyrocufflink.blue +TRUSTED_PROXIES=172.30.0.171,172.30.0.172,172.30.0.173 + +MAIL_MAILER=smtp +MAIL_HOST=mail.pyrocufflink.blue +MAIL_PORT=25 +MAIL_ENCRYPTION=null +MAIL_FROM_ADDRESS=invoice-ninja@pyrocufflink.net +MAIL_FROM_NAME='Invoice Ninja' + +EXPANDED_LOGGING=true + +http_proxy=http://172.30.0.1:3128 +https_proxy=http://172.30.0.1:3128 +NO_PROXY=local,pyrocufflink.blue,localhost diff --git a/invoice-ninja/invoice-ninja.yaml b/invoice-ninja/invoice-ninja.yaml new file mode 100644 index 0000000..0d5bd1e --- /dev/null +++ b/invoice-ninja/invoice-ninja.yaml @@ -0,0 +1,218 @@ +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: invoice-ninja + labels: + app.kubernetes.io/name: invoice-ninja + app.kubernetes.io/component: invoice-ninja + app.kubernetes.io/part-of: invoice-ninja +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: 1Gi + +--- +apiVersion: v1 +kind: Service +metadata: + name: invoice-ninja + labels: + app.kubernetes.io/name: invoice-ninja + app.kubernetes.io/component: invoice-ninja + app.kubernetes.io/part-of: invoice-ninja +spec: + ports: + - port: 8000 + targetPort: http + selector: + app.kubernetes.io/name: invoice-ninja + app.kubernetes.io/component: invoice-ninja + type: ClusterIP + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: invoice-ninja + labels: + app.kubernetes.io/name: invoice-ninja + app.kubernetes.io/component: invoice-ninja + app.kubernetes.io/part-of: invoice-ninja +spec: + selector: + matchLabels: + app.kubernetes.io/name: invoice-ninja + app.kubernetes.io/component: invoice-ninja + template: + metadata: + labels: + app.kubernetes.io/name: invoice-ninja + app.kubernetes.io/component: invoice-ninja + app.kubernetes.io/part-of: invoice-ninja + spec: + initContainers: + - name: init + image: &image docker.io/invoiceninja/invoiceninja:5.8.16 + command: + - /init.sh + securityContext: + capabilities: + drop: + - ALL + add: + - CHOWN + readOnlyRootFilesystem: true + runAsGroup: 0 + runAsNonRoot: false + runAsUser: 0 + volumeMounts: + - mountPath: /app + name: app + - mountPath: /init.sh + name: init + subPath: init.sh + - mountPath: /storage + name: data + subPath: storage + containers: + - name: invoice-ninja + image: *image + env: &env + - name: DB_HOST + value: invoice-ninja-db + - name: DB_DATABASE + value: ninja + - name: DB_USERNAME + value: ninja + - name: DB_PASSWORD_FILE + value: /run/secrets/invoiceninja/db.password + - name: APP_KEY_FILE + value: /run/secrets/invoiceninja/app.key + - name: APP_CIPHER + value: AES-256-GCM + - name: TRUSTED_PROXIES + value: '*' + envFrom: &envFrom + - configMapRef: + name: invoice-ninja + readinessProbe: &probe + tcpSocket: + port: 9000 + periodSeconds: 60 + startupProbe: + <<: *probe + periodSeconds: 1 + failureThreshold: 60 + securityContext: + readOnlyRootFilesystem: true + volumeMounts: &mounts + - mountPath: /run/secrets/invoiceninja + name: secrets + readOnly: true + - mountPath: /tmp + name: tmp + subPath: tmp + - mountPath: /var/www/app + name: app + - mountPath: /var/www/app/public/storage + name: data + subPath: storage-public + - mountPath: /var/www/app/storage + name: data + subPath: storage + - mountPath: /var/www/app/storage/logs + name: tmp + subPath: logs + - name: nginx + image: docker.io/library/nginx:1 + ports: + - containerPort: 8000 + name: http + readinessProbe: &probe + httpGet: + port: 8000 + path: /health + periodSeconds: 60 + startupProbe: + <<: *probe + periodSeconds: 1 + failureThreshold: 30 + securityContext: + readOnlyRootFilesystem: true + runAsUser: 101 + runAsGroup: 101 + volumeMounts: + - mountPath: /etc/nginx/nginx.conf + name: nginx-conf + subPath: nginx.conf + readOnly: true + - mountPath: /run/nginx + name: run + subPath: nginx + - mountPath: /var/cache/nginx + name: nginx-cache + - mountPath: /var/www/app/public + name: app + subPath: public + readOnly: true + - mountPath: /var/www/app/public/storage + name: data + subPath: storage-public + readOnly: true + - name: cron + image: *image + command: + - sh + - -c + - | + cleanup() { kill -TERM $!; exit; } + trap cleanup TERM + while sleep 60; do php artisan schedule:run; done + env: *env + envFrom: *envFrom + securityContext: + readOnlyRootFilesystem: true + volumeMounts: *mounts + enableServiceLinks: false + affinity: + podAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 1 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchExpressions: + - key: app.kubernetes.io/name + operator: In + values: + - invoice-ninja-db + securityContext: + runAsNonRoot: True + seccompProfile: + type: RuntimeDefault + volumes: + - name: app + emptyDir: {} + - name: data + persistentVolumeClaim: + claimName: invoice-ninja + - name: init + configMap: + name: invoice-ninja-init + defaultMode: 0755 + - name: nginx-cache + emptyDir: {} + - name: nginx-conf + configMap: + name: nginx + - name: run + emptyDir: + medium: Memory + - name: secrets + secret: + secretName: invoice-ninja + - name: tmp + emptyDir: {} diff --git a/invoice-ninja/kustomization.yaml b/invoice-ninja/kustomization.yaml new file mode 100644 index 0000000..9dcbc68 --- /dev/null +++ b/invoice-ninja/kustomization.yaml @@ -0,0 +1,30 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: invoice-ninja + +labels: +- pairs: + app.kubernetes.io/instance: invoice-ninja + includeSelectors: false + +resources: +- namespace.yaml +- secrets.yaml +- network-policy.yaml +- mariadb.yaml +- invoice-ninja.yaml +- ingress.yaml + +configMapGenerator: +- name: invoice-ninja-init + files: + - init.sh + +- name: invoice-ninja + envs: + - invoice-ninja.env + +- name: nginx + files: + - nginx.conf diff --git a/invoice-ninja/mariadb.yaml b/invoice-ninja/mariadb.yaml new file mode 100644 index 0000000..f6a416d --- /dev/null +++ b/invoice-ninja/mariadb.yaml @@ -0,0 +1,111 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: invoice-ninja-db + labels: + app.kubernetes.io/name: invoice-ninja-db + app.kubernetes.io/component: mysql + app.kubernetes.io/part-of: invoice-ninja +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + +--- +apiVersion: v1 +kind: Service +metadata: + name: invoice-ninja-db + labels: + app.kubernetes.io/name: invoice-ninja-db + app.kubernetes.io/component: mysql + app.kubernetes.io/part-of: invoice-ninja +spec: + ports: + - port: 3306 + targetPort: mysql + selector: + app.kubernetes.io/name: invoice-ninja-db + app.kubernetes.io/component: mysql + type: ClusterIP + +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: invoice-ninja-db + labels: + app.kubernetes.io/name: invoice-ninja-db + app.kubernetes.io/component: mysql + app.kubernetes.io/part-of: invoice-ninja +spec: + serviceName: invoice-ninja-db + selector: + matchLabels: + app.kubernetes.io/name: invoice-ninja-db + app.kubernetes.io/component: mysql + template: + metadata: + labels: + app.kubernetes.io/name: invoice-ninja-db + app.kubernetes.io/component: mysql + app.kubernetes.io/part-of: invoice-ninja + spec: + containers: + - name: mariadb + image: docker.io/library/mariadb:10.11.6 + env: + - name: MARIADB_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: mysql-root + key: password + - name: MARIADB_DATABASE + value: ninja + - name: MARIADB_USER + value: ninja + - name: MARIADB_PASSWORD + valueFrom: + secretKeyRef: + name: invoice-ninja + key: db.password + ports: + - containerPort: 3306 + name: mysql + readinessProbe: &probe + tcpSocket: + port: mysql + periodSeconds: 60 + startupProbe: + <<: *probe + periodSeconds: 1 + failureThreshold: 60 + securityContext: + readOnlyRootFilesystem: true + volumeMounts: + - mountPath: /run/mysqld + name: run + subPath: mysqld + - mountPath: /tmp + name: tmp + subPath: tmp + - mountPath: /var/lib/mysql + name: data + subPath: mysql + enableServiceLinks: false + securityContext: + runAsNonRoot: true + runAsUser: 3306 + runAsGroup: 3306 + fsGroup: 3306 + volumes: + - name: data + persistentVolumeClaim: + claimName: invoice-ninja-db + - name: run + emptyDir: + medium: Memory + - name: tmp + emptyDir: {} diff --git a/invoice-ninja/namespace.yaml b/invoice-ninja/namespace.yaml new file mode 100644 index 0000000..79a6c2c --- /dev/null +++ b/invoice-ninja/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: invoice-ninja + labels: + app.kubernetes.io/name: invoice-ninja + app.kubernetes.io/component: invoice-ninja diff --git a/invoice-ninja/network-policy.yaml b/invoice-ninja/network-policy.yaml new file mode 100644 index 0000000..e718c22 --- /dev/null +++ b/invoice-ninja/network-policy.yaml @@ -0,0 +1,46 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: invoice-ninja + labels: + app.kubernetes.io/name: invoice-ninja + app.kubernetes.io/component: invoice-ninja +spec: + egress: + - to: + - podSelector: + matchLabels: + app.kubernetes.io/part-of: invoice-ninja + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + - to: + - ipBlock: + cidr: 172.30.0.12/32 + ports: + - port: 25 + - to: + - ipBlock: + cidr: 172.30.0.160/28 + ports: + - port: 80 + - port: 443 + - to: + - ipBlock: + cidr: 172.30.0.1/32 + ports: + - port: 3128 + podSelector: + matchLabels: + app.kubernetes.io/component: invoice-ninja + policyTypes: + - Egress diff --git a/invoice-ninja/nginx.conf b/invoice-ninja/nginx.conf new file mode 100644 index 0000000..4e917c8 --- /dev/null +++ b/invoice-ninja/nginx.conf @@ -0,0 +1,68 @@ +worker_processes auto; + +error_log /var/log/nginx/error.log notice; + +pid /run/nginx/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + + gzip on; + + keepalive_timeout 65; + + upstream backend { + server 127.0.0.1:9000; + } + + server { + listen 8000 default; + server_name _; + + root /var/www/app/public; + + index index.php; + + charset utf-8; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location /health { + return 200 'UP'; + } + + location ~ \.php$ { + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass backend; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_intercept_errors on; + fastcgi_buffer_size 16k; + fastcgi_buffers 4 16k; + } + + location ~ /\.ht { + deny all; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + } +} diff --git a/invoice-ninja/secrets.yaml b/invoice-ninja/secrets.yaml new file mode 100644 index 0000000..bfd384c --- /dev/null +++ b/invoice-ninja/secrets.yaml @@ -0,0 +1,32 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: mysql-root + namespace: invoice-ninja + labels: + app.kubernetes.io/name: mysql-root + app.kubernetes.io/component: mysql + app.kubernetes.io/part-of: invoice-ninja +spec: + encryptedData: + password: AgCWJhpMd/GmSzYZv+lofE9vQrTBewpeUO7rPnZGy5n9lvwwSin3DSzqeUCh37byCQ086VjIA1AqcJAXkur8dcZWXRAXY3H26rDoEMjGIyfrUEByCLhSNhL3sK7AcE14QWOuoxtUSbGk5RmYc+qvIw8b4l/dNpEnatLCRUeF9CefMgnTk2phVMlzkasvXjxAvxcBIvDg7DLcBOsenGg1xNG8j8wQ8flGsX6bWHmlt1+EBhyp+8PS+GyOT1BmjnVyQeo2mKwXm+FY9WHlEswypKTVQAsV6F0fUh9gIFoAdklOMwxbaW8321xLfQQvB4Qkbx8N0YJYy1jFNMF6plwcZhE7KwxXoNjW3GQhyGqTq/iFDi/oLJmAjxH9Vz8RPGT5IyOLRIkrQjCDhWrIHAEh1TUVF2BorrV8gIQOLV2xP2Lxa20KIjVZdosntWPc8bp8Br4RiP0JIK/ktRIMt+cCOwwrux8FhJe8WklujnaiZ1HX7G8dgidtjmUXYBxyNOZ9FMs2+c7D3bgqNQsTQ/NMlyP02l5oXUNzQpIVNbY4t+AT0ISn8NP9xDmLVwFw0Y3lJbx5rDtqaSFivkMOsp20l/JVUkeyig3Trm6OLh9FzI6Qr4Qo6fPBSrqKu1ieQPF76C80phrTWwtiK67i2LSmtb2zAvm3Hwj4X4Ag7HIi8F7zF7HjgOcmmS+6fIgyaIufE6IeQtwFwekbWGTHWDFddias9qHBuM1QcnQP/SJZkZrR/A== + template: + metadata: + name: mysql-root + namespace: invoice-ninja + +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: invoice-ninja + namespace: invoice-ninja + labels: + app.kubernetes.io/name: invoice-ninja + app.kubernetes.io/component: invoice-ninja + app.kubernetes.io/part-of: invoice-ninja +spec: + encryptedData: + app.key: AgA1p4ay2avNqkFYzLIB5VXxIjFBoZ8YZksf89Q81vCXrUs9mxd2/PuaRFeVu9WZwYXElT/esF2TV8P0JfXI3OJrRqDTSDnPwphTTxDYokFTvK01kCMwjaGWnINX02aFL+N5rCpETBw2Ptojq3SRltbIG5xe9e1sfe0ltWU2YiwQ7Zp9ekQKV2A5TZjZ55rtjO6C3HqBYWz/AnxiizWGgBAIxHuM4D0mPHOK4u/7tnfC3CIaD8UqINE1Dz3qfeDDgf0rzgVq+b6pbaoEquCkvvbng2rFfY/MVq3tb3gFGR6G1qWA+XKPJwBm9ODWcHxAliqzMsvja26izwtK8ci3VBmaL6mWcyuaWcfA4Wbjo2sb7srcS9COjUvuZf6NiTqehBpplUHkqoyFq9+QO2zfVUZ0PMltEEuTmoG6K0PSnzROUjim5FavnVCzKlvLv8ChG/GE3sYMWxBFfJCpnXhfPkNyghok+WGiTqc4pBIy3KnqbCs1xxZhfJ3UVvd1Rkq1xEW3VnEJSg76EDerj5J526Q6V6i4dbXHnxeeoHLUfIGapIa4Pv63DwxSaH72gJXrtrT4X6XgQjEuZofLvm2q8QToTxSezT4Fc76ojVOJF3ssPJMwC7mx8hcYDjy1cWkw5pNvLgj5QDAspAgKSMuoe0YQU2ES7oOtTHZ7peSAGz+8zoJNoy1gQCGH95uztt/tkCyYPl3JaYF0A9j8oOWxAfJJMVBcxszt9EqD+j246JofJM5Gnn5SpgrIyWngpiK3G2xiM68= + db.password: AgCtaz+QGcnKsKonIsAmT9oiZHd9cmj4ntKZ2vhH4cwDzCw0mHu3s1NKGTFxqVkrxyn0S2PbM+6gSXFyz6FtxI+nhb1gP6+QbSLmbJk35+sdC90WYj51r+k1tjugGaw8RpdAACxHSe7Vf8S0fPS5JFqrLR5HzmthoqNwzChcXjALCkArSXEG2kuQj0Dx72NTStYOQCPth0pPFytako3gHlSHFgGrjQ/g/hOnrP/booIFl4GMAZnJ5CgwI4XQKP4VvyK8msF93T278pyFr7fFFVSLrYrzpqFYfpKrHdiwirooed52Xlwpy6tfFsD64kZ0hDd5xbzXStNxDBkPOOgEu+KSbqUuGu5s/TmqOhxD394RU3AcwiFiQ5nASldeTmzVqC1et5Wx2IuD1b0hVcqGTNh/6uaZRSSchM7enja1v++nd9W7eYkCLdzxUjMC5+GDC/MwNYrrPoIOZAjOLii2UTH0WmvvTu8R79wRmgqCzLykS2VQWaBcMlVQsbyj/IjBbAhTwZ1bu0HKwDQbWckCFQTixR1k612U3gK8P/TsqspSkip9WtlaR3eSwrjqImTe4fhdLI8B6oEYm/D6h4ciXthkl2uYtyd3gwMf3TsHlrev+aOV0K98oaAPkV4EkbDTSQfZDEvAlFwoLScPHBIUahWKPADES7O37cFwkYPo8JOC2yLjYGOlWh997EUsnB/rk2cIdlbVaS8HIWwO1QdPWHelOgBYo1lcWesxRswB1SM7bQ==