Adding ADFS integration to Apache

Overview

ADFS is Microsoft Active Directory Federated Services. It is a single sign-on solution, and this post explains how to tie in Apache 2.4 (CentOS 7) to ADFS. All of this works even with SELinux enforcing!

The test environment described by this document includes the following.

Root URL: https://sample.example.org (10.1.9.192)

Protected subdirectory: https://sample.example.org/auth1/

Warning! ADFS Configuration changes can take a few minutes to take effect.

Adding ADFS integration to Apache

This guide assumes you have a functional apache environment.

Configuring apache

Install mod_auth_mellon from the regular centos repository. Also include php.

yum -y install mod_auth_mellon php

Set up mellon with the sample hostname and url using the provided tool.

mkdir -p /etc/httpd/mellon
cd /etc/httpd/mellon
/usr/libexec/mod_auth_mellon/mellon_create_metadata.sh urn:samplesite:sample.example.org "https://sample.example.org/auth1/endpoint/"

This script outputs 3 files to the current directory.

urn_samplesite_sample.example.org.key
urn_samplesite_sample.example.org.cert
urn_samplesite_sample.example.org.xml

This certificate is a self-signed certificate, but other options can be used and should be considered for production environments. Be aware that the certificates are also dumped into the xml file that will be shared with the ADFS host, so be sure to share any new certificates there as well.

Collect the ADFS metadata and store it locally. Such metadata is usually available at a URL similar to the following.

https://adfs.example.org/federationmetadata/2007-06/FederationMetadata.xml

Here is a copy-pastable line for people like me.

wget https://adfs.example.org/federationmetadata/2007-06/FederationMetadata.xml -O /etc/httpd/mellon/FederationMetadata.xml

Build the apache Mellon config.

cat <<EOF >/etc/httpd/conf.d/auth_mellon.conf
MellonCacheSize 100
MellonLockFile /var/run/mod_auth_mellon.lock
MellonPostTTL 900
MellonPostSize 1073741824
MellonPostCount 100
MellonPostDirectory "/var/cache/mod_auth_mellon_postdata"
EOF

My test environment uses a modular ssl directives include file:

cat <<EOFSSL >/etc/httpd/sites/all-ssl.cnf
SSLEngine on
SSLProtocol all -SSLv2 -SSLv3
SSLHonorCipherOrder on
SSLCipherSuite "EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRSA+SHA384 EECDH+aRSA+SHA256 EECDH+aRSA+RC4 EECDH EDH+aRSA !RC4 !aNULL !eNULL !LOW !3DES !MD5 !EXP !PSK !SRP !DSS"

<Files ~ "\.(cgi|shtml|phtml|php3?)$">
        SSLOptions +StdEnvVars
</Files>
<Directory "/var/www/cgi-bin">
        SSLOptions +StdEnvVars
</Directory>

SSLCertificateFile /etc/pki/tls/certs/localhost.crt
SSLCertificateKeyFile /etc/pki/tls/private/localhost.key

SetEnvIf User-Agent ".*MSIE 4\.0b2.*"                 nokeepalive ssl-unclean-shutdown                 downgrade-1.0 force-response-1.0

LogLevel warn
ErrorLog logs/ssl_error_log
CustomLog logs/ssl_access_log combinedvhost

<Directory "/var/www/html/notfound/">
        AllowOverride None
        Order allow,deny
        Allow from all
</Directory>

# END OF FILE all-ssl.cnf
EOFSSL

In the apache config, probably /etc/httpd/sites/sample.conf, modify the virtual host.

cat <<EOF >/etc/httpd/sites/sample.conf
Listen 10.1.9.192:80
Listen 10.1.9.192:443

<VirtualHost 10.1.9.192:80>

        ServerName      sample.example.org:80
        ServerAlias     sample

        # Redirect everything to the https site
        RewriteEngine   On
        RewriteRule ^(.*)$      https://%{HTTP_HOST}%{REQUEST_URI}

</VirtualHost>

<VirtualHost 10.1.9.192:443>

        ServerName      sample.example.org:443
        ServerAlias     sample sample.example.org
        DocumentRoot /var/www/html/sample.example.org

        Include sites/all-ssl.cnf

        <Directory "/var/www/html/sample.example.org">
            AllowOverride None
            Order allow,deny
            Allow from all
            Options Indexes FollowSymLinks
        </Directory>

        <Location /auth1/>
                # Mellon auth which goes to ADFS
                Include sites/adfs.cnf
                MellonCond "groups" "WebAppUsers_grp" [REG,SUB,NC]
        </Location>
