Saturday, August 22, 2026

That's What She Shed

 Alright.  We're slowly preparing to move out for a rebuild.

 And I have tools that I don't want to hire a rigger on to come and lift them.  I had put them on casters way back in the day, so I can actually roll them to where a rigger would lift and tie down on a trailer.  But, I'd need a rigger at the other end, and then when done with the rebuild, I'd need a rigger at the other end to re-load it, and the drop it off again here.

I needed a place to store things like lathes and drill presses and oxygen and argon tanks that are awkwardly large, and would need a lot more work to build the safety requirements necessary to even haul them.

So I built a shed.  Well, one minus a door.

After having gone through some of the health trauma, I know I'm not nearly where I was before that happened.  So this had to be very strategic.  It needed to be small enough, yet large enough to fit a few lathes, a drill press, welder, and hopefully a bandsaw.  I need this to last for exactly one year while the house is being built.  It doesn't require insulation, but it needs to shed (pun intended) water.

I started out by building the walls.  I took a day for each panel.


I also built some rafters (again, about a day for the lot).

Next, I measured out a foundation (using deck blocks).  This took two days, because I was working to get things parallel in three different dimensions (so that this could be level).

Once I was ready to proceed (there was a bit of a delay here), I threw in some nice 2x6"s and screwed the base together.



Once that was up, I dropped the base sheet of plywood.  Then, I could prop the walls up quickly (took a day for the two sides and the base sheet).

