1
0
Fork 0

invoice-ninja: Deploy Invoice Ninja

Invoice Ninja is a small business management tool.  Tabitha wants to
use it for HLC.

I am a bit concerned about the code quality of this application, and
definitely alarmed at the data it send upstream, so I have tried to be
extra careful with it.  All privileges are revoked, including access to
the Internet.
etcd
Dustin 2024-01-27 21:07:46 -06:00
parent a5d186b461
commit 4e15a9d71d
12 changed files with 650 additions and 0 deletions

View File

@ -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

72
invoice-ninja/README.md Normal file
View File

@ -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.

View File

@ -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

18
invoice-ninja/init.sh Normal file
View File

@ -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

View File

@ -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

View File

@ -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: {}

View File

@ -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

111
invoice-ninja/mariadb.yaml Normal file
View File

@ -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: {}

View File

@ -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

View File

@ -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

68
invoice-ninja/nginx.conf Normal file
View File

@ -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;
}
}
}

View File

@ -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==