</VirtualHost>
EOF

Make the post dump location, which is not necessary but might be useful in the future.

mkdir -p /var/cache/mod_auth_mellon_postdata
chown apache:apache /var/cache/mod_auth_mellon_postdata
chmod 0700 /var/cache/mod_auth_mellon_postdata

Building the include files

We already built the all-ssl.cnf include file, so we just need the adfs include file.

cat <<EOF >/etc/httpd/sites/adfs.cnf
# File: /etc/httpd/sites/adfs.cnf
MellonEnable "auth"
Require valid-user
AuthType "Mellon"
MellonVariable "cookie"
#MellonSamlResponseDump On

MellonSPPrivateKeyFile /etc/httpd/mellon/urn_samplesite_sample.example.org.key
MellonSPCertFile /etc/httpd/mellon/urn_samplesite_sample.example.org.cert
MellonSPMetadataFile /etc/httpd/mellon/urn_samplesite_sample.example.org.xml
MellonIdPMetadataFile /etc/httpd/mellon/FederationMetadata.xml
MellonMergeEnvVars On ":"
MellonEndpointPath /auth1/endpoint
EOF

Here, the MellonMergeEnvVars On “:” means that any multiple-value attribute (like Groups) will be added to one colon-delimited string instead of being assigned to “Mellon_Groups_1” “Mellon_Groups_2” and so on. It’s how the MellonCond works in the virtual host configuration.

Building example sites

Build the index file for the protected directory.

cat <<EOF >/var/www/html/sample.example.org/auth1/index.html
<html>
<head><title>Authorized zone</title></head>
<body>
<h1>Welcome to the authorized zone.</h1>
You should only be able to see this if you are authenticated and authorized.
</body>
</html>
EOF

Make a php troubleshooting file.

thisfile=/var/www/html/sample.example.org/auth1/info.php
cat <<EOF >${thisfile}
<?php    
phpinfo(INFO_VARIABLES);
?>
EOF
chown apache:apache ${thisfile}
chmod 644 ${thisfile}

This little php file will show the apache environment variables that are available for use in the apache directives. The important ones here will be the ones prepended with “MELLON_.”

Configuring ADFS to share data

On the ADFS server, add a new relying party trust.

Run the AD FS management tool.

Navigate in the tree structure to AD FS –> Trust relationships –> Relying party trusts.

Select on the action menu “Add relying party trust…”

The easiest way to do this is to use the xml file generated by that script earlier.

Do not configure multi-factor authentication.

Permit all users to access this relying party.

Edit the properties of the relying party trust –> Advanced tab.

Set value “Secure hash algorithm” to SHA-1.

Adding claim rules

Right-click this relying party trust and select “Edit Claim Rules.”

Add a rule of type “Transform incoming claim.”

Incoming claim type: Windows account name
Outgoing claim type: Name ID
Outgoing name ID format: Transient Identifier
Radio button: Pass through all claim values

The rule text looks like:

c:[Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"]
 => issue(Type = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier", Issuer = c.Issuer, OriginalIssuer = c.OriginalIssuer, Value = c.Value, ValueType = c.ValueType, Properties["http://schemas.xmlsoap.org/ws/2005/05/identity/claimproperties/format"] = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient");

Reference: Specific example of mellon configuration

Add custom rule “Get all groups user belongs to.”

c:[Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname", Issuer == "AD AUTHORITY"]
 => add(store = "Active Directory", types = ("http://schemas.xmlsoap.org/claims/Group"), query = ";tokenGroups;{0}", param = c.Value);

The add command instead of issue passes the information on to the next rule.

Reference: Custom rules sharing

Add custom rule “Filter your groups only.”

c:[Type == "http://schemas.xmlsoap.org/claims/Group", Value =~ ".*WebAppUsers_grp.*|.*IT.*"]
 => issue(Type = "groups", Value = c.Value, Issuer = c.Issuer);

The type name of “groups” means that mellon will provide an apache environment variable of “MELLON_groups” which we are performing the condition statement on in the virtual host.

The regex in the first part is looking for any group name that has “IT” in it at all or “WebAppUsers_grp” which is probably pretty specific to just that one group.

What this does is limit the groups being sent so instead of those 538 AD groups possible that that one user is in, it will pass back only the 28 IT department-related ones and the WebAppUsers_grp.

After the apachectl configtest, give apache graceful a shot!

https://sample.example.org/auth1/info.php

The above link (modified for you of course) should redirect to the AD FS login page and then send authenticated users back!

The benefits here include using apache as a reverse proxy to tomcat applications (local or otherwise) and providing a layer of authentication.

References

  1. Official mellon page https://github.com/UNINETT/mod_auth_mellon
  2. Specific example of mellon configuration https://answers.splunk.com/answers/177936/accessing-splunk-enterprise-using-adfs-authenticat.html
  3. https://httpd.apache.org/docs/2.4/mod/mod_authz_core.html
  4. ADFS saml rules https://support.zendesk.com/hc/en-us/articles/203663896-Mapping-attributes-from-Active-Directory-with-ADFS-and-SAML-Professional-and-Enterprise-
  5. Regex is allowed in claims https://social.technet.microsoft.com/wiki/contents/articles/8008.ad-fs-2-0-selectively-send-group-membership-s-as-a-claim.aspx
  6. http://serverfault.com/questions/700126/sending-ad-attributes-as-ad-fs-claims-to-shibboleth-sp-attributes
  7. Custom rules sharing http://molikop.com/2014/04/adfs-claim-rules-filtering-groups/
  8. http://stackoverflow.com/questions/6861534/is-it-possible-to-output-any-or-all-available-variables-in-a-htaccess-file

Bash shell script that reads RDP files – updated

The story

last updated 2017-01-18

In my migration to linux, I wanted a shell script that can read the Microsoft rdp file format. I still need to connect to my Windows systems. There are multiple packages that can do the task of connecting (I went with xfreerdp), but none that can intrinsically read the rdp files that the mstsc tool can save. So, I wrote one.

I’m sure someday I will have a github or gitlab account for hosting my stuff. But for now, here’s some WordPress <pre> code for you.

You can find this rdp.sh script in my bgscripts package on github. Go check it out!

The RDP.sh script

#!/bin/sh
# Filename: RDP.sh
# Location:
# Author: bgstack15@gmail.com
# Startdate: 2016-02-08 11:55:31
# Title: Script that Opens RDP Connections Based on RDP Files
# Purpose:
# Package:
# History:
# Usage:
# Reference: ftemplate.sh 2016-02-02a; framework.sh 2016-02-02a
#   https://github.com/FreeRDP/FreeRDP/wiki/CommandLineInterface
# Improve:
#    Warning: Some systems don't like the clipboard sharing, including Hulk.
fiversion="2016-02-02a"
RDPversion="2016-02-08a"

usage() {
   less -F >&2 <<ENDUSAGE
usage: RDP.sh [-duV] [-i infile1]
version ${RDPversion}
 -d debug   Show debugging info, including parsed variables.
 -u usage   Show this usage block.
 -V version Show script version number.
 -i infile  Overrides default infile value. Default is none.
Return values:
0 Normal
1 Help or version info displayed
2 Count or type of flaglessvals is incorrect
3 Incorrect OS type
4 Unable to find dependency
5 Not run as root or sudo
ENDUSAGE
}

# DEFINE FUNCTIONS
function getscreensize {
   # call: getscreensize thisheight thiswidth
   # assigns W and H to the 2 variables sent to the function
   calledvar1=${1-thiswidth}
   calledvar2=${2-thisheight}
   thisfile=$(mktemp -u)
  
   # exact methods will differ depending on available packages and distros
   # Korora 22
   grep -qiE "(Fedora|Korora).*(22|23)" /etc/redhat-release 2>/dev/null && \
      xdpyinfo | grep -oiE "dimensions.*[0-9]{3,4}x[0-9]{3,4} pi" | tr -d '[A-Za-wyz ():]' | tr 'x' ' ' > ${thisfile}
   read myx myy < ${thisfile}
   eval "${calledvar1}=\${myx}"
   eval "${calledvar2}=\${myy}"
   rm -rf ${thisfile}
}

function getuser {
   # call: getuser "${userfile}" thisuser thispassword
   # read fstab credentials file "userfile" and place in strings thisuser and thispassword
   # Note: This gets user in domain\username format.

   calledvar1=${2-thisuser}
   calledvar2=${3-thispassword}

   [[ -n "$1" ]] && thisinfile="$1"
   if [[ -f "${thisinfile}" ]];
   then
      for word in $( fsudo grep -viE "^$|^#" "${thisinfile}" );
      do
         item="${word%%=*}"
         value="${word##*=}"
         case "${item}" in
            domain) usertemp="${value}\\${usertemp}";;
            username|user) usertemp="${usertemp}${value}";;
            password) passtemp="${value}";;
            *) [ ];; #other item is useless
         esac
      done
   else
      # not a valid file, so get username from environment
      domain=$( hostname -d )
      usertemp="${domain}\\$USER"
   fi

   eval "$calledvar1=\$usertemp"
   eval "$calledvar2=\$passtemp"

}