After a nice nights rest (I'm pushing myself too hard in order to beat a forecast of rain), I set the rafters on top of this thing and locked them down.  It was fairly sketchy at this point (a wee bit wobbly), but once the top roof sheets were on, it should tighten right up.




With that ready, I painted (a nice, acrylic latex outdoor paint).  Color didn't matter, but since I had to make someone mix a color in, I went with a nice wood plywood color.  Seriously.  I could have painted this thing purple, but then the neighbors would have proof that I was a mental case.  I still should have painted it purple.

And then I could do the roof.  First the plywood. Then tar paper (moisture barrier), and the shingles.  Before anyone says anything about the pitch of the roof - it is intentional.  I want this thing to shed, not just be a shed.



Anyway, as soon as I got the roof on, the rains hit. It couldn't have been better timing.  But, I still need a door.

Wednesday, August 12, 2026

Docker and Nagios

Note, this is a first run.  What I want to do is reduce as many files required as possible for the running, but I have not done so (e.g. mapping direct files, instead of entire directories).  But, the first run at this is functions.

Note, the jasonrivers/nagios image runs a web service of its own that requires cgi to be in the root.  This means that you won't be able to utilize proxy pass in Apache HTTPd unless you are mapping root for root (e.g. "/"), and not adding a "/nagios" onto the URL.

Okay, down to the nitty gritty. Here's how we'll go about this.

Here we go. 

 

Set up NRPE (Nagios Remote Plugin Executor)

First, install nrpe (this is so that the nagios instance can monitor our docker server that this is running on) on your docker host.  This is specifically because you probably want to monitor it, and host-to-host monitoring is easiest through nrpe.

    sudo yum install nrpe
    yum install nagios-plugins-swap.x86_64 nagios-plugins-uptime.x86_64 nagios-plugins-load.x86_64 nagios-plugins-disk.x86_64 nagios-plugins-ping.x86_64 nagios-plugins-check-updates.x86_64 nagios-plugins-users.x86_64 nagios-plugins-procs.x86_64
    

Edit your NRPE configuration (/etc/nagios/nrpe.cfg) and configure the commands you want.  Additionally, I changed my allowed hosts to match the docker network.  If you are absolutely crazy, comment out allowed_hosts out, which will allow ANYTHING to run checks against the service. My commands are :

    command[check_users]=/usr/lib64/nagios/plugins/check_users -w 5 -c 10
    command[check_load]=/usr/lib64/nagios/plugins/check_load -r -w .15,.10,.05 -c .30,.25,.20
    command[check_root]=/usr/lib64/nagios/plugins/check_disk -w 20% -c 10% -p /dev/mapper/rhel-root
    command[check_home]=/usr/lib64/nagios/plugins/check_disk -w 20% -c 10% -p /dev/mapper/rhel-home
    command[check_swap]=/usr/lib64/nagios/plugins/check_swap
    command[check_zombie_procs]=/usr/lib64/nagios/plugins/check_procs -w 5 -c 10 -s Z
    command[check_total_procs]=/usr/lib64/nagios/plugins/check_procs -w 275 -c 300
    command[check_uptime]=/usr/lib64/nagios/plugins/check_uptime
    command[check_gateway]=/usr/lib64/nagios/plugins/check_ping -H 10.0.0.1 -w 5,1% -c 10,5%
    command[check_internet]=/usr/lib64/nagios/plugins/check_ping -H 8.8.8.8 -w 20,1% -c 30,5%

then enable and start NRPE :

    systemctl enable nrpe
    systemctl start nrpe

And the service should be listening on port 5666

 

Nagios Docker

Next, create the docker container directory structure :

    cd /docker/images/path
    mkdir nagios
    cd nagios
    mkdir {etc,var,plugins,graphvar,graphetc}

launch the basic, no frills docker image first :

    docker run --name nagios4 jasonrivers/nagios

Then, hit ctrl-c to break out of it.  This installs the image with the name nagios4 that we can reference and copy out the configs we'll need.  Now you can copy the files out that you need :

    docker cp nagios4:opt/nagios/etc etc/
    docker cp nagios4:opt/nagios/var var/
    chown polkitd var
    chown polkitd graphvar
    

Clean up that image now :

    docker container rm nagios4

Now, you probably want to add the NRPE checks for the docker host at this point.  Edit the etc/objects/commands.cfg file, and add the following :

    define command {
            command_name    check_nrpe
            command_line    $USER1$/check_nrpe -H $HOSTADDRESS$ -c $ARG1$
    }

Then, edit the localhost.cfg (or create a new cfg file and ensure the nagios.cfg includes it) and add the host for your docker and any checks you want to run that you created in your nrpe configuration, e.g. :

    define service{
            use                  local-service,graphed-service   ; Name of service template to use
            host_name            docker
            service_description  Docker Host Internet
            check_command        check_nrpe!check_internet
    }

Next, you can modify those configurations, and launch your custom image using :

    docker run --name nagios -d --restart unless-stopped -e NAGIOS_FQDN=sharktooth.net -e NAGIOS_TIMEZONE=America/Boise -v /opt/docker/nagios/etc:/opt/nagios/etc -v /opt/docker/nagios/var:/opt/nagios/var -v /opt/docker/nagios/plugins:/opt/Custom-Nagios-Plugins -v /opt/docker/nagios/graphetc:/opt/nagiousgraph/etc -v /opt/docker/nagios/graphvar:/opt/nagiosgraph/var -p 0.0.0.0:6080:80 jasonrivers/nagios:latest

That should give it a quick start and have you online.  You can now customize those configurations and restart the container as needed.

 

What is installed

Now, I wanted some customizations, such as monitoring mqtt, but I kinda need to know what is included.  Sure, we have a custom plugin directory we can stuff things into, but if it's already there, then why should I also create a plugin?

While a simple directory listing would suffice, I just used the typical docker cp command, and copied out the plugin directory to see what had been installed.

    [username@docker-host ~]$ docker cp nagios:/opt/nagios/libexec tmp/
    Successfully copied 8.42MB to /home/username/tmp/
    [username@docker-host ~]$ ls -l tmp/libexec/
    total 8300
    -rwxr-xr-x. 1 username 1026 194024 Nov 6 2024 check_apt
    -rwxr-xr-x. 1 username 1026 2342 Nov 6 2024 check_breeze
    -rwxr-xr-x. 1 username 1026 205824 Nov 6 2024 check_by_ssh
    lrwxrwxrwx. 1 username 1026 9 Nov 6 2024 check_clamd -> check_tcp
    -rwxr-xr-x. 1 username 1026 139256 Nov 6 2024 check_cluster
    -rwxr-xr-x. 1 username 1026 204664 Nov 6 2024 check_dbi
    -r-sr-xr-x. 1 username 1026 210952 Nov 6 2024 check_dhcp
    -rwxr-xr-x. 1 username 1026 193304 Nov 6 2024 check_dig
    -rwxr-xr-x. 1 username 1026 336488 Nov 6 2024 check_disk
    -rwxr-xr-x. 1 username 1026 10130 Nov 6 2024 check_disk_smb
    -rwxr-xr-x. 1 username 1026 220352 Nov 6 2024 check_dns
    -rwxr-xr-x. 1 username 1026 97440 Nov 6 2024 check_dummy
    -rwxr-xr-x. 1 username 1026 5062 Nov 6 2024 check_file_age
    -rwxr-xr-x. 1 username 1026 6500 Nov 6 2024 check_flexlm
    -rwxr-xr-x. 1 username 1026 203824 Nov 6 2024 check_fping
    lrwxrwxrwx. 1 username 1026 9 Nov 6 2024 check_ftp -> check_tcp
    -rwxr-xr-x. 1 username 1026 156760 Nov 6 2024 check_game
    -rwxr-xr-x. 1 username 1026 191392 Nov 6 2024 check_hpjd
    -rwxr-xr-x. 1 username 1026 342280 Nov 6 2024 check_http
    -r-sr-xr-x. 1 username 1026 252320 Nov 6 2024 check_icmp
    -rwxr-xr-x. 1 username 1026 146576 Nov 6 2024 check_ide_smart
    -rwxr-xr-x. 1 username 1026 15271 Nov 6 2024 check_ifoperstatus
    -rwxr-xr-x. 1 username 1026 14477 Nov 6 2024 check_ifstatus
    lrwxrwxrwx. 1 username 1026 9 Nov 6 2024 check_imap -> check_tcp
    -rwxr-xr-x. 1 username 1026 6981 Nov 6 2024 check_ircd
    lrwxrwxrwx. 1 username 1026 9 Nov 6 2024 check_jabber -> check_tcp
    -rwxr-xr-x. 1 username 1026 2363 Nov 6 2024 check_jenkins
    -rwxr-xr-x. 1 username 1026 204960 Nov 6 2024 check_ldap
    lrwxrwxrwx. 1 username 1026 10 Nov 6 2024 check_ldaps -> check_ldap
    -rwxr-xr-x. 1 username 1026 181336 Nov 6 2024 check_load
    -rwxr-xr-x. 1 username 1026 8182 Nov 6 2024 check_log
    -rwxr-xr-x. 1 username 1026 25719 Nov 6 2024 check_mailq
    -rwxr-xr-x. 1 username 1026 19217 Nov 6 2024 check_mem.pl
    -rwxr-xr-x. 1 username 1026 14163 Nov 6 2024 check-mqtt.py
    -rwxr-xr-x. 1 username 1026 146112 Nov 6 2024 check_mrtg
    -rwxr-xr-x. 1 username 1026 148776 Nov 6 2024 check_mrtgtraf
    -rwxr-xr-x. 1 username 1026 14953 Nov 6 2024 check_mssql_database.py
    -rwxr-xr-x. 1 username 1026 21856 Nov 6 2024 check_mssql_server.py
    -rwxr-xr-x. 1 username 1026 158376 Nov 6 2024 check_nagios
    -rwxr-xr-x. 1 username 1026 12200 Nov 6 2024 check_ncpa.py
    lrwxrwxrwx. 1 username 1026 9 Nov 6 2024 check_nntp -> check_tcp
    lrwxrwxrwx. 1 username 1026 9 Nov 6 2024 check_nntps -> check_tcp
    -rwxr-xr-x. 1 username 1026 156328 Nov 6 2024 check_nrpe
    -rwxr-xr-x. 1 username 1026 206624 Nov 6 2024 check_nt
    -rwxr-xr-x. 1 username 1026 225112 Nov 6 2024 check_ntp
    -rwxr-xr-x. 1 username 1026 203008 Nov 6 2024 check_ntp_peer
    -rwxr-xr-x. 1 username 1026 207672 Nov 6 2024 check_ntp_time
    -rwxr-xr-x. 1 username 1026 219552 Nov 6 2024 check_nwstat
    -rwxr-xr-x. 1 username 1026 9495 Nov 6 2024 check_oracle
    -rwxr-xr-x. 1 username 1026 172160 Nov 6 2024 check_overcr
    -rwxr-xr-x. 1 username 1026 195272 Nov 6 2024 check_pgsql
    -rwxr-xr-x. 1 username 1026 207440 Nov 6 2024 check_ping
    lrwxrwxrwx. 1 username 1026 9 Nov 6 2024 check_pop -> check_tcp
    -rwxr-xr-x. 1 username 1026 209304 Nov 6 2024 check_procs
    -rwxr-xr-x. 1 username 1026 170464 Nov 6 2024 check_real
    -rwxr-xr-x. 1 username 1026 9675 Nov 6 2024 check_rpc
    -rwxr-xr-x. 1 username 1026 1629 Nov 6 2024 check_sensors
    lrwxrwxrwx. 1 username 1026 9 Nov 6 2024 check_simap -> check_tcp
    -rwxr-xr-x. 1 username 1026 237160 Nov 6 2024 check_smtp
    -rwxr-xr-x. 1 username 1026 250280 Nov 6 2024 check_snmp
    lrwxrwxrwx. 1 username 1026 9 Nov 6 2024 check_spop -> check_tcp
    -rwxr-xr-x. 1 username 1026 8481 Nov 6 2024 check_sql
    -rwxr-xr-x. 1 username 1026 166736 Nov 6 2024 check_ssh
    -rwxr-xr-x. 1 username 1026 12544 Nov 6 2024 check_ssl_validity
    lrwxrwxrwx. 1 username 1026 9 Nov 6 2024 check_ssmtp -> check_tcp
    -rwxr-xr-x. 1 username 1026 143696 Nov 6 2024 check_swap
    -rwxr-xr-x. 1 username 1026 218216 Nov 6 2024 check_tcp
    -rwxr-xr-x. 1 username 1026 167368 Nov 6 2024 check_time
    lrwxrwxrwx. 1 username 1026 9 Nov 6 2024 check_udp -> check_tcp
    -rwxr-xr-x. 1 username 1026 186936 Nov 6 2024 check_ups
    -rwxr-xr-x. 1 username 1026 133536 Nov 6 2024 check_uptime
    -rwxr-xr-x. 1 username 1026 131696 Nov 6 2024 check_users
    -rwxr-xr-x. 1 username 1026 8193 Nov 6 2024 check_vpn
    -rwxr-xr-x. 1 username 1026 3266 Nov 6 2024 check_wave
    lrwxrwxrwx. 1 username 1026 20 Nov 6 2024 mibs -> /usr/share/snmp/mibs
    -rwxr-xr-x. 1 username 1026 131912 Nov 6 2024 negate
    -rwxr-xr-x. 1 username 1026 124112 Nov 6 2024 remove_perfdata
    -rwxr-xr-x. 1 username 1026 123440 Nov 6 2024 urlize
    -rwxr-xr-x. 1 username 1026 1920 Nov 6 2024 utils.pm
    -rwxr-xr-x. 1 username 1026 2792 Nov 6 2024 utils.sh [username@docker-host ~]$

This tells me exactly what we have available to us straight out of the box, and lists things like check_jabber, check-mqtt.py, and check_snmp (so, in theory, I COULD monitor my NAS without customization - that's cool).

 

Monitoring MQTT and SNMP

Let's get those set up.  Now, in the commands.cfg file, there is already a check_snmp command configured.  That means we don't have to define a command, but we just point to our NAS for HDD monitoring.  We have three, just because I can :

    # NAS stuff
    # the overall health
    define service{
            use                             local-service,graphed-service
            host_name                       nas
            service_description             NAS Overall Health
    	check_command			check_snmp!-C community -o .1.3.6.1.4.1.6574.1.1 -r 1
    }
    # the NAS filesystems
    define service{
            use                             local-service,graphed-service
            host_name                       nas
            service_description             NAS Volume 1 Health
    	check_command			check_snmp!-C NASHealthCheck -o .1.3.6.1.4.1.6574.3.1.1.3.0 -r 1
    }
    # NAS temperature
    define service{
            use                             local-service,graphed-service
            host_name                       nas
            service_description             NAS Temperature
    	check_command			check_snmp!-C NASHealthCheck -o .1.3.6.1.4.1.6574.1.2 -r 3
    }  

I loaded it, and all the snmp just failed.  Bah!  Wait a minute.... didn't I have to open UDP ports for the weatherflow crap?

Let's add it to the firewall.  I threw the entire pot of spaghetti at the wall hoping something stuck :

    sudo firewall-cmd --zone public --permanent --add-service=snmp
    sudo firewall-cmd --permanent --add-port=161/udp sudo firewall-cmd --reload

Nagios eventually loaded those and they started checking OK.

On to MQTT.  This one doesn't have a command defined, so we'll have to do that first, and we can (in theory) run the check-mqtt.py file to get output.

Note, to be able to run the check-mqtt.py from a server that didn't have this properly installed to (e.g. not in the container), I had to install the python3-paho-mqtt.noarch package.  Once done, I could run the "./check-mqtt.py" to see output.

    [username@docker-host nagios]$ /home/username/tmp/libexec/check-mqtt.py  -?
    usage: check-mqtt.py [-h] [-d] [-H ] [-P ] [-u ] [-p ] [-m ] [-e ] [--sleep ] [-a ] [-C ] [-k ] [-n] [-t ]
                         [-s ] [-r] [-l ] [-v ] [-o ] [-w ] [-c ] [-S] [-V]
    check-mqtt.py: error: unrecognized arguments: -?
    [username@docker-host nagios]$

This gives us the basics we need to build a command.

    define command {
            command_name    check_mqtt
            command_line    $USER1$/check-mqtt.py -H $HOSTNAME$ $ARG1$
      }

Slap together a quick command like (I put the options from the output into there, but they are also listed at https://github.com/check-plugins/check_mqtt ) :

    # check MQTT queues
    define service{
            use                             local-service,graphed-service
            host_name                       docker
            service_description             MQTT
    	check_command			check_mqtt!-t topic
    # usage: check-mqtt.py [-h] [-d] [-H ] [-P ] [-u ] [-p ] [-m ] [-e ] [--sleep ] [-a ] [-C ] [-k ] [-n] [-t ]
    #                     [-s ] [-r] [-l ] [-v ] [-o ] [-w ] [-c ] [-S] [-V]
    }

and restart Nagios.  I do have some tweaking to get it to work right, since it went into a yellow/WARNING state with some mumbo jumbo about "No output on stdout".  So, let's fix that hurdle next.

    [username@docker-host nagios]$ docker container exec nagios /opt/nagios/libexec/check-mqtt.py -H docker -t topic
    Traceback (most recent call last):
      File "/opt/nagios/libexec/check-mqtt.py", line 257, in 
        mqttc = paho.Client('nagios-%d' % (os.getpid()), clean_session=True, userdata=userdata, protocol=4)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      File "/usr/local/lib/python3.12/dist-packages/paho/mqtt/client.py", line 772, in __init__
        raise ValueError(
    ValueError: Unsupported callback API version: version 2.0 added a callback_api_version, see docs/migrations.rst for details
    [username@docker-host nagios]$
    

Quick research, tells me the Client declaration needs modification after an update to a version, so I copied the check-mqtt.py into the custom plugins folder for the docker instance with the following patch applied to it :

    -- /home/username/tmp/libexec/check-mqtt.py	2024-11-06 02:35:46.000000000 -0700
    +++ plugins/check-mqtt.py	2026-08-13 08:22:59.243414998 -0600
    @@ -254,7 +254,7 @@ userdata = {
         'have_response' : False,
         'start_time'    : time.time(),
     }
    -mqttc = paho.Client('nagios-%d' % (os.getpid()), clean_session=True, userdata=userdata, protocol=4)
    +mqttc = paho.Client(paho.CallbackAPIVersion.VERSION1,'nagios-%d' % (os.getpid()), clean_session=True, userdata=userdata, protocol=4)
     mqttc.on_message = on_message
     mqttc.on_connect = on_connect
     mqttc.on_disconnect = on_disconnect
    

this alloed to run

    [username@docker-host nagios]$ docker container exec nagios /opt/Custom-Nagios-Plugins/check-mqtt.py -H docker -t topic
    /opt/Custom-Nagios-Plugins/check-mqtt.py:257: DeprecationWarning: Callback API version 1 is deprecated, update to latest version
      mqttc = paho.Client(paho.CallbackAPIVersion.VERSION1,'nagios-%d' % (os.getpid()), clean_session=True, userdata=userdata, protocol=4)
    OK - message from topic at docker in 0.31s | response_time=0.31 value=PiNG
    [username@docker-host nagios]$

And then I updated my command to point to the new module :

    define command {
            command_name    check_mqtt
            command_line    /opt/Custom-Nagios-Plugins/check-mqtt.py -H $HOSTNAME$ $ARG1$
            #command_line    $USER1$/check-mqtt.py -H $HOSTNAME$ $ARG1$
    }

Success! I'm working, even with the deprecation warning.  I did create an issue in the jasonrivers/nagios github for it.

Friday, July 24, 2026

Docker, Apache HTTPd, and Perl with DBI

Okay.  I swore about crap all the day long, trying to get CGI's to write to a database using the default httpd:2.4 docker image.

NOTHING worked.

Until I found some obscure post referencing extending an image, I was at a loss.  Then, with the reference to "extending an image", I knew what to search for, and finally got it.  Here's what to do.  You can "create" a new image using a "build", e.g. create a Dockerfile containing :

    # Sample Dockerfile for extending a container
    FROM httpd:latest
    RUN apt-get update && apt-get install -y libdbd-mysql-perl
    CMD ["httpd-foreground"]

The file says to take the httpd:latest, run a command on it, then set the command to run (could already be there, I don't know - it just worked and make me very happy).

Then, run :

    docker build -t httpd:mysql .

After that, change your docker-compose.yml to reference your new image, and then you should be able to make it function.  Granted, CGI's need the REMOTE_USER if you are doing anything specific with that, so add the config where you need it to pull the authenticated user from the Cloudflare header containing the username and stuff it.

    # set the REMOTE_USER variable
    RewriteCond %{HTTP:Cf-Access-Authenticated-User-Email} ^(.*)$
    RewriteRule .* - [E=REMOTE_USER:%1]

Then stop the container and start it back up, and that part should be functional.  The bonus is you can extend all you want. 

Cloudflare (Personal) for Secure Tunnel + Specific Google Account Access

I'm working on getting rid of my virtual server at a service provider (cheaper, right), and I don't want to shift to a static IP or an open firewall.  NetworkChuck posted a video about setting up a "free" account with Cloudflare for Zero Trust, and that resulted in this direction.  So, here we go.

I signed up at Cloudflare following NetworkChuck's video (there were some differences - they've changed their website since he posted the video).  Then, once I had the DNS transferred over and detected, it was time to start setting it up.

Note, you still have to provide a credit card (or other method for payment such as PayPal or a bank) and "sign" the form indicating that you will pay for services that exceed the "free" version limits.  That means you could stop now if you don't want to proceed.

Okay.  Once you have your site added and the DNS transferred, on the left side menu (toward the bottom) is a "Zero Trust" link.  Click that.  You will work through their wizard for activating Zero Trust (including the above bank/card information).

Once complete, click on "Networks" on the side to expand it, and you'll see "Tunnels and Mesh".  Click on that.

Then, click "Add a Tunnel".  I selected the Cloudflare tunnel rather than a mesh tunnel.  Provide a name, then save the tunnel.

Now, if you aren't using Docker, you can select the OS type (e.g. RedHat, Debian, Windows), all of which have the instructions on installation.  Even though I am working on RedHat, I went with the Docker OS type. This will present you with the commands to run.  In order to make the docker image background, after the FIRST run in there (because the Docker image has another command for "run"), add a -d (for detach).

    docker run -d cloudflare/cloudflared:latest tunnel --no-autoupdate run --token [TOKEN]

I'd recommend storing your token somewhere safe.  Really.  Safe.

The command will start a docker image.  Now, just to be sure that this image starts after a power outage, I ran an update to the policy.  Get the policy name from docker first, then run the update :

    docker ps
    docker update --restart unless-stopped container_name

This should make it permanent.  I did rename my "docker container rename CONTAINER NEW_CONTAINER_NAME".

 Now, I followed NetChuck's instructions on the last step of setting up the tunnel a little too exact, and had some problems (bad gateway error).

So, getting the logs revealed that :

    docker container logs cloudflare -f

The above command dumped the logs and kept watching for more log updates (like tail -f), so I knew what the problem was.

    2026-07-23T15:39:03Z ERR  error="Unable to reach the origin service. The service may be down or it may not be responding to traffic from cloudflared: dial tcp 10.0.17.45:8006: connect: connection refused" connIndex=2 event=1 ingressRule=0 originService=https://10.0.17.45:8006
    2026-07-23T15:39:03Z ERR Request failed error="Unable to reach the origin service. The service may be down or it may not be responding to traffic from cloudflared: dial tcp 10.0.17.45:8006: connect: connection refused" connIndex=2 dest=https://hostname.domain/favicon.ico event=0 ip=131.67.22.37 type=http

It turns out the URL wasn't for the Cloudflare service, but actually for the web service I needed to connect to.  Duh.

Okay, under "Tunnels & Mesh", click on "Published application routes" at the top, then the three dots to the right of the connection we just set up, and then "Edit". 

Once fixed, save that thing, and reload it.

Now, since we don't have authentication, we'd better get OpenIDC authentication going.  I fought long and hard trying to get the Apache HTTPd docker image to load the mod_auth_openidc module so I could configure it that way, but I stopped short of rebuilding an entire httpd specifically for that.  I was getting errors loading the module if I manually mapped it from an existing ubuntu image, but that gave me errors about dynamic .so file load failures for libcrypt as well.

I rapidly gave up and in the Cloudflare dashboard (https://dash.cloudflare.com/), I went under Zero-Trust, and then hit "integrations".

In there, click on "Identity Providers" followed by "Add an Identity Provider", and then fill out the information you get from your Google (or whatever service provider you are using).  Hit the "test" button.

If it gives you an error when testing, such as :

If it gives you an error, get more details (Google likes to add a link about if you are the application developer, and that will provide a redirect URL - copy that).  Then, go into your application provider where you set up your OAuth2.0 key, and add it as a redirection URL.  If it complains about a space in it and refuses to let you save it, you've likely copied too much - just whack everything after the "callback" where the space starts, and then save it.

Next, go under "Access Controls", and "Applications".  Follow the wizard to create a new "application" - it can be a sub URI, or you can lock down the whole site (which is what I did).

Here, you will also create a policy (I did a policy that only allows specific Google e-mail addresses, both those that are in a google domain as well as one or two that are outside of it).

Once that is done, before you finish the application, if you are only authenticating against one source, I'd recommend selecting that source and not showing the prompt for multiple sources.  However, if you are going to accept users from other locations (such as Entra, etc), leave those as-is. 

Suddenly, you should have immediate access if you've logged in to Google.

The next thing I had to figure out was how to get Grafana to recognize the user.  And that meant going into the Grafana docker and changing the GF_AUTH_PROXY_HEADER_NAME value :

     - GF_AUTH_PROXY_HEADER_NAME=Cf-Access-Authenticated-User-Email

After restarting the docker image, this let me use the username in the dashboards for Grafana, and view my data.  Sweet!

Wednesday, July 22, 2026

Docker and MQTT (Mosquito)

My Mosquito implementation is not heavily modified.  In fact, it's so simple, I could do a :

    docker run -d ---name mosquitto -p 1883:1883 eclipse-mosquitto:2

This would be just fine.  However, I want my docker image to restart after reboots, and I'm too lazy to go about it any other way than a docker-compose.yml :

    # docker-compose.yml - Mosquitto MQTT broker
    services:
    mosquitto:
    image: eclipse-mosquitto:2
    container_name: mqtt
    ports:
    # Standard MQTT protocol
    - "1883:1883"
    # MQTT over WebSocket
    - "9001:9001"
    #volumes:
    # Mount configuration directory
    #- ./config:/mosquitto/config
    # Persist message data
    #- ./data:/mosquitto/data
    # Persist log files
    #- ./log:/mosquitto/log
    restart: unless-stopped

Note the entire "volumes" section has been commented out.  I started to add it, and realized I didn't care about persisting message or log data, nor was I running a custom configuration.  So, I left it out.

Then, start it up in the usual fashion with a :

    docker compose up -d

And off you go! 

Tempest Weatherflow - Data Mapping into MySQL

I'm slowly migrating off of a virtual server at a service provider.  Most things have been moving to docker.  In this case, it's going onto the docker server, but not going to run in docker itself.

It has to be on the same network as the Weatherflow device to be able to pick up data, and me keeping it on the unrouteable network meant this docker platform needed to be on two separate networks.  So, I fired off the command to create a new connection on the same interface.

    nmcli connection add con-name unrouteable ifname enp1s0 type ethernet ipv4.method manual ipv4.addresses 192.168.2.10/24 ipv4.never-default yes

Unfortunately, this resulted in the system not being able to boot onto the network.  So, it was backed out, and I moved the weather hub off of the unrouteable to the regular network.  The code below, I simply could NOT get to arrive in any docker image, or running directly on the docker server (outside of a container).  However, after a lot of troubleshooting to verify that the broadcast packets were getting there using :

    sudo tcpdump -i enp1s0 udp port 50222 -Xvvv

I finally realized that RedHat had firewall-cmd running by default.  Now, I'm trying to make all this work with defaults, which means that instead of disabling it, I'm going to open the port :

    firewall-cmd --permanent --add-port=50222/udp
    firewall-cmd --reload
    firewall-cmd --list-all

You should see the ports: line now containing "50222/udp".  And, if you run the server, it should show it's receiving packets.  Okay, back to building this "listener".  I went down this whole path of making it run an exec() to curl, which made it really easy to send the received JSON to a perl script and customize whatever you needed from there.  Yes, at the very bottom of the code I have commented out a "sendJSON" function, and instead I'm using a handleJSON function that will do the parsing, so if you need to flip that switch because you are using a non-docker version of HTTPd and Perl, you can install the package and make it happen.

Since the Perl instances in the Apache/HTTPd 2.4 instances ("httpd 2.4") do NOT include either the Perl JSON module, or the DBD::MySQL module, it ultimately means you can't send this to Apache and deal with it nicely there if you are also using native docker.  That leaves one choice - write a UDP "listener" (duh, UDP packages don't "listen" on UDP ports, because there is no connection/handshake).

So, I wrote the dumb thing in perl. I'd already had the UDP side, but since it might as well do the local database insertion, whelp, that's what I did.

Fancy thing is that I can specify which datagram types to accept from the Tempest system.  I really only care about lightning strikes and obs_st (standard metrics).  But, I slapped SQL for all, and did a fancy ".disabled" extension to hide the ones I wanted.  If I don't have a disabled, when it sees a packet, it will dump the hash_ref so that I can easily write one if I need it.  I just wrote what I saw, then disabled most of them.

Here's the code for the listener :

    #!/usr/bin/perl -w

    # written by Joe Lewis <joe@joe-lewis.com> on November 29, 2022
    # A UDP listener for the tempest weather system. The API for weatherflow (maker
    # of the tempest system) can be found at :
    # https://weatherflow.github.io/Tempest/api/udp.html
    # if on RHEL, install :
    # perl-libwww-perl
    # perl-Sys-Syslog.x86_64
    # for RHEL, this uses the template tool kit (available in the EPEL repository)
    # sudo subscription-manager repos --enable codeready-builder-for-rhel-9-x86_64-rpms
    # sudo dnf install https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm
    # sudo yum install perl-Template-Toolkit.x86_64


    use strict;
    use warnings;
    use Data::Dumper;
    use DBI;
    use Getopt::Long;
    use HTTP::Request;
    use HTTP::Response;
    use IO::Socket;
    use JSON;
    use LWP::UserAgent;
    use POSIX;
    use Sys::Syslog qw(:DEFAULT :standard :macros);
    use Template;


    my $template_directory = 'sql';
    my $mysql_hostname = '127.0.0.1'; # if you use "localhost", it will try the unix socket
    my $mysql_username = 'user_only_having_insert_or_update';
    my $mysql_password = 'users_password';
    my $mysql_database = 'weather';

    my $verbose = undef;
    my $daemonize = undef;

    GetOptions(
    'sql-directory=s' => \$template_directory,
    'daemonize' => \$daemonize,
    'verbose' => \$verbose,
    );


    my($sock, $newmsg, $MAXLEN, $PORTNO);
    my $json = JSON->new->allow_nonref;
    my $userAgent = LWP::UserAgent->new(timeout => 10);
    my $postURL = 'https://protected.silverhawk.net/cgi-bin/weatherflow.pl';
    my $postContentType = 'application/json';
    my $name = 'WeatherFlow-UDP-conv';
    my $nagios_command_file = '/var/lib/nagios3/rw/nagios.cmd';
    my $error_log_qfn = '/var/log/'.$name.'.log';
    my $pid_file_qfn = '/var/run/'.$name.'.pid';

    $MAXLEN = 1024;
    $PORTNO = 50222;

    sub sighup_handler {
    exit;
    };

    sub sigusr1_handler {
    exit;
    };

    sub logMessage {
    my $msg = shift;
    $msg .= "\n" unless $msg =~ /\n/;
    syslog('info',$msg);
    };

    sub handleUDPPacket {
    my $weatherstationname = shift;
    my $weatherstationclient = shift;
    my $data = shift;
    my $href = $json->decode($data);
    my $weatherstationip = inet_ntoa($weatherstationclient);
    # just for my sanity, insert the hostname
    $href->{'client_hostname'} = $weatherstationname;
    $href->{'client_ip'} = $weatherstationip;

    my $sqlTemplate = $template_directory.'/'.$href->{type}.'.sql';
    if (-f $sqlTemplate) {
    my $tt = Template->new({INCLUDE_PATH=>$template_directory,INTERPOLATE => 1}) or die "Error launching the template toolkit : $Template::ERROR\n";
    # establish a MySQL connection
    my $dbh = DBI->connect('dbi:mysql:host='.$mysql_hostname.':database='.$mysql_database.':mysql_ssl=1',$mysql_username,$mysql_password);
    $href->{dbh} = $dbh;
    my $SQL;
    $tt->process($href->{type}.'.sql',$href,\$SQL) or warn 'Template Processing Failure: '.$tt->error()."\n";
    if ($SQL) {
    $dbh->do($SQL) or warn "Error running SQL on type $href->{type}: $DBI::errstr, SQL :\n\t$SQL\n";
    }
    $dbh->disconnect();
    } else {
    # if we have a disabled SQL file, do nothing because we're intentionally swallowing it.
    if (!-f $sqlTemplate.'.disabled') {
    # no template defined, warn it
    warn "non-existant SQL template $sqlTemplate containing:\n".Dumper($href);
    }
    };
    };

    openlog("",'ndelay','daemon');
    if (defined($daemonize)) {
    my $pid = fork();
    exit if ($pid > 0);
    setsid();
    $pid = fork();
    exit if ($pid > 0);
    };
    $SIG{HUP} = \&sighup_handler;
    $SIG{USR1} = \&sigusr1_handler;

    $sock = IO::Socket::INET->new(
    LocalPort => $PORTNO,
    Proto => 'udp') or die "socket: $@";

    logMessage("Awaiting UDP messages on port $PORTNO");

    while ($sock->recv($newmsg, $MAXLEN)) {
    my($clientport, $clientaddr) = sockaddr_in($sock->peername);
    my $clienthost = gethostbyaddr($clientaddr, AF_INET);
    handleUDPPacket($clienthost,$clientaddr,$newmsg);
    }
    my $status = $!;
    logMessage("Loop completed, exiting with $status");
    die "recv: $status";

Now, that's relatively simple.  I created the separate directory for the SQL's specific to the Tempest functionality, and then piled a bunch of files in there :

    *** sql/device_status.sql.disabled
    INSERT INTO device_status (timestamp,serial_number,client_ip,client_hostname,hub_sn,voltage,rssi,hub_rssi,sensor_status,firmware_revision) VALUES (from_unixtime([% timestamp %]),[% dbh.quote(serial_number) %],[% dbh.quote(client_ip) %],[% dbh.quote(client_hostname) %],[% dbh.quote(hub_sn) %],[% voltage %],[% rssi %],[% hub_rssi %],[% sensor_status %],[% firmware_revision %])

    *** sql/evt_precip.sql.disabled
    INSERT INTO evt_precip (timestamp,station_id,hub_sn) VALUES (from_unixtime([% evt.0 %]),[% dbh.quote(serial_number) %],[% dbh.quote(hub_sn) %)

    *** sql/evt_strike.sql
    INSERT INTO lightning_events (epoch,station_id,energy,distance_km,distance_miles) VALUES (from_unixtime([% evt.0 %]),[% dbh.quote(serial_number) %],[% evt.2 %],[% evt.1 %],[% evt.1/1.6093445 %])

    *** sql/hub_status.sql.disabled
    INSERT INTO hub_status (timestamp,serial_number,frequency,rssi,client_ip,uptime,firmware_revision,hw_version,client_hostname,radio_version,reboot_count,i2c_bus_error_count,radio_status,radio_network_id) VALUES (from_unixtime([% timestamp %]),[% dbh.quote(serial_number) %],[% freq %],[% rssi %],[% dbh.quote(client_ip) %],[% uptime %],[% dbh.quote(firmare_revision) %],[% hw_version %],[% dbh.quote(client_hostname) %],[% radio_stats.0 %],[% radio_stats.1 %],[% radio_stats.2 %],[% radio_stats.3 %],[% radio_stats.4 %])

    *** sql/obs_air.sql.disabled
    INSERT INTO obs_air (timestamp,serial_number,firmware_revision,hub_sn,station_pressure_mb,station_pressure_inhg,temperature_celcius,temperature_farenheit,rel_humidity,lightning_strike_count,lightning_strike_avg_distance_km,lightning_strike_avg_distance_miles,battery_voltage,report_interval) VALUES (from_unixtime([% obs.0.0 %]),[% dbh.quote(serial_number) %],[% firmware_revision %],[% dbh.quote(hub_sn) %],[% obs.0.1 %],([% obs.0.1 %]*0.029530)+4.7634625,[% obs.0.2 %],([% obs.0.2 %]*1.8)+32,[% obs.0.3 %],[% obs.0.4 %],[% obs.0.5 %],[% obs.0.5 %]/0.621371,[% obs.0.6],[% obs.0.7 %])

    *** sql/obs_sky.sql.disabled
    INSERT INTO obs_sky (timestamp,serial_number,firmware_revision,hub_sn,illuminance_lux,uv_index,rain_previous_minute_mm,rain_previous_minute_in,wind_lull_mps,wind_lull_mph,wind_avg_mps,wind_avg_mph,wind_gust_mps,wind_gust_mph,wind_direction,battery_voltage,report_interval,solar_radiation,day_rain_accumulation_mm,precipitation_type,wind_sample_interval) VALUES (from_unixtime([% obs.0.0 %]),[% dbh.quote(serial_number) %],[% firmware_revision %],[% dbh.quote(hub_sn) %],[% obs.0.1 %],[% obs.0.2 %],[% obs.0.3 %],[% obs.0.3 %]/25.4,[% obs.0.4 %],[% obs.0.4 %]*2.23694,[% obs.0.5 %],[% obs.0.5 %]*2.23694,[% obs.0.6 %],[% obs.0.6 %]*2.23694,[% obs.0.7 %],[% obs.0.8 %],[% obs.0.9 %],[% obs.0.10 %],[% obs.0.11 %],[% obs.0.12 %],[% obs.0.13 %])

    *** sql/obs_st.sql
    INSERT INTO twenty_four_hour (epoch,station_id,wind_lull_metric,wind_lull_standard,wind_avg_metric,wind_avg_standard,wind_gust_metric,wind_gust_standard,wind_direction,wind_sample_interval,barometric_pressure_milibars,barometric_pressure_inHg,temperature_celcius,temperature_farenheit,relative_humidity,illuminance,ultraviolet_index,solar_radiation,rain_amount_metric,rain_amount_standard,precipitation_type,lightning_avg_distance_metric,lightning_avg_distance_standard,lightning_strike_count,battery_voltage,report_interval) VALUES (from_unixtime([% obs.0.0 %]),[% dbh.quote(serial_number) %],[% obs.0.1 %],[% obs.0.1 %]*2.23694,[% obs.0.2 %],[% obs.0.2 %]*2.23694,[% obs.0.3 %],[% obs.0.3 %]*2.23694,[% obs.0.4 %],[% obs.0.5 %],[% obs.0.6 %],([% obs.0.6 %]*0.029530)+4.7634625,[% obs.0.7 %],([% obs.0.7 %]*1.8)+32,[% obs.0.8 %],[% obs.0.9 %],[% obs.0.10 %],[% obs.0.11 %],[% obs.0.12 %],[% obs.0.12 %]/25.4,[% obs.0.13 %],[% obs.0.14 %],[% obs.0.14 %]/0.621371,[% obs.0.15 %],[% obs.0.16 %],[% obs.0.17 %])

    *** sql/obs_st.sql.original
    INSERT INTO obs_st (timestamp,serial_number,firmware_revision,hub_sn,wind_lull_mps,wind_lull_mph,wind_avg_mps,wind_avg_mph,wind_gust_mps,wind_gust_mph,wind_direction,wind_sample_interval,station_pressure_mb,station_pressure_inhg,temperature_c,temperature_f,relative_humidity,illuminance_lux,uv,solar_radiation,rain_previous_minute_mm,rain_previous_minute_in,precip_type,lightning_strike_avg_distance_km,lightning_avg_distance_miles,lightning_strike_count,battery,report_interval) VALUES (from_unixtime([% obs.0.0 %]),[% dbh.quote(serial_number) %],[% firmware_revision %],[% dbh.quote(hub_sn) %],[% obs.0.1 %],[% obs.0.1 %]*2.23694,[% obs.0.2 %],[% obs.0.2 %]*2.23694,[% obs.0.3 %],[% obs.0.3 %]*2.23694,[% obs.0.4 %],[% obs.0.5 %],[% obs.0.6 %],([% obs.0.6 %]*0.029530)+4.7634625,[% obs.0.7 %],([% obs.0.7 %]*1.8)+32,[% obs.0.8 %],[% obs.0.9 %],[% obs.0.10 %],[% obs.0.11 %],[% obs.0.12 %],[% obs.0.12 %]/25.4,[% obs.0.13 %],[% obs.0.14 %],[% obs.0.14 %]/0.621371,[% obs.0.15 %],[% obs.0.16 %],[% obs.0.17 %])

    *** sql/rapid_wind.sql.disabled
    INSERT INTO rapid_wind (epoch,station_id,speed_mps,speed_mph,direction) VALUES (from_unixtime([% ob.0 %]),[% dbh.quote(serial_number) %],[% ob.1 %],[% ob.1 %]*2.236936,[% ob.2 %])

And to ensure it starts on boot (this is outside of docker - meh, I was so frustrated that I'm fine with it), I created an /etc/systemd/system/weatherflow_udp.service file containing :

    [Unit]
    Description=UDP Listener for Tempest Weatherflow
    After=default.target

    [Service]
    Type=oneshot
    ExecStart=/where/ever/you/stored/it/tempest/perl/udp_listener.pl --daemonize --sql-directory /where/ever/you/stored/it/tempest/perl/sql/

    [Install]
    WantedBy=default.target

And then enabled the service :

    systemctl daemon-reload
    systemctl enable weatherflow_udp

Viola (or Cello, I don't care), I have data!

One step closer!

Docker and WeeWx

WeeWX - Weather Station Data Gathering

Note, I eventually gave up on installing the weatherflow-udp extention under docker, and just wrote a UDP-to-CGI bridge tool, documented later.  However, I opted to post this anyway because of what I ran into and it could be useful for someone out there (I got fairly close, actually).

This is an interesting tool to accept data for the weather system. I'm using a weatherflow tempest, which means I have a UDP port I have to translate.  Plus, it is a little more complicated than the others we've dealt with.  First, let's create a pre-run form of docker-connect.yaml :

    services:
      weewx:
        image: felddy/weewx:latest
        container_name: tempest
        restart: unless-stopped
        ports:
          - "50222:50222/udp" # Map WeatherFlow UDP port
        environment:
          - TZ=America/Boise
        volumes:
          - type: bind
            source: ./weewx-data
            target: /data 

Then, let's create our data directory to grab configuarion files from :

    mkdir weewx-data/
    chown 1000:1000 weewx-data

Now we can run a "fake" start of the docker image to get the configurations we need :

    docker compose run --rm weewx

At this point, the weewx-data/weewx.conf file is ready for manipulation.  Copy the weewx.conf into the docker image directory, then modify it by changing the station name, the latitude and longitude, etc.  Once ready, change your docker-compose.yml file by adding the bind volume for the weewx.conf file, e.g. :

        volumes:
          - type: bind
            source: ./weewx-data
            target: /data

becomes :

        volumes:
          - type: bind
            source: ./weewx-data
    target: /data

Then, start the docker (usual "docker compose up -d").

It is now possible to run and install extensions.  First, just to make sure it's showing, run :

    docker exec -it tempest weectl extension list

Then install with : 

    docker compose run --rm weewx extension install --yes https://github.com/captain-coredump/weatherflow-udp/archive/refs/heads/master.zip 

Now, it was right about here that I gave up on getting the extension installed.  I'm leaving my notes, just in case.  But really, all I need is a UDP translator, anyway.  I'm not trying to do anything fancy like serve weather data (that will all happen through Grafana, so, yeah).\

On giving up, I just wrote a UDP listener.

Docker and Grafana

Managers love pretty pictures, right?  I know, this is a personal intranet, but hey, why not?

Grafana was a bit more of a challenge to run in docker.  I have issues with it being a default non-SSL port, but we can potentially fix that by throwing Apache in front of it down the road.  But, it's the same process as before, but with a major caveat.

We're not doing much special here, but I do need to make a quick statement.  My Grafana was an older 9.something version.  If you run it in Docker, and top are not starting from scratch (e.g. you are going to copy from a backup), it will fail.  In this case, you MUST download a 12.3 package, and do a debian upgrade on that package, and stop/restart so that it can migrate.  THEN you can move to the Docker 12.4 image base.

Now, back to the regularly scheduled programming.

Simply copy over your /var/lib/grafana/grafana.db SQLite file.  If you have customizations in your grafana.ini file, you can make those changes to a grafana.ini file and map it just like we did with Apache HTTPd above.  Note that it will be better to do that old "cat" command we mentioned in Apache to get a copy and then modify that one.

My docker-compose.yml looks like :

     services:
      web:
        image: grafana/grafana
        container_name: grafana
        restart: unless-stopped
        ports:
          - "3000:3000"
    environment:
    - GF_SECURITY_ADMIN_USER: admin
    - GF_SECURITY_ADMIN_PASSWORD: admin
    - GF_SERVER_ROOT_URL=%(protocol)s://%(domain)s:%(http_port)s/grafana/
    - GF_SERVER_ENFORCE_DOMAIN=false
    - GF_AUTH_PROXY_ENABLED=true
    - GF_AUTH_PROXY_HEADER_NAME=X-WEBAUTH-USER
    - GF_AUTH_PROXY_HEADER_PROPERTY=username
    - GF_AUTH_PROXY_AUTO_SIGN_UP=true
        volumes:
          - /opt/docker/storage/grafana:/var/lib/grafana:rw

Note the environment variables.

  • The SECURITY_ADMIN stuff sets  your default username/password.  If you are not migrating, and are instead creating a brand-new instance, these will set the default username/password.
  • The GF_SERVER_ROOT_URL is used to change the default URL (since Grafana is so smart it is going to redirect you to the Grafana port number, and this specifies whoat to hit with).
  • The GF_SERVER_ENFORCE_DOMAIN - this is where the AI engines kept biting me.  They had NO CLUE about this.  Grafana LOVES to redirect you, and if the enforce_domain in the grafana.ini uses the default or is set to true, it's doing to redirect you if you didn't hit it with the domain (could be hostname, and in docker, this defaults to "localhost").  So, if you are using docker, and if you are also reverse proxy'ing Grafana, which, in a reverse proxy environment, is not good).  It kept redirecting me to http://localhost:3000/ .  Setting this value via GF_SERVER_ENFORCE_DOMAIN allowed me to override the enforce_domain and shut that stupid thing off.
  • The GF_AUTH_PROXY_* environmment variables are to disable forcing a login.  If you are reverse proxy'ing Grafana, and the parent process is handling the authentication, this is likely what you will need to do in combination with the ReverseProxy configs I showed earlier that use the rewrite_module under Apache HTTPd.

Then it's a matter of starting it up again (the "docker compose up -d" command).

Docker and MySQL

In my pursuit of an easier, personal "intranet", my next task was to get MySQL running.  It's critical for the Grafana and Nagios instances that run my environmental monitoring.  There is a lot of historical (hysterical?) data involved, so we'll make it easier and just migrate it over, right?

First, create your docker directory for MySQL :

    mkdir -p /opt/docker/mysql

Then, create only one file in there, the infamous docker-compose.yml :

    services:
      mysql:
        image: mysql:latest
        container_name: mysql-container
        environment:
          MYSQL_ROOT_PASSWORD: super-secret-pwd
          # uncomment below if you want to automatically create and select a database
          # MYSQL_DATABASE: database_name
        ports:
          - "3306:3306"
        volumes:
          - /opt/docker/storage/mysql:/var/lib/mysql

Change the MYSQL_ROOT_PASSWORD row in the docker-compose.yml, and you're done.

Now, we are ready to spin that sucker up already - it really is that simple.

    docker compose up -d

Give it a minute to spin it up.  Then, test the connection (no MySQL Unix-socket will be available, so your MySQL command has to use the "--protocol TCP" option :

    [jlewis@docker mysql]$ mysql -u root -p -h localhost --protocol TCP
    Enter password: 
    Welcome to the MySQL monitor.  Commands end with ; or \g.
    Your MySQL connection id is 9
    Server version: 9.7.1 MySQL Community Server - GPL

    Copyright (c) 2000, 2026, 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> show databases;
    +--------------------+
    | Database           |
    +--------------------+
    | information_schema |
    | mysql              |
    | performance_schema |
    | sys                |
    +--------------------+
    4 rows in set (0.01 sec)

    mysql> exit 

Create whatever you need in your database, and you have a modular database that can be quickly moved over to another instance of docker if CPU gets too high.  In my case, I'm migrating an existing MySQL database, so I'll run : 

    mysqldump -u root -p databasename > mysqldump-databasename.sql

Copy that to the location that can write this into the database, create the database ("CREATE DATABASE databasename" in the new MySQL server first) and import it :

    mysql -u root -p --protocol TCP databasename > mysqldump-databasename.sql

Doing pretty good so far. 

Docker - Apache HTTPd for my tiny Intranet

I'm rebuilding my network stack for my little intranet, and the first thing out of the gate is the HTTPd web service.  So, let's get started.

I'm not using NginX because there will be a lot more CGI, and also because I want to implement my template mechanism, a custom module for the Apache HTTPd serer.  Converting from an existing HTTPd configuration is a bit more difficult when it comes to migrating it in to Docker.

Now, RedHat and other Linux vendors/distributors love to part the httpd.conf file out into a whole lot of directories.

Docker doesn't do that.  However, we have a little secret.  So, let's create our docker image first.  Create your docker directory and then the container for this specific image.

    mkdir -p /opt/docker/httpd

Because the docker image is actually based on a Debian version, and because it includes an httpd.conf file already (by default), we only need three files in this directory.

For the httpd.conf, I found it easier to grab the httpd.conf file out of the docker image first, then add my custom module.  And, since I wanted the image to serve content from the NAS, I needed to get the web content from that mounted up.  Let's grab a copy of the docker container httpd.conf file.

    docker run --rm httpd:2.4 cat /usr/local/apache2/conf/httpd.conf > httpd.conf

Now, with that, I can add my own module using the LoadModule with any custom directives.  Then, we simply add the NFS mounts for the mount the NAS up via NFS, and then use volumes, too.  Enter the docker compose. 

    services:
      web:
        image: httpd:2.4
        container_name: httpd
        restart: unless-stopped
        ports:
          - "80:80"
          - "443:443"
        volumes:
          - /opt/docker/storage/http/html:/usr/local/apache2/htdocs/:ro
          - /opt/docker/storage/http/cgi-bin:/usr/local/apache2/cgi-bin/:ro
          - /opt/docker/storage/http/logs:/usr/local/apache2/logs/:rw
          - ./certs:/etc/apache2/ssl
          - ./httpd.conf:/usr/local/apache2/conf/httpd.conf:ro
          - ./mod_template.so:/usr/local/apache2/modules/mod_template.so:ro

The container name/image, are fairly inconsequential - you can name them anything you want.  The default is "apache_service" for this from the Docker image, but I like to get very specific because Apache likes to do a lot of services.

The "restart" line is there in case the EliteDesk hardware loses power and reboots - I want this container to start back up immediately.

The ports are very easy to understand if you are used to TCP.  It is formatted as "HOST PORT:CONTAINER PORT".

Also, we need to set up SSL (note we have port 443 in there).  Create a certificate :

    mkdir certs
    openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout certs/httpd.key -out certs/httpd.crt -subj "/C=US/ST=Idaho/L=Boise/O=SilverHawk/CN=silverhawk.net"

You will need to set up the standard VirtualHost for it (make sure you uncomment the LoadModule line for the ssl extension while in there) :

    <VirtualHost *:443>
    DocumentRoot "/usr/local/apache2/htdocs"
    #ServerName www.example.com
    SSLEngine On
    SSLCertificateFile /etc/apache2/ssl/httpd.crt
    SSLCertificateKeyFile /etc/apache2/ssl/httpd.key
    </VirtualHost>

While I was there, I also added proxy and proxypass information for Grafana, (that's coming - note you need to enable xml2enc_module, proxy_http_module, and proxy_module, and make sure you disable proxy for everything except your own proxt so that someone doesn't suddenly use your site as a personal proxy to mask what they are doing on the Internet).

A grafana note :

I really strugged with proxy pass in this situation.  all of the AI engines kept telling me things that were not accurate.  So, here's what I have.  In my httpd.conf for docker, I have the following excerpt :

    ProxyRequests Off
    <VirtualHost *:443>
    DocumentRoot "/usr/local/apache2/htdocs"
    #ServerName www.example.com
    SSLEngine On
    SSLCertificateFile /etc/apache2/ssl/httpd.crt
    SSLCertificateKeyFile /etc/apache2/ssl/httpd.key

    RewriteEngine On
    RewriteRule .* - [E=PROXY_USER:%{LA-U:REMOTE_USER},NS]
    RequestHeader set X-WEBAUTH-USER "%{REMOTE_USER}e"
    ProxyPreserveHost On
    ProxyPass /grafana/ http://172.17.0.1:3000/
    ProxyPassReverse /grafana/ http://172.17.0.1:3000/
    RequestHeader unset Accept-Encoding
    <Location /charts>
    TemplateEnabled off
    </Location>
    </VirtualHost>

Then, in my Grafana docker-compose.yml file, I had to add the GF_SERVER_ROOT_URL and GF_SERVER_ENFORCE_DOMAIN environment variables (there are some others, to set up anonymous browsing since this will be front-ended by an authentication later) :

        environment:
    - GF_SERVER_ROOT_URL=%(protocol)s://%(domain)s:%(http_port)s/grafana/
    - GF_SERVER_ENFORCE_DOMAIN=false
    - GF_AUTH_PROXY_ENABLED=true
    - GF_AUTH_PROXY_HEADER_NAME=X-WEBAUTH-USER
    - GF_AUTH_PROXY_HEADER_PROPERTY=username
    - GF_AUTH_PROXY_AUTO_SIGN_UP=true

Volumes are where the magic happens for the custom module and configuration as well as the web root.  Each row represents a specific file/directory that is mapped from the docker image.  For example, I have my mod_template.so in there so it gets mapped into the container, plus I am mapping an httpd.conf file, as well as two NFS directories so I don't have to restart the docker every time I change the website.

Then, it's simply a matter of launching it up.

    sudo docker compose up -d  

Note, if you run into errors, you can use (and there is a -f option to tail -f them) :

    docker compose logs

This will dump the containers log files (I suppose you COULD re-map log files if you want those pulled out of the image naturally, but this was easier). 

Tuesday, May 5, 2026

Flushing The Radiator

While preparing to wire in dual electric fans on a switch, I knew I needed to give the cooling system a clean bill of health.  I mean, what's an evening compared to peace of mind?  (Or is it supposed to be a piece of mind because I may have lost it?)

I slipped by the big box home improvement store, bought two 6-foot garden hoses, two 1-1/4" PVC reducers (to 1/2" female pipe thread), three short  1/2" pipes (2" in length, I think), one PVC ball valve, and two garden hose adapters (one male and one female, both with a female 1/2" pipe thread on the other end).

 I screwed it all together, and had a solid system, ready to flush the radiator.  I cracked the thermostat housing, and... realized a previous engine owner had mixed orange and green antifreeze (I'd filled with water at one point, but never added antifreeze).  Orange and green antifreeze do NOT mix well!

Here's the thermostat.

I think it's pretty safe to say, don't mix orange and green antifreeze.  Any way, it took some time to get all of that cleaned out, including removing the thermostat (so I can flush the engine side, of course - if that's still in there, it won't open, and you won't get any flush).  Then, it was time to flush the system.  I started with the radiator side, and put the ball valve host between the house garden hose connection and the top of the radiator (connected from the engine side), and the other hose at the other end, with the hose in a bucket.

Turn on the house connection, then turn on the ball value, and it pushed water through.  Initially, it came out a thick-ish (it wasn't really thick - it was still very much liquid), but definitely not transparent.

I emptied one bucket, filled the next, and the next one came out a little cleaner.

I switched sides to flush from the other direction (still on the radiator), and ran two more buckets, until I was satisfied with clear water.

I disconnected my lines from the radiator hosts and re-attached them to the engine, then took off the radiator side in order to flush the engine.  Again, I did this from both ends, flushing coolant from both directions.

Again, repeating until I had clear water.

Once I was satisfied that water was clear from both directions, I stopped.

I detached from from the hoses, put a new thermostat (95-degree) with switch and sensor threaded holes, and now I'm ready to run this engine once I get the fuel pump in.

Saturday, May 2, 2026

Hammo Can Antenna

I needed a better way to store my Hammo Can antenna.  I ran to the local hardware store and found some 1-1/2" PVC schedule 40 pipe, because that's larger than the diameter of the antenna base.  I cut a 48" length of the pipe.

Next, I glued up some fittings so that I could thread things on.  I'm going all-external to keep the 1.5" interior dimension to fit the antenna.

After that, I needed to machine some pieces that I could set inside those end caps.  I took some UHMW round, bored it through 0.800" (to clear a male PL-259 thread, which I had put a male-to-male connector in the bottom of the antenna).  I then bored out only 1.25" deep to a 1.038" inside diameter (to grip the antenna's straight knurling).  The outside was turned down to 1.480" (a slightly slip-fit for the inside of the pipe end).

With the part parted from the rest of the stock, and inserted into the pipe, I cross drilled with a 13/64" (a clearance drill size for a 5mm screw) both the outside pipe and the part itself. 

I used the hole in the part to line it up with a drill bit, clamp a vise around it to hold it securely, then drilled the part itself for a threaded insert with the size specific for my inserts (letter "J").  I installed one heat-set insert, and bolted the part in place.  Since this is my first of four holes, I added a witness mark via sharpie so that I knew which hole went where.

The part was chucked back up into the lathe to turn it round again just on that surface, and then re-installed to the pipe with one screw.  It was then taken back to the drill press to pop the three remaining holes with the 13/64" drill bit.  The same technique was used to then clamp on the 13/64", switch to the "J" drill bit, and add clearance for the inserts.  Then, install the last three inserts, and turned back down to dimension on that insert band on the lathe again.


Next up, I heated the part up until I could force the antenna/pl-259 adapter into the part, and locked it down with a washer.  Back into the pipe went the part/antenna.

This means I can unthread this from a pipe, flip it around, and thread it back into the pipe, and I have an outside antenna that can be staked to the ground via the pipe, or return it internally to the pipe to protect it in storage.
 



Now I have a functional storage for the antenna that also doubles as a riser!

Monday, April 20, 2026

You Light Up My Life

 Whoever designs Honda Accord's probably ought to find a new job.

One headlight went out.  I decided I need to just replace them all at the same time (Rock Auto had all four for the price of one light at O'Reilly's Auto).

I managed to replace two lights, on the passengers side.

  • H11LL are the low-beams for a 2017 Honda Accord.
  • 9005LL (HB3) are high beams.

The outside light on the passengers side was the easiest.  It was simply disconnecting it, rotating it to release, put the new one in and lock it in place, and re-connect the wiring.

The inside light was an irritant.  It took me a half hour to realize I could pop the plastic tank blocking it off (there is a tab on the back holding it in place, a long screw driver will release it) and lift it out of position without disconnecting any tubing.  Then it, too, because fairly easy.

The drivers side was a whole different ball of wax.  I did not want to remove the batter, so tried working around it.  I broke the new bulbs trying to do that.

Simply put, just remove the battery.  If the drivers side is one you have to swap, just remove the battery.  It will make your life a WHOLE lot easier.

Thursday, February 12, 2026

Dock(er) Wok

Docker is a simple way to create virtualization without having full systems running.  I took a moment to figure out how to run docker in relation to Postgres.

Note you need at least three servers for this for it to have any sort of redundancy. One is the "master".  You can probably get away with two (run the manager on one of the nodes), but I do not recommend this.  Really, you could get away with one server that runs the manager and two instances (I did), but know that if you don't run the docker instances on separate hosts, you've now lost all high availability, because if that server fails, the whole stack will cease to exist.

First, install Docker :

    sudo apt-get install docker.io
    sudo systemctl enable docker
    sudo systemctl start docker
    

Then, create your swarm (anything recent should have swarm built in, you just need to set it up).

    
    sudo docker swarm init --advertise-addr 192.168.x.x
    

Next, join a docker worker to the swarm

    username@server1:~$ sudo docker swarm init --advertise-addr 192.168.0.3
    Swarm initialized: current node (NODE IDENTIFIER) is now a manager.
    
    To add a worker to this swarm, run the following command:
    
        docker swarm join --token TOKEN_STRING 192.168.0.3:2377
    
    To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.
    username@server1:~$
    

Then, on each worker node that isn't the manager, run the command you were provided (use sudo) :

    sudo docker swarm join --token TOKEN_STRING 192.168.1.237:2377
    

Next is your basic configuration.  Each host is relatively identical, with just a few modifications.  Each host has 3 different configuration files, pg_hba.conf, postgresql.conf, and pg_ident.conf.  Let's start by creating our directory structure.

I created a directory to house all of this so I can clear it out quickly after my learning curve.

    mkdir cluster-postgres
    cd cluster-postgres
    mkdir -p {master,slave-1,slave-2}/config
    

Next, create our three files for each host :

    touch {master,slave-1,slave-2}/config/{pg_hba.conf,postgresql.conf,pg_ident.conf}
    

With the files created, let's populate them.  Here's the gist for each host, but at the end, I'll identify differences as needed.

postgresql.conf

This file contains :

    # -----------------------------
    # PostgreSQL configuration file
    # -----------------------------
    #
    
    data_directory = '/data'
    hba_file = '/config/pg_hba.conf'
    ident_file = '/config/pg_ident.conf'
    
    port = 5432
    listen_addresses = '*'
    max_connections = 100
    shared_buffers = 128MB
    dynamic_shared_memory_type = posix
    max_wal_size = 1GB
    min_wal_size = 80MB
    log_timezone = 'Etc/UTC'
    datestyle = 'iso, mdy'
    timezone = 'Etc/UTC'
    
    #locale settings
    lc_messages = 'en_US.utf8'   # locale for system error message
    lc_monetary = 'en_US.utf8'   # locale for monetary formatting
    lc_numeric = 'en_US.utf8'    # locale for number formatting
    lc_time = 'en_US.utf8'       # locale for time formatting
    
    default_text_search_config = 'pg_catalog.english'
    
    #replication
    wal_level = replica
    wal_keep_size = 512MB  # Adjust this value as needed
    archive_mode = on
    archive_command = 'test ! -f /mnt/server/archive/%f && cp %p /mnt/server/archive/%f'
    max_wal_senders = 3
    

However, on the slave nodes, lines 28-34, which contain :

    #replication
    wal_level = replica
    wal_keep_size = 512MB  # Adjust this value as needed
    archive_mode = on
    archive_command = 'test ! -f /mnt/server/archive/%f && cp %p /mnt/server/archive/%f'
    

are removed.

pg_hba.conf

This file contains :

    # TYPE  DATABASE        USER            ADDRESS                 METHOD
    
    host     replication     replicationUser         0.0.0.0/0        md5
    
    # "local" is for Unix domain socket connections only
    local   all             all                                     trust
    # IPv4 local connections:
    host    all             all             127.0.0.1/32            trust
    # IPv6 local connections:
    host    all             all             ::1/128                 trust
    # Allow replication connections from localhost, by a user with the
    # replication privilege.
    local   replication     all                                     trust
    host    replication     all             127.0.0.1/32            trust
    host    replication     all             ::1/128                 trust
    
    host all all all scram-sha-256
    

However, on the slave nodes, line 3 :

    host     replication     replicationUser         0.0.0.0/0        md5
    

is removed.

pg_ident.conf

This file contains :

    # PostgreSQL User Name Maps
    # =========================
    #
    # Refer to the PostgreSQL documentation, chapter "Client
    # Authentication" for a complete description.  A short synopsis
    # follows.
    #
    # This file controls PostgreSQL user name mapping.  It maps external
    # user names to their corresponding PostgreSQL user names.  Records
    # are of the form:
    #
    # MAPNAME  SYSTEM-USERNAME  PG-USERNAME
    #
    # (The uppercase quantities must be replaced by actual values.)
    #
    # MAPNAME is the (otherwise freely chosen) map name that was used in
    # pg_hba.conf.  SYSTEM-USERNAME is the detected user name of the
    # client.  PG-USERNAME is the requested PostgreSQL user name.  The
    # existence of a record specifies that SYSTEM-USERNAME may connect as
    # PG-USERNAME.
    #
    # If SYSTEM-USERNAME starts with a slash (/), it will be treated as a
    # regular expression.  Optionally this can contain a capture (a
    # parenthesized subexpression).  The substring matching the capture
    # will be substituted for \1 (backslash-one) if present in
    # PG-USERNAME.
    #
    # Multiple maps may be specified in this file and used by pg_hba.conf.
    #
    # No map names are defined in the default configuration.  If all
    # system user names and PostgreSQL user names are the same, you don't
    # need anything in this file.
    #
    # This file is read on server startup and when the postmaster receives
    # a SIGHUP signal.  If you edit the file on a running system, you have
    # to SIGHUP the postmaster for the changes to take effect.  You can
    # use "pg_ctl reload" to do that.
    
    # Put your actual configuration here
    # ----------------------------------
    
    # MAPNAME       SYSTEM-USERNAME         PG-USERNAME
    

There are no differences here for each node.

Last Configurations

Create the docker "network" :

sudo docker network create postgres-cluster-network

This will print a fairly large alphanumeric ID. 

Starting the "master"

At this point in time, start the master node using the following command :

    sudo docker run -d --name postgres-master  --net postgres-cluster-network \
    -e POSTGRES_USER=postgresadmin -e POSTGRES_PASSWORD=admin123 \
    -e POSTGRES_DB=postgresdb -e PGDATA="/data" -v ${PWD}/master/pgdata:/data \
    -v ${PWD}/master/config:/config -v ${PWD}/master/archive:/mnt/server/archive \
    -p 5000:5432 postgres:latest -c 'config_file=/config/postgresql.conf'
    

If you haven't downloaded the postgres docker image yet, this will actually cause it to try and install.  If you get an error about it already existing because you tried to start it one and it gave you an error :

    docker: Error response from daemon: Conflict. The container name "/postgres-master" is already in use by container "6362ca473d3f29b7fe1bf02558f5a871fb6cf6827eff23fe01714f34cb386951". You have to remove (or rename) that container to be able to reuse that name.
    

Then list the dockers, and delete it :

    username@server1:~/postgres-cluster$ sudo docker ps -a
    CONTAINER ID   IMAGE             COMMAND                  CREATED          STATUS    PORTS     NAMES
    6362ca473d3f   postgres:latest   "docker-entrypoint.s…"   10 minutes ago   Created             postgres-master
    username@server1:~/postgres-cluster$ sudo docker rm 6362ca473d3f
    6362ca473d3f
    username@server1:~/postgres-cluster$ sudo docker ps -a
    CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES
    username@server1:~/postgres-cluster$
    

Now try to start it if it had previous failed again, and it should simply give you a large alphanumeric ID again.

Next, create a replication user :

    username@server1:~$ sudo docker exec -it postgres-master bash
    [sudo] password for username:          
    root@63919c16e356:/# createuser -U postgresadmin -P -c 5 --replication replicationUser
    Enter password for new role: 
    Enter it again: 
    root@63919c16e356:/# exit
    exit
    username@server1:~$
    

Just type exit and get back to the prompt.

Now, we can move on to starting the slaves. 

Starting the slaves

For each node, run :

    username@server1:~$ sudo docker run -it --name postgres-slave1 --rm \
    > --net postgres-cluster-network \
    > -v ${PWD}/slave-1/pgdata:/data \
    > --entrypoint /bin/bash postgres:latest
    root@447571e3e11e:/#
    

This will put you in a bash prompt as "interactive", where you then run :

    pg_basebackup -h postgres-master -p 5432 -U replicationUser -D /data/ -Fp -Xs -R
    

The command initiates replication, and will use the password you specified on the master node when you started that up with the "createuser" command.

Repeat for slave-2 (of course, replacing postgres-slave1 with postgres-slave2, and the slave-1 folder names with slave-2).

Create standby instances 

Run the following :

    sudo docker run -d --name postgres-slave1 --net postgres-cluster-network \
    -e POSTGRES_USER=postgresadmin -e POSTGRES_PASSWORD=admin123 \
    -e POSTGRES_DB=postgresdb -e PGDATA="/data"  \
    -v ${PWD}/slave-2/pgdata:/data -v ${PWD}/slave-2/config:/config \
    -v ${PWD}/slave-2/archive:/mnt/server/archive -p 5002:5432 \
    postgres:latest -c 'config_file=/config/postgresql.conf'
    

Repeat for slave-2 (of course, replacing postgres-slave1 with postgres-slave2, and the slave-1 folder names with slave-2).

Test It

Connect to the master node using :

    sudo docker exec -it postgres-master bash
    

From in here, you can run your psql commands to create databases and manipulate whatever you need.

    psql --username=postgresadmin postgresdb
    

Exit, and then check the other slave nodes by connecting to them (sudo docker exec) and running psql to query any tables you've created and populated with data.