Published: Nov 14, 2024 by Isaac Johnson
Ealier this week we looked at three link shorteners and trackers; YOURLS, shlink and Reduced.to. We narrowed it to YOURLS and shlink as our go forward.
YOURLS
Despite the great CLI and REST backend of shlink, I think I’ll try going forward with YOURLS.
The first test was to see if I could POST to the API
$ curl -X POST -H "Content-Type: application/json" --user "user:asdfasdf" https://yourls.tpk.pw/yourls-api.php -d '{"url":"https://freshbrewed.science/2024/11/12/urlshortner.html","keyword":"shorten","title":"Blog: Open-source URL shorteners","format":"json","action":"shorturl","username":"user","password":"asdfasdf"}'
<?xml version="1.0" encoding="UTF-8"?>
<root><message>Please log in</message><errorCode>403</errorCode><callback></callback></root>
$ curl -X POST -H "Content-Type: application/json" https://yourls.tpk.pw/yourls-api.php -d '{"url":"https://freshbrewed.science/2024/11/12/urlshortner.html","keyword":"shorten","title":"Blog: Open-source URL shorteners","format":"json","action":"shorturl","username":"user","password":"asdfasdf","signature":"asdfasdfasdf"}'
<?xml version="1.0" encoding="UTF-8"?>
<root><message>Please log in</message><errorCode>403</errorCode><callback></callback></root>
Despite finding old code suggesting that would work, it clearly didnt. I saw in the latest docs they use GET with a signature you can get from your admin page:
I tried that (still including my user pass for the test)
$ curl -G \
ta-urlencode "ur> --data-urlencode "url=https://freshbrewed.science/2024/11/12/urlshortner.html" \
> --data-urlencode "keyword=shorten" \
> --data-urlencode "title=Blog: Open-source URL shorteners" \
> --data-urlencode "format=json" \
> --data-urlencode "action=shorturl" \
> --data-urlencode "username=user" \
> --data-urlencode "password=asdfasdfasfd" \
> --data-urlencode "signature=asdfasdfasdf" \
> https://yourls.tpk.pw/yourls-api.php
{"status":"success","code":"","message":"https:\/\/freshbrewed.science\/2024\/11\/12\/urlshortner.html added to database","errorCode":"","statusCode":200,"url":{"keyword":"shorten","url":"https:\/\/freshbrewed.science\/2024\/11\/12\/urlshortner.html","title":"Blog: Open-source URL shorteners","date":"2024-11-13 12:26:16","ip":"192.168.1.1"},"title":"Blog: Open-source URL shorteners","shorturl":"https:\/\/yourls.tpk.pw\/shorten"}
That clearly works, with the new links showing up in the interface
Production install
Let’s use an even shorter URL for this final prod setup.
$ az account set --subscription "Pay-As-You-Go" && az network dns record-set a add-record -g idjdnsrg -z tpk.pw -a 75.73.224.240 -n go
{
"ARecords": [
{
"ipv4Address": "75.73.224.240"
}
],
"TTL": 3600,
"etag": "07f9737a-922a-4ade-8c70-aada66aab373",
"fqdn": "go.tpk.pw.",
"id": "/subscriptions/d955c0ba-13dc-44cf-a29a-8fed74cbb22d/resourceGroups/idjdnsrg/providers/Microsoft.Network/dnszones/tpk.pw/A/go",
"name": "go",
"provisioningState": "Succeeded",
"resourceGroup": "idjdnsrg",
"targetResource": {},
"trafficManagementProfile": {},
"type": "Microsoft.Network/dnszones/A"
}
Before I move on, I need to create a MariaDB (MySQL) database and user we can use for deploy
ijohnson@SassyNassy:~$ mysql -u root -p
Enter password:
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 4197377
Server version: 10.3.32-MariaDB Source distribution
Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
MariaDB [(none)]> create database yourls;
Query OK, 1 row affected (0.046 sec)
MariaDB [(none)]> GRANT ALL PRIVILEGES ON yourls.* TO 'yourls'@'%' IDENTIFIED BY 'YourLs#Pass333444';
Query OK, 0 rows affected (0.463 sec)
MariaDB [(none)]> FLUSH PRIVILEGES;
Query OK, 0 rows affected (0.089 sec)
We can now launch the instance with helm using the database values
$ helm install yourls --create-namespace -n yourls --set mariadb.enabled=false,externalDatabase.host=192.168.1.129,externalDatabase.user=yourls,externalDatabase.password=asdfasdf,externalDatabase.database=yourls,yourls.domain=go.tpk.pw,yourls.username=builder yourls/yourls
NAME: yourls
LAST DEPLOYED: Wed Nov 13 06:48:19 2024
NAMESPACE: yourls
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
CHART NAME: yourls
CHART VERSION: 6.1.14
APP VERSION: 1.9.2
** Please be patient while the chart is being deployed **
Your YOURLS site can be accessed through the following DNS name from within your cluster:
yourls.yourls.svc.cluster.local (port 80)
To access your YOURLS site from outside the cluster follow the steps below:
1. Get the YOURLS URL by running these commands:
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
Watch the status with: 'kubectl get svc --namespace yourls -w yourls'
export SERVICE_IP=$(kubectl get svc --namespace yourls yourls --template "{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}")
echo "YOURLS URL: http://$SERVICE_IP/"
echo "YOURLS Admin URL: http://$SERVICE_IP/admin"
2. Open a browser and access YOURLS using the obtained URL.
3. Login with the following credentials below to see your app:
echo Username: $(kubectl get secret --namespace yourls yourls -o jsonpath="{.data.username}" | base64 --decode)
echo Password: $(kubectl get secret --namespace yourls yourls -o jsonpath="{.data.password}" | base64 --decode)
We can see it launched, but is awaiting our admin install
builder@DESKTOP-QADGF36:~$ kubectl get pods -n yourls
NAME READY STATUS RESTARTS AGE
yourls-7cf8df7659-gkrv4 0/1 Running 0 61s
builder@DESKTOP-QADGF36:~$ kubectl logs yourls-7cf8df7659-gkrv4 -n yourls
YOURLS not found in /var/www/html - copying now...
Complete! YOURLS has been successfully copied to /var/www/html
Running custom script /docker-entrypoint-init.d/*
... ignoring /docker-entrypoint-init.d/*
AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 10.42.2.183. Set the 'ServerName' directive globally to suppress this message
AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 10.42.2.183. Set the 'ServerName' directive globally to suppress this message
[Wed Nov 13 12:48:44.829469 2024] [mpm_prefork:notice] [pid 1:tid 1] AH00163: Apache/2.4.62 (Debian) PHP/8.3.13 configured -- resuming normal operations
[Wed Nov 13 12:48:44.829663 2024] [core:notice] [pid 1:tid 1] AH00094: Command line: 'apache2 -D FOREGROUND'
10.42.2.1 - - [13/Nov/2024:12:49:19 +0000] "GET /admin/install.php HTTP/1.1" 503 3134 "-" "kube-probe/1.26"
10.42.2.1 - - [13/Nov/2024:12:49:29 +0000] "GET /admin/install.php HTTP/1.1" 503 3134 "-" "kube-probe/1.26"
I tried to port-forward
$ kubectl port-forward yourls-7cf8df7659-gkrv4 -n yourls 8084:80
Forwarding from 127.0.0.1:8084 -> 80
Forwarding from [::1]:8084 -> 80
Handling connection for 8084
Handling connection for 8084
But the install says we cannot connect to the DB
It dawned on me that on this NAS, I have the port off from default (using 3307 instead of the standard MySQL 3306).
I updated with a helm update
adding the port to the values
$ helm upgrade yourls --create-namespace -n yourls --set mariadb.enabled=false,externalDatabase.host=192.168.1.129,externalDatabase.user=yourls,externalDatabase.password=asdfasdfasdfs,externalDatabase.database=yourls,externalDatabase.port=3307,yourls.domain
=go.tpk.pw,yourls.username=builder yourls/yourls
...
I tested locally
$ mysql --host=192.168.1.129 --user=yourls --password=YourLs#Pass333444 --port=3307 yourls
mysql: [Warning] Using a password on the command line interface can be insecure.
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 4197913
Server version: 5.5.5-10.3.32-MariaDB Source distribution
Copyright (c) 2000, 2024, Oracle and/or its affiliates.
Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
mysql> \dt;
mysql>
I’m going to try MariaDB 10 on my other NAS which runs on port 3306 (the standard). Perhaps it isn’t respecting the passed in port.
I tested my connection locally after creating the DB and user
builder@DESKTOP-QADGF36:~$ mysql --host=192.168.1.116 --user=yourls --password=asdfasdfasdf yourls
mysql: [Warning] Using a password on the command line interface can be insecure.
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 62
Server version: 5.5.5-10.11.6-MariaDB Source distribution
Copyright (c) 2000, 2024, Oracle and/or its affiliates.
Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
mysql> \q
Bye
Then a helm delete and install
$ helm delete yourls -n yourls
release "yourls" uninstalled
$ helm install yourls --create-namespace -n yourls --set mariadb.enabled=false,externalDatabase.host=192.168.1.116,externalDatabase.user=yourls,externalDatabase.password=MyYourls_334488,externalDatabase.database=yourls,yourls.domain=go.tpk.pw,yourls.username=builder yourls/yourls
NAME: yourls
LAST DEPLOYED: Wed Nov 13 07:22:42 2024
NAMESPACE: yourls
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
CHART NAME: yourls
CHART VERSION: 6.1.14
APP VERSION: 1.9.2
** Please be patient while the chart is being deployed **
Your YOURLS site can be accessed through the following DNS name from within your cluster:
yourls.yourls.svc.cluster.local (port 80)
To access your YOURLS site from outside the cluster follow the steps below:
1. Get the YOURLS URL by running these commands:
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
Watch the status with: 'kubectl get svc --namespace yourls -w yourls'
export SERVICE_IP=$(kubectl get svc --namespace yourls yourls --template "{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}")
echo "YOURLS URL: http://$SERVICE_IP/"
echo "YOURLS Admin URL: http://$SERVICE_IP/admin"
2. Open a browser and access YOURLS using the obtained URL.
3. Login with the following credentials below to see your app:
echo Username: $(kubectl get secret --namespace yourls yourls -o jsonpath="{.data.username}" | base64 --decode)
echo Password: $(kubectl get secret --namespace yourls yourls -o jsonpath="{.data.password}" | base64 --decode)
This time it seems to be working
$ kubectl port-forward yourls-866ffb8cf9-wsrf6 -n yourls 8084:80
Forwarding from 127.0.0.1:8084 -> 80
Forwarding from [::1]:8084 -> 80
Handling connection for 8084
Handling connection for 8084
Handling connection for 8084
And I see it complete
I can go ahead and make the ingress now that we’ve sorted out our DB connectivity issues.
Actually, im noticing it tried to make a LoadBalancer type of ingress. I’de much rather keep it as a ClusterIP as we plan to route our ingress there.
$ kubectl get svc -n yourls
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
yourls LoadBalancer 10.43.64.38 <pending> 80:31833/TCP,443:30527/TCP 4m15s
I should be able to do a helm upgrade to fix that with service.type=ClusterIP
$ helm upgrade yourls --create-namespace -n yourls --set mariadb.enabled=false,externalDatabase.host=192.168.1.116,externalDatabase.user=yourls,externalDatabase.password=asdfsadfasdf,externalDatabase.database=yourls,yourls.domain=go.tpk.pw,yourls.username=builder,service.type=ClusterIP yourls/yourls
Release "yourls" has been upgraded. Happy Helming!
NAME: yourls
LAST DEPLOYED: Wed Nov 13 07:29:14 2024
NAMESPACE: yourls
STATUS: deployed
REVISION: 2
TEST SUITE: None
NOTES:
CHART NAME: yourls
CHART VERSION: 6.1.14
APP VERSION: 1.9.2
... snip ...
That looks better
$ kubectl get svc -n yourls
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
yourls ClusterIP 10.43.64.38 <none> 80/TCP,443/TCP 7m5s
Let’s make that Ingress now
$ cat ./ingress.yourlsnew.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: azuredns-tpkpw
kubernetes.io/ingress.class: nginx
kubernetes.io/tls-acme: "true"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.org/websocket-services: yourls
name: yourlsingress
spec:
rules:
- host: go.tpk.pw
http:
paths:
- backend:
service:
name: yourls
port:
number: 80
path: /
pathType: ImplementationSpecific
tls:
- hosts:
- go.tpk.pw
secretName: mygo-tls
$ kubectl apply -f ./ingress.yourlsnew.yaml -n yourls
ingress.networking.k8s.io/yourlsingress created
Which started the cert process
$ kubectl get cert -n yourls
NAME READY SECRET AGE
mygo-tls False mygo-tls 32s
$ kubectl get ingress -n yourls
NAME CLASS HOSTS ADDRESS PORTS AGE
yourlsingress <none> go.tpk.pw 80, 443 40s
Once the cert was validated
$ kubectl get certificate -n yourls
NAME READY SECRET AGE
mygo-tls True mygo-tls 49s
I could login
I can now see the basic setup
Using in Github Actions
My next thought was about modifying my flow to put in a link for Socials in my Mastodon step (I will not use Twitter for obvious reasons).
- name: Post to Mastodon
run: |
export LATESTFILE=`ls -l _posts/ | grep ".* 20[0-9][0-9]-[0-9][0-9].*markdown" | tail -n1 | sed 's/.* \(20[0-9][0-9]-[0-9][0-9].*markdown\)/\1/'`
export LATESTURL=`ls -l _posts/ | grep ".* 20[0-9][0-9]-[0-9][0-9].*markdown" | tail -n1 | sed 's/.* \(20[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-\)\(.*\)\.markdown/\2.html/'`
echo '{"status":"' | tr -d '\n' > payload.json
cat _posts/$LATESTFILE | grep "^social: " | head -n 1 | sed 's/^social: "\(.*\)"/\1/' | tr -d '\n'>> payload.json
cat _posts/$LATESTFILE | grep "^date: " | head -n 1 | sed "s/^date: .\([0-9]*\)-\([0-9]*\)-\([0-9]*\) .*/ https:\/\/freshbrewed.science\/\1\/\2\/\3\/$LATESTURL/g" | sed 's/.markdown/.html/' | tr -d '\n' >> payload.json
echo '"}' >> payload.json
cat payload.json | base64
# allow a way to not repost on image updates and hotfixes
log=$(git log -n 1)
if [[ $log != *"SKIPSOCIAL"* ]]; then
echo "CHECK-OK: posting to social"
curl -X POST -H "Authorization: Bearer $MASTODONAPI" -H 'Content-Type: application/json' -d @payload.json https://noc.social/api/v1/statuses
else
echo "CHECK-SKIP: Skip Posting To Social.. would have posted:"
cat payload.json
fi
env:
MASTODONAPI: $
GHTOKEN: $
That Payload basically translates to a slug for noc.social, e.g.
{"status":"I needed to find a decent URL shortener and optionally link tracker and realized there are quite a few good Open-Source options for self-hosting. I the blog writeup i look at YOURLS, SHLink and reduced.to. I'll set all three up in Docker and Kubernetes and compare and contrast their features and usage. I'll also touch on the SaaS offering from reduced.to https://freshbrewed.science/2024/11/12/urlshortner.html"}
I found myself needing to work out the syntax for the bash locally so i can up with a quick test file
#!/bin/bash
set -x
# clean
rm ./title.txt || true
rm ./payload.json || true
rm ./target.url || true
rm ./yourls.output.json || true
export LATESTFILE=`ls -l _posts/ | grep ".* 20[0-9][0-9]-[0-9][0-9].*markdown" | tail -n1 | sed 's/.* \(20[0-9][0-9]-[0-9][0-9].*markdown\)/\1/'`
export LATESTURL=`ls -l _posts/ | grep ".* 20[0-9][0-9]-[0-9][0-9].*markdown" | tail -n1 | sed 's/.* \(20[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-\)\(.*\)\.markdown/\2.html/'`
echo '{"status":"' | tr -d '\n' > payload.json
cat _posts/$LATESTFILE | grep "^title: " | head -n 1 | sed 's/^title: "\(.*\)"/\1/' | tr -d '\n'> title.txt
cat _posts/$LATESTFILE | grep "^social: " | head -n 1 | sed 's/^social: "\(.*\)"/\1/' | tr -d '\n'>> payload.json
cat _posts/$LATESTFILE | grep "^date: " | head -n 1 | sed "s/^date: .\([0-9]*\)-\([0-9]*\)-\([0-9]*\) .*/https:\/\/freshbrewed.science\/\1\/\2\/\3\/$LATESTURL/g" | sed 's/.markdown/.html/' | tr -d '\n' > target.url
curl --data-urlencode "url=`cat target.url`" --data-urlencode "title=`cat title.txt`" --data-urlencode 'format=json' --data-urlencode 'action=shorturl' --data-urlencode 'signature=asdfasdf' "https://go.tpk.pw/yourls-api.php" | tee yourls.output.json
echo " " | tr -d '\n' >> payload.json
cat yourls.output.json | jq -r '.shorturl' | tr -d '\n' >> payload.json
echo '"}' >> payload.json
This gave a nice little slug (and would get a 400 back from an existing link but still deliver the same YOURLS payload)
$ cat yourls.output.json
{"status":"fail","code":"error:url","message":"https:\/\/freshbrewed.science\/2024\/11\/14\/links.html already exists in database (short URL: go.tpk.pw\/1)","errorCode":"400","statusCode":"400","url":{"keyword":"1","url":"https:\/\/freshbrewed.science\/2024\/11\/14\/links.html","title":"YOURLS in production","date":"2024-11-14 01:08:56","ip":"172.56.13.36","clicks":1},"title":"YOURLS in production","shorturl":"https:\/\/go.tpk.pw\/1"}
$ cat payload.json
{"status":"As I mentioned in the last post, I would move either shlink to yourls into production including a shorter url and real HA database backend. In the post, I do that as well as tie into Gthuba actions. https://go.tpk.pw/1"}
The redirect works, but obviously at the time of this writing the file doesnt exist just yet.
I added to the github action, but I’ll have to wait to test when this is published.
Plugins
Let’s assume we want random strings in the URLs instead of just a number that iterates. That’s actually a pre-installed plugin we can use OOTB.
We just need to enable it
Now if I delete the current link
and try my test script again, I get a random short string for the URL
{"status":"As I mentioned in the last post, I would move either shlink to yourls into production including a shorter url and real HA database backend. In the post, I do that as well as tie into Gthuba actions. https://go.tpk.pw/9g375 "}
Summary
This was not a tremendously long post but not every one needs to be. Our goal here was to take a URL shortener, in our case YOURLS and deploy it to a production cluster. We wanted to use an HA MariaDB backend which I did on my newer NAS. I then wanted to create a nicer short URL (go.tpk.pw) and lastly tie it in to my CICD, which is Github Actions.
If all goes well, this post should be the first to use the shortener. One consequence is that if my YOURLS ever junks out and people read my old posts, they won’t have direct links. I, of course, did all I could to ensure this service is restorable and the database is backed up on durable storage with dedicated battery backups (which they are). In fact the NASes (only them) have the USB sensors to power down on low battery from the APCs to make them even safer.