# DEFINE TRAPS

function clean_RDP {
   #rm -f $logfile >/dev/null 2>&1
   [ ] #use at end of entire script if you need to clean up tmpfiles
}

function CTRLC {
   #trap "CTRLC" 2
   [ ] #useful for controlling the ctrl+c keystroke
}

function CTRLZ {
   #trap "CTRLZ" 18
   [ ] #useful for controlling the ctrl+z keystroke
}

function parseFlag {
   flag=$1
   hasval=0
   case $flag in
      # INSERT FLAGS HERE
      "d" | "debug" | "DEBUG" | "dd" ) setdebug; ferror "debug level ${debug}";;
      "u" | "usage" | "help") usage; exit 1;;
      "V" | "fcheck" | "version") ferror "${scriptfile} version ${RDPversion}"; exit 1;;
      #"i" | "infile" | "inputfile") getval;infile1=$tempval;;
   esac
  
   debuglev 10 && { [[ hasval -eq 1 ]] && ferror "flag: $flag = $tempval" || ferror "flag: $flag"; }
}

# DETERMINE LOCATION OF FRAMEWORK
while read flocation; do if [[ -x $flocation ]] && [[ $( $flocation --fcheck ) -ge 20151123 ]]; then frameworkscript=$flocation; break; fi; done <<EOFLOCATIONS
./framework.sh
${scriptdir}/framework.sh
~/bin/bgscripts/framework.sh
~/bin/framework.sh
~/bgscripts/framework.sh
~/framework.sh
/usr/local/bin/bgscripts/framework.sh
/usr/local/bin/framework.sh
/usr/bin/bgscripts/framework.sh
/usr/bin/framework.sh
/usr/bgscripts/framework.sh
/usr/framework.sh
/bin/bgscripts/framework.sh
/bin/framework.sh
/mnt/scripts/bgscripts/framework.sh
/usr/libexec/bgscripts/framework.sh
EOFLOCATIONS
[[ -z "$frameworkscript" ]] && echo "$0: framework not found. Aborted." 1>&2 && exit 4

# REACT TO OPERATING SYSTEM TYPE
case $( uname -s ) in
   AIX) [ ];;
   Linux) [ ];;
   *) echo "$scriptfile: 3. Indeterminate OS: $( uname -s )" 1>&2 && exit 3;;
esac

# INITIALIZE VARIABLES
# variables set in framework:
# today server thistty scriptdir scriptfile scripttrim
# is_cronjob stdin_piped stdout_piped stderr_piped sendsh sendopts
. ${frameworkscript} || echo "$0: framework did not run properly. Continuing..." 1>&2
infile1=
outfile1=
logfile=${scriptdir}/${scripttrim}.${today}.out
interestedparties="bgstack15@example.com"
rdpcommand=/usr/bin/xfreerdp
alloptions="/sec-rdp"; #where defaults go. Will be added to by infile1 options
userfile=~/.bgstack15.example.com
fullscreenborder=80px;

## REACT TO ROOT STATUS
#case $is_root in
#   1) # proper root
#      [ ] ;;
#   sudo) # sudo to root
#      [ ] ;;
#   "") # not root at all
#      #ferror "${scriptfile}: 5. Please run as root or sudo. Aborted."
#      #exit 5
#      [ ]
#      ;;
#esac

