Wednesday, September 9, 2026

Another Run at AI

I'm back with another task after the initial local-LLM run.  I was curious yet again, to see if I can improve my prompting, as well as to improve coding parts.  For this go-around, I'm using gemma4:31b because it may offer a bit more in the way of context than gpt-oss:20b did.

I started this time with a quick "bio" generation for a new website.  For example, I fed in pieces of my resume one at a time at the context prompt, one at a time, e.g. :

I have a bachelors degree in Computer Science, Networking and Systems Integration from [university name] in [location] ([date]).  Primary studies included Networking / Systems Integration, and Internet Protocols and Ports (RFC's, system configuration, etc.). Network communication systems, .... blah blah blah.

It recognized that there was no embedded request, but guessed at what might be wanted and provided a few options, such as 

### 2. If you are writing a "Professional Summary" or "About" section
This frames your education as a strong theoretical foundation that informs 
your current expertise.

> "I hold a BS in Computer Science from location, where I 
pursued a rigorous curriculum blending Networking and Systems Integration 
with Electrical Engineering. My academic foundation spans the entire 
computing stack—from hardware development and Assembly language to 
high-level software architecture and network protocol implementation. With 
a senior project centered on the development of a 3D CAD application in 
Visual C++, I developed a deep aptitude for complex system design and 
mathematical visualization."

Cool.  I then fed the next certification in, and repeated those one at a time, and then started repeating for each job, too.  It was spitting out it's "thinking", and threw things like :

   *   The user is providing a chronological or categorical list of their qualifications. They haven't given a specific command yet (like "write a resume"), but the intent is clearly to build a professional profile or 
document their expertise.

Now, I immediately started wondering if gemma4:31b was trained on LinkedIn profiles, because that's exactly what it sounds like.

Unfortunately, it conked out after it hit the prediction limits, and stopped accepting work history prompts, so I didn't get much of a bio.

I restarted a new context, and asked it to : 

Create a website for an individual who likes computers, art, car restoration, electronics, machining, security, woodworking, and engineering.  Include separate pages for a bio or an about, a link to a blog, pages for various interests, and an art gallery.

It dumped information for five different files - an about page, a gallery, an index page, and an interests page, along with a single CSS file that would be used for all of them. Curious, I put them into files and loaded them into a web browser :



This was a way more usable website.  It looks like it was based on something from "Polymath Studio".

I might even modify this and implement it. It did follow up with :

### Final Recommendations for the user:

  1. **Images:** You will need to save your images (like `profile.jpg` or `project1.jpg`) in the same folder as the HTML files for them to appear.
  2. **Blog:** Since a blog requires a database, I've included a link in the navigation. I recommend using **Substack**, **Medium**, or **GitHub Pages (Jekyll)** for the blog and simply putting that URL in the code.
  3. **Hosting:** To put this online for free, you can upload these files to **GitHub Pages** or **Netlify**.

That's easy enough to follow through.  For a hosted application, I simply added a few "background-image: url('/image.jpg');" lines to the .card, body, and nav sections of the style, then I set the accent-color in the root section to something a little easier.

That was it.  Aside from some javascript customizations (for things like random quotes or the gallery display), it went fairly smoothly, and I had a brand new website up and running that has more personality than I do.

Wednesday, September 2, 2026

Local AI (Ollama) on a Linux Workstation?

Preamble : Doing this with a lack of decent hardware probably exacerbated my opinion on the comically-generated, managerial bull categorization of AI later here.  More than likely I'd have ignored the over-exuberance of vocabulary that AI's use, but when the hardware lacks and takes painfully long to put together a simple sentence, my brain immediately goes to "this could have been an e-mail, not a meeting" mentality.  Please be aware that if you are exploring this without good hardware, and if you have OCD [CDO?], you might find yourself cussing about the stupidity of AI.

I don't know if this is a case of artificial intelligence, or natural stupidity on my part.  But, like all great learning experiences, it will be a good one, right?  We've hit the "rush" for artificial intelligence in the work place, and, being the curious sort, I've started to explore this on my own, too.  (Especially since the company has locked down all of the links to the one's I'd use).  So, if you are new to this "AI" thing less than asking Microsoft's Copilot to generate a new, funny picture of your boss, join me as we learn this together.

Note, this post got a little long, so I ended up feeling the need to break this out and use a "table of contents".  I hate them, but there is enough here that I want the information to be easily accessible.

Definitions
LLM's Available
Installing an LLM [Llama 3.3] using Ollama
Testing the Llama LLM
Trying OpenCode
The OpenCode Missing Link
Removing Llama and Installing gpt-oss:20b
Testing the gpt-oss LLM (this is where we finally get opencode success)

After the definitions, I list a few AI models that are available, and then I run through an installation and explore two of them, just to see.

Definitions

So let's get a few things defined for the n00b like me :

  • LLM - Large Language Model - artificial intelligence is typically based on a model that learns language.  These LLM's are how AI systems "learn" and interact, and basically how they work.
  • Open-weighted - I thought this was a bad form of "open source", until I saw a chart that showed them side by side.  An open-source LLM will have to supply all of the learning text back to the community as well, while an open-weighted LLM doesn't release anything except for the final weights after the learning.
  • Tokens - When an LLM breaks your request or text down, it actually breaks it into the simplest format called a "token".  It usually references a simple word, like "perl".
  • Context Window - a context window is a span of a conversation.  When you start a "chat" with an AI, the context window begins at that point.  This context allows the AI to receive simple commands after a prior response to modify the response. 

Note that AI costs are typically broken down into a per-token charge.  If you run an AI locally, you won't have charges for that other than regular operational costs such as power, hardware, and cooling, etc.  But, the moment you start into the cloud, you will encounter [most of the time] charges built up.  You are using AI agents that are running on someone else's CPU when you use the cloud, and whomever that is can charge accordingly.

Now, I really wanted to run with the latest and greatest at this point in time (as of August 28, 2026).  That appears to be Ox Alpha (names really don't mean much to me) that was now identified as "GLM-5.3" [someone bounced me to a "GLM-5.3-FLASH" name].  Other recommendations from peers are "OpenClaw" (I honestly thought he'd said "OpenClaude", and I knew what Claude was).  The other one that came up was "Ollama".  So, I dug in just a bit to find out about them.  Note that "Ollama" isn't actually an LLM - it's a tool to load an LLM. You can use Ollama to install the specific LLM you want to use.  So, we'll get to that after a quick run down.

LLM's Available 

I'm going to mention 4 things here :

  • GLM-5.3-FLASH (from Z.ai)
  • OpenClaw, which is not exactly an LLM
  • Llama (Meta's LLM)
  • gpt-oss
  • Gemma4

Here we go. 

GLM-5.3-FLASH

If you can afford it, this is the recommendation.  Seriously, the efficiency seems to be incredible from all of the charts hanging around it (see the charts on that link).

The unfortunate thing that will get in the way is that you need 100Gb of RAM for a 1-bit mode, or a 128Gb device for the 3-bit (it would run on a nice, new, $7k Mac, or an NVIDIA DGX Spark - which I can't really afford).

The thing attracting me to the GLM-5.3-FLASH is that it is "open-weighted" (see the definitions above).

Now, if you have the hardware, there are some variations of running it locally, just so you can get started. 

OpenClaw

OpenClaw is not an actual LLM.  It's an assistant on your home network that connects to something else (actually, Claude code, etc).  So, while it isn't a separate, local LLM, it does use the Claude LLM.

How this one works is to connect an assistant up via "token" to your Claude subscription.  A quick note is that you will be billed for your tokens that you use.

It will open a lot of doors.  The plus for it is that you don't require immense hardware to implement it, but you can allow local things like an Amazon echo to make changes to your local devices (like your router configuration, see about 17:30 into the video).

Llama (Meta's)

New state-of-the-art 70B model from Meta that offers similar performance compared to Llama 3.1 405B model.

The Meta Llama 3.3 multilingual large language model (LLM) is a pretrained and instruction tuned generative model in 70B (text in/text out). The Llama 3.3 instruction tuned text only model is optimized for multilingual dialogue use cases and outperform many of the available open source and closed chat models on common industry benchmarks

gpt-oss

OpenAI’s open-weight models designed for powerful reasoning, agentic tasks, and versatile developer use cases.  This seems like it has a lot of capabilities for integration and agents if you want to have something better than just talking to something.  In theory, this has agentic, full chain-of-thought, and a "configurable reasoning effort", whatever that is.  Not many people I know have much good reason.

gemma4:26b

Gemma 4 models are designed to deliver frontier-level performance at each size. They are well-suited for reasoning, agentic workflows, coding, and multimodal understanding. 

Installing an LLM

Ollama to Install an LLM

Finally, I'm getting somewhere. It installs with :

    curl -fsSL https://ollama.com/install.sh > ollama-install.sh

That install script (I kinda don't trust them, but this is also trusting what is downloaded, so I'm hosed one way or another) pulls down dependencies and install the kernel modules.  In this case, it's installing some NVIDIA kernel modules.  So, off and running :

    >>> Installing ollama to /usr/local
    [sudo] password for silverhawk:
    >>> Downloading ollama-linux-amd64.tar.zst
    ######################################################################################################################################################################################### 100.0%
    >>> Creating ollama user...
    >>> Adding ollama user to render group...
    >>> Adding ollama user to video group...
    >>> Adding current user to ollama group...
    >>> Creating ollama systemd service...
    >>> Enabling and starting ollama service...
    Created symlink /etc/systemd/system/default.target.wants/ollama.service → /etc/systemd/system/ollama.service.
    >>> NVIDIA GPU installed.is

This should start the service in the background, too.  The next step is to download the actual LLM.  Now, if you are running the GLM-5.3-FLASH, you can install it (they are all listed on https://ollama.com/search if you need).  Now, if you want some additional details, you can click on the name of your LLM you want to use on that search page, and it lists "applications" and "models".  If you want the 1b model, it is smaller and tighter, and may be more efficient on lesser hardware.

I'm going with llama (not the llama3.2-vision made for image reasoning, though that would be pretty cool), and the latest tag, just to see.  Llama is from Meta.  Here's how to install :

    ollama pull llama3.3:latest

Gave :

    pulling manifest 
    pulling 4824460d29f2: 100% ▏  42 GB/ 42 GB   11 MB/s      0s
    verifying sha256 digest 
    writing manifest 
    success

This took a while, because it pulled down a 42Gb image (it took me about 45 minutes).  I grabbed a quick "top" just to get a baseline while this was downloading.

    top - 14:48:58 up  2:05,  1 user,  load average: 0.35, 0.22, 0.20
    Tasks: 518 total, 1 running, 517 sleeping, 0 stopped, 0 zombie
    %Cpu(s): 0.3 us, 0.3 sy, 0.0 ni, 99.4 id, 0.0 wa, 0.0 hi, 0.1 si, 0.0 st
    MiB Mem : 31973.0 total, 10264.9 free, 5149.0 used, 17101.8 buff/ca

List currently installed models :

    silverhawk@localhost:~$ ollama ls
    NAME ID SIZE MODIFIED
    llama3.3:latest a6eb4748fd29 42 GB 2 minutes ago
    silverhawk@localhost:~$ 

You can check the capabilities by having it output some basic stats :

    silverhawk@localhost:~$ ollama show llama3.3:latest
    Model
    architecture llama
    parameters 70.6B
    context length 131072
    embedding length 8192
    quantization Q4_K_M

    Capabilities
    completion
    tools

    Parameters
    stop "<|start_header_id|>"
    stop "<|end_header_id|>"
    stop "<|eot_id|>"

    License
    LLAMA 3.3 COMMUNITY LICENSE AGREEMENT
    Llama 3.3 Version Release Date: December 6, 2024
    ...

    silverhawk@localhost:~$

It tells me this can do "completion" and "tools".

Testing The Llama LLM

Okay, enough of the chit chat, let's see if this thing can do anything.  Launch a "shell" :

    ollama run llama3.3:latest 

It took a little time to load the thing up, and load went from 0.35 processes in the run queue to 13.93 while this was happening.  Yeah, we're working with something that needs some serious CPU.  Once it got to the prompt, I tried my first prompt :

create a web page template for a silver hawk

It took a lot of time, and the CPU spiked up to 21.99 (19.91 average over 15 minutes).  Here's some stats on how long this took on a 32Gb RAM Linux Mint workstation with a 16-core processor (AMD Ryzen 9 5950X) :

  1. Generated the text "Here is" at 5 minutes (started at 15:59).
  2. Generated the text "Here is a basic web page template for a Silver Hawk:" at 33 minutes.
  3. At the 16 hour and 27 minute mark, I had half of an HTML page generated, and realizing it thinks "Silver Hawk" is a new form of an old Studebaker car.
  4. I got really tired of sitting at the computer and went out to manufacture some fuel lines for a car.
  5. I rested on Sunday.
  6. At 9:07 AM nearly 2.5 days later, the CSS and HTML were complete, while the model was still generating a summary of what it generated. 
  7. A full 3 days later, the summary had been generated. 

Now, I'm not benchmarking anything - I could, but that's not what I'm trying to do.  I'm in this to learn something, not provide metrics that someone else has already provided or to eke out every last bit of performance.  I'm after the actual process to figure out how I would go about this if I had a good piece of equipment.

What the Llama3.3 provided is (without images, of course) :

When I entered "quit", it took an hour to say "Goodbye! It was nice chatting with you. If you need anything else in the future, feel free to come back and start a new conversation. Have a great day!".  And it didn't even exit the shell.  A control-D was used to exit.  Why does this thing have to be so verbose?    I don't need a full paragraph saying "goodbye"!  Just say "goodbye" and not even exit, fer cryin' out loud!  Sorry about that.

OpenCode

Anyway, it's on to the next step. I want to try something else to see what it is capable of.  I want to see if this thing could produce something code-based, a little more intense than a single web page.  So, let's try opencode, and I'm going to see if it can generate a Perl script that connects to an LDAP directory.  (I've never done this before via opencode.)

Be aware that there are two (with additional options) of starting this.  You can start the server for ollama and start opencode separately, or, you can start the ollama that runs the opencode interface inside of it.

If you launch opencode separately, you have to point opencodeguide to the ollama service by setting environment variables or an opencode config.json file.

Or, if you run the opencode interface by ollama, simply put in :

    ollama launch opencode --model llama3.3

For the sake of simplicity, I did the "ollama launch" command.

It immediately told me that opencode was not installed and gave me a fancy, text-based prompt to install it.  Then it complained that it doesn't work well and to try glm-5.3-flash or gemma4:26b, but gave me a choice to launch anyway.  I just went ahead and launched it.

Once it was loaded, I threw a prompt in to see what it could do.  My prompt is :

    Write a Perl script that uses Hashicorp APIs to retrieve a password and then connect to an LDAP server, bind as a service account, and then search for a user.

The beautiful thing here is, because opencode has the ability to also query it's own cloud, is that it provides a fancy "Context" window on the right that shows how many tokens and the cost of the query.  I don't know the details on using tokens, etc, at this point within the cloud, especially since I'm attempting to run this locally, but seems like a nice feature.

This ran for a while (again, woefully inadequate memory) as Ollama and OpenCode interpreted the prompt, and then it suddenly responded with :

I am not able to complete this task as it falls outside of the scope of the functions I have been given.

Bugger. Let's alter the prompt and heavily simplify it to see if we get better results (maybe the Hashicorp thing caused the issue) :

 Create a script that connects to an LDAP server using the Perl scripting language.

It took 8 hours to start saying :

With the functions I have at hand, I am not able to execute this task. It seems that the functions provided are primarily designed for tasks related to coding, such as writing code, fetching data from the web, or loading skills. The task you've described involves creating a script in Perl to connect to an LDAP server, which may require using specific Perl libraries or modules like Net::LDAP.  This task might be more aligned with the capabilities of functions like "skill" if there's a specific skill related to Perl or LDAP connectivity. Alternatively, if there's a function that allows...

I got tired of waiting for it to generate a paragraph telling me what one sentence could, and hit escape a few times to get out of it.  And then I thought...

What in the sam-hill, comically-generated, managerial bull is this?

Is THIS why managers and executives love the concept of AI?  Because it produces such a diarrhea of the keyboard that they can use to fill time?  Really?  Why does this thing think it needs to be more verbose?  The first sentence answered the question ("I am not able to execute this task"), and the rest is just that - comically-generated, managerial bull.

Just shut up and either provide the code, or tell me you can't.  There is no need to generate a lot of words that tell me nothing more than the single, embedded statement.  Maybe if I ask for a simple C# Windows-based "Hello, World".

If you have the hardware to run the larger LLM's for OpenCode, I'd recommend that.  I read a note that it works for code snippets if you have limited hardware, but if you get complex applications, it struggles to keep things straight.  That sounds almost human.

"With more powerful hardware, such as 64 or 128 GB of RAM and several dozen gigabytes of GPU memory, larger models with wider context windows become viable for more advanced development workflows." From https://academy.zenva.com/opencode-local-llm-ollama/

Okay, let's try the hello-world.

Write code for a c sharp application that produces a "hello, world" message. 

Ugh.

The provided functions are insufficient for me to complete this task. 

Nope.  It's not going to fly.  Obviously, I'm missing something.

The Missing Link for OpenCode

Okay. Time to install opencode officially.

    curl -fsSL https://opencode.ai/install | bash

Then, install the sdk :

    npm install @opencode-ai/sdk

Now, we WILL have to connect opencode to the LLM, since we're starting opencode outside of the LLM.  So, edit your ~/.opencode/opencode.json file.  If it's empty, you can just add :

    {
            "schema": "https://opencode.ai/config.json",
            "provider": {
                    "ollama": {
                            "npm": "@ai-sdk/openai-compatible",
                            "name": "Ollama (local)",
                            "options": {
                                    "baseURL": "http://localhost:11434/v1"
                            },
                            "models": {
                                    "llama3.3:latest": {
                                            "name": "Llama 3.3 Latest",
                                            "tools": true
                                    }
                            }
                    }
            },
            "model": "ollama/llama3.3:latest"
    }

Note that you need to change the model information appropriately.  If you don't remember the model you've chosen, you can run :

    ollama list

That should print it out.  Under the "NAME", that's what you are selecting under the "models" section.  Anyway, back into gear.  Re-launch opencode :

    ~/.opencode/bin/opencode

Then, connect to ollama by typing :

    /connect ollama 

Congrats, it's running.  You can ask it to describe things like your code base, optimizing code, modelling production code notes, or even to find potential security flaws (I haven't tried - again, I just want it to generate some code).  Let's try it again, but we'll start with the simpler requests from earlier...

...and no dice.  It just (again) tells me it can't do it.  Perhaps it's the model?

Removing Llama and Installing gpt-oss:20b

To remove our llama3.3:latest model, simply run :

    ollama rm llama3.3:latest

It will tell you it was deleted. Let's now install :

    ollama pull gpt-oss:20b

Ready or not, here we go. 

Testing the gpt-oss:20b LLM

With the 20b instance installed, let's see how long this takes to build a web page.  We'll use the same prompt that we did with Llama3.3.

    ollama run gpt-oss:20b

This one dumped in "thinking", and then spit out an HTML page combination within 1 minute.  No assumptions that this was an automobile, and created an actually interesting web page within a matter of 3 minutes :

Much better performance. Minutes are better than days when it comes to waiting, right?  The look is better, the HTML is better organized, and things appear more fluid and kosher.

Let's try opencode with that "ollama launch opencode --model gpt-oss:20b" command and see if it will give us more than HTML.  We'll use the "Write code for a c sharp application that produces a "hello, world" message.  Within a minute, it had gone into Apache mode, and dumped something about needing access to the .htaccess file (we got a little derailed).

But it responded.  Let's try :

Write a basic c# application.

And it spit out actual code :

Very interesting.   The opencode failures were completely in the model, not opencode!

So, now we have the potential to feed other code into it and ask for optimizations, etc! 

 

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
    

The above patch allowed it to run (but note after this) :

    [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, and tronyx there bounced me to an update on Feb 11, 2026, where the code was already patched in a better way.  I'd recommend applying the latest plugin and using it if the image doesn't have the plugin already patched by the time you are reading this.

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