# SET CUSTOM SCRIPT AND VALUES
#setval 1 sendsh sendopts<<EOFSENDSH      # if $1="1" then setvalout="critical-fail" on failure
#/usr/local/bin/bgscripts/send.sh -hs     #                setvalout maybe be "fail" otherwise
#/usr/local/bin/send.sh -hs               # on success, setvalout="valid-sendsh"
#/usr/bin/mail -s
#EOFSENDSH
#[[ "$setvalout" = "critical-fail" ]] && ferror "${scriptfile}: 4. mailer not found. Aborted." && exit 4

# VALIDATE PARAMETERS
# objects before the dash are options, which get filled with the optvals
# to debug flags, use option DEBUG. Variables set in framework: fallopts
validateparams infile1 - "$@"

# CONFIRM TOTAL NUMBER OF FLAGLESSVALS IS CORRECT
if [[ $thiscount -lt 1 ]];
then
   ferror "${scriptfile}: 2. Fewer than 1 flaglessvals. Aborted."
   exit 2
fi

# CONFIGURE VARIABLES AFTER PARAMETERS

# READ CONFIG FILE
remember_IFS="${IFS}"; IFS=$'\n'
the_raw_data=($(cat "${infile1}"))
IFS="${remember_IFS}"

unhandled="unhandled:";
sizes=0;
#grep -viE "^$|^#" "${infile1}" | sed "s/[^\]#.*$//;' | while read line
#BASH BELOW
#while read -r line
for line in "${the_raw_data[@]}"
do
   debuglev 5 && ferror "$line"
   value=$( echo "${line##*:}" | tr -d '\r' )
   #read -p "Please type something here:" response < $thistty
   #echo "$response"
   case "${line}" in
      *screen\ mode\ id*)    screenmode="${value}";;
      use\ multimon*)        multimon="${value}";;
      desktopwidth*)        desktopwidth="${value}";((sizes+=10));;
      desktopheight*)        desktopheight="${value}";((sizes+=1));;
      session\ bpp*)        sessionbpp="${value}";;
      full\ address*)        fulladdress="${value}";; #should include port if necessary
      compression*)        compression="${value}";;
      displayconnectionbar*)    displayconnectionbar="${value}";; # guessing that this is /disp
      audiomode*)        audiomode="${value}";;
      audiocapturemode*)    audiocapturemode="${value}";;
      keyboardhook*)        unhandled="${unhandled}\n${line}";; #cannot find implementation in freerdp
      redirectclipboard*)    clipboard="${value}";;
      disable\ wallpaper*)    wallpaper="${value}";;
      allow\ font\ smoothing*)    fontsmoothing="${value}";;
      allow\ desktop\ composition*)    unhandled="${unhandled}\n${line}";; # provides aero but I never ever use aero
      disable\ full\ window\ drag*)    windowdrag="${value}";;
      disable\ menu\ anims*)    menuanims="${value}";;
      disable\ themes*)        themes="${value}";;
      *) debuglev 4 && ferror "Unknown option: ${line}";;
   esac
done
#BASH BELOW
#done < <( grep -viE "^$|^#" "${infile1}" | sed 's/[^\]#.*$//g;' )

#GET USER
getuser "${userfile}" thisuser thispassword

#FINISH PARSING DIRECTIVES FOR freerdp
[[ "${multimon}" = "1" ]] && alloptions="${alloptions} /multimon"
echo "screenmode=${screenmode}"
if [[ "${screenmode}" = "1" ]];
then
   # screenmode 1 windowed
   case sizes in
      0) screenmode=2;; #abort and just do fullscreen
      1) alloptions="${alloptions} /h:${desktopheight}";;
      10) alloptions="${alloptions} /w:${desktopwidth}";;
      11) alloptions="${alloptions} /size:${desktopwidth}x${desktopheight}";;
      *) ferror "Did not understand sizing. Emulating fullscreen." && screenmode=2;;
   esac
fi
if [[ "${screenmode}" = "2" ]];
then
   # screenmode 2 fullscreen

   # on linux make that windowed but 20px border around window
   # xfreerdp has the "/f" flag though if I want to change it in the future
   getscreensize thiswidth thisheight
   fullscreenborder=${fullscreenborder%%px}
   ((thiswidth-=fullscreenborder)) && ((thisheight -=fullscreenborder))
   

   alloptions="${alloptions} /size:${thiswidth}x${thisheight}"
fi
[[ -n "${sessionbpp}" ]] && alloptions="${alloptions} /bpp:${sessionbpp}"
[[ -n "${fulladdress}" ]] && alloptions="${alloptions} /v:${fulladdress}"
[[ -n "${compression}" ]] && alloptions="${alloptions} -z"
[[ "${displayconnectionbar}" = "1" ]] && alloptions="${alloptions} /disp"
[[ -n "${audiomode}" ]] && alloptions="${alloptions} /audio-mode:${audiomode}"
[[ -n "${audiocapturemode}" ]] && alloptions="${alloptions} /mic"
[[ "${clipboard}" = "1" ]] && alloptions="${alloptions} +clipboard"
[[ "${wallpaper}" = "0" ]] && alloptions="${alloptions} /wallpaper" #because it is actually a disable-wallpaper flag
[[ "${fontsmoothing}" = "1" ]] && alloptions="${alloptions} /fonts"
[[ "${windowdrag}" = "0" ]] && alloptions="${alloptions} /window-drag"
[[ "${menuanims}" = "0" ]] && alloptions="${alloptions} /menu-anims"
[[ "${themes}" = "0" ]] && alloptions="${alloptions} /themes"
alloptions="${alloptions} /u:${thisuser} /p:${thispassword}"

alloptions="${alloptions# }" # to trim leading space just to look nicer

## REACT TO BEING A CRONJOB
#if [[ $is_cronjob -eq 1 ]];
#then
#   [ ]
#else
#   [ ]
#fi

# SET TRAPS
#trap "CTRLC" 2
#trap "CTRLZ" 18
#trap "clean_RDP" 0

# MAIN LOOP
#{
   debuglev 1 && echo ${rdpcommand} ${alloptions} || \
      ${rdpcommand} ${alloptions}
   [ ]
#} | tee -a $logfile

# EMAIL LOGFILE
#$sendsh $sendopts "$server $scriptfile out" $logfile $interestedparties

 

LibreOffice Basic Open Form with Macro

The macro

Sub Dialog1Show
' References include
' http://stackoverflow.com/questions/27934551/open-a-form-in-libreoffice-openoffice-base-with-a-specific-filter-query
' https://ask.libreoffice.org/en/question/7555/open-form-via-macro-in-libreoffice-base/?answer=45607#post-id-45607
' https://help.libreoffice.org/Basic/Programming_Examples_for_Controls_in_the_Dialog_Editor


    thisDoc = StarDesktop.CurrentComponent
    'MsgBox thisDoc.dbg_properties
    'MsgBox thisDoc.dbg_methods
    thisForm = thisDoc.getdrawpage.getbyindex(2)
    thisID = thisForm.getControl.Text
    'MsgBox "using ID " & thisID

    form_container = ThisDatabaseDocument.FormDocuments.getByName("EditContact")
    form_container.open
    form2 = form_container.component.getDrawPage.getforms.getbyindex(0)
    form2.Filter =("ID = " & thisID)
End Sub

References

http://stackoverflow.com/questions/27934551/open-a-form-in-libreoffice-openoffice-base-with-a-specific-filter-query

https://ask.libreoffice.org/en/question/7555/open-form-via-macro-in-libreoffice-base/?answer=45607#post-id-45607

https://help.libreoffice.org/Basic/Programming_Examples_for_Controls_in_the_Dialog_Editor

The story

In my migration to Linux on the desktop (yes, this is the year for me), I have started converting my Microsoft Access .accdb file to LibreOffice. In my interim I had a really cool jdbc connection, but now was the time to actually get off of that entirely.

Using some instructions for the overly-simple task of exporting and importing data into LibreOffice Base, I got my little table saved to a .ods and then imported into a new, HyperSQL database.

Oh my goodness, HyperSQL is not what I learned in my few database classes during college. I had to rewrite my mid-level-complexity query (the first time for the jdbc connection, and then again) for HyperSQL. Some user-unfriendly documentation exists.

In the function above, the MsgBox contents are debugging info that can provide you with method calls to duckduckgo to get examples and descriptions.

In a future post I might share my “ContactsExtended” query I wrote a long time ago in Access 2010 and have ported up all the way to my current HyperSQL format.