changeset 3:6d87242c537e

Add Makefile Use make to allow changing the tclsh path and for easy installation.
author Guido Berhoerster <guido+relmon@berhoerster.name>
date Mon, 20 Oct 2014 19:31:20 +0200
parents 0203fffb4d74
children f28486666a4f
files Makefile README relmon.tcl relmon.tcl.in
diffstat 4 files changed, 1773 insertions(+), 1708 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Makefile	Mon Oct 20 19:31:20 2014 +0200
@@ -0,0 +1,65 @@
+#
+# Copyright (C) 2014 Guido Berhoerster <guido+relmon@berhoerster.name>
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be included
+# in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+#
+
+PACKAGE =	relmon
+VERSION =	1
+DISTNAME :=	$(PACKAGE)-$(VERSION)
+
+INSTALL :=	install
+INSTALL.exec :=	$(INSTALL) -D -m 0755
+INSTALL.data :=	$(INSTALL) -D -m 0644
+PAX :=		pax
+GZIP :=		gzip
+SED :=		sed
+TCLSH_PATH :=	/usr/bin/tclsh
+
+DESTDIR ?=
+prefix ?=	/usr/local
+bindir ?=	$(prefix)/bin
+
+SCRIPTS =	$(PACKAGE).tcl
+
+.DEFAULT_TARGET = all
+
+.PHONY: all clean clobber dist install
+
+all: $(PACKAGE)
+
+$(PACKAGE): $(SCRIPTS)
+	cp $< $@
+
+%.tcl: %.tcl.in
+	$(SED) -e '1s,#!.*,#!$(TCLSH_PATH),' -e 's,@VERSION@,$(VERSION),' $< \
+	    > $@
+
+install:
+	$(INSTALL.exec) $(PACKAGE) "$(DESTDIR)$(bindir)/$(PACKAGE)"
+
+clean:
+	rm -f $(PACKAGE) $(SCRIPTS)
+
+clobber: clean
+
+dist: clobber
+	$(PAX) -w -x ustar -s ',.*/\..*,,' -s ',./[^/]*\.tar\.gz,,' \
+	    -s ',\./,$(DISTNAME)/,' . | $(GZIP) > $(DISTNAME).tar.gz
--- a/README	Sun Oct 19 21:32:37 2014 +0200
+++ b/README	Mon Oct 20 19:31:20 2014 +0200
@@ -20,8 +20,8 @@
 Requirements
 ------------
 
-relmon requires Tcl 8.5 or later, tcllib, tls, and tdom. It has been tested on
-Linux distributions and FreeBSD.
+relmon requires GNU make, GNU or BSD install, Tcl 8.5 or later, tcllib, tls,
+and tdom. It has been tested on Linux distributions and FreeBSD.
 
 License
 -------
--- a/relmon.tcl	Sun Oct 19 21:32:37 2014 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1706 +0,0 @@
-#!/usr/bin/tclsh
-#
-# Copyright (C) 2014 Guido Berhoerster <guido+relmon@berhoerster.name>
-#
-# Permission is hereby granted, free of charge, to any person obtaining
-# a copy of this software and associated documentation files (the
-# "Software"), to deal in the Software without restriction, including
-# without limitation the rights to use, copy, modify, merge, publish,
-# distribute, sublicense, and/or sell copies of the Software, and to
-# permit persons to whom the Software is furnished to do so, subject to
-# the following conditions:
-#
-# The above copyright notice and this permission notice shall be included
-# in all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
-# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
-# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
-# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
-# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-package require Tcl 8.5
-package require http
-package require tls
-package require tdom
-package require try
-package require cmdline
-package require control
-package require html
-package require htmlparse
-package require json
-package require json::write
-package require logger
-package require logger::utils
-package require textutil::split
-package require uri
-package require uri::urn
-
-
-namespace eval ::relmon {
-    # version
-    variable VERSION 1
-}
-
-
-namespace eval ::relmon::common {
-    namespace export cmpVersions isUrlValid urlGetHost normalizeHttpHeaders \
-            parseStateFile
-}
-
-# implementation of the Debian version comparison algorithm described at
-# http://www.debian.org/doc/debian-policy/ch-controlfields.html#s-f-Version
-proc ::relmon::common::cmpVersion {v1 v2} {
-    set v1Len [string length $v1]
-    set v2Len [string length $v2]
-    set v1Pos 0
-    set v2Pos 0
-    while {($v1Pos < $v1Len) || ($v2Pos < $v2Len)} {
-        set firstNumDiff 0
-        # until reaching ASCII digits in both version strings compare character
-        # values which are modified as so they are sorted in the following
-        # order:
-        # - "~"
-        # - missing character or ASCII digits
-        # - ASCII alphabet
-        # - everything else in the order of their unicode value
-        while {(($v1Pos < $v1Len) &&
-                ![string match {[0123456789]} [string index $v1 $v1Pos]]) ||
-                (($v2Pos < $v2Len) &&
-                ![string match {[0123456789]} [string index $v2 $v2Pos]])} {
-            foreach char [list [string index $v1 $v1Pos] \
-                    [string index $v2 $v2Pos]] charValueName \
-                    {v1CharValue v2CharValue} {
-                if {$char eq "~"} {
-                    set $charValueName -1
-                } elseif {$char eq ""} {
-                    set $charValueName 0
-                } elseif {[string match {[0123456789]} $char]} {
-                    set $charValueName 0
-                } elseif {[string match -nocase {[abcdefghijklmnopqrstuvwxyz]} \
-                        $char]} {
-                    set $charValueName [scan $char "%c"]
-                } else {
-                    set $charValueName [expr {[scan $char "%c"] + 0x7f + 1}]
-                }
-            }
-            if {$v1CharValue != $v2CharValue} {
-                return [expr {$v1CharValue - $v2CharValue}]
-            }
-            incr v1Pos
-            incr v2Pos
-        }
-
-        # strip leading zeros
-        while {[string index $v1 $v1Pos] eq "0"} {
-            incr v1Pos
-        }
-        while {[string index $v2 $v2Pos] eq "0"} {
-            incr v2Pos
-        }
-
-        # process digits until reaching a non-digit
-        while {[string match {[0123456789]} [string index $v1 $v1Pos]] &&
-                [string match {[0123456789]} [string index $v2 $v2Pos]]} {
-            # record the first difference between the two numbers
-            if {$firstNumDiff == 0} {
-                set firstNumDiff [expr {[string index $v1 $v1Pos] -
-                        [string index $v2 $v2Pos]}]
-            }
-            incr v1Pos
-            incr v2Pos
-        }
-
-        # return if the number of one version has more digits than the other
-        # since the one with more digits is the larger number
-        if {[string match {[0123456789]} [string index $v1 $v1Pos]]} {
-            return 1
-        } elseif {[string match {[0123456789]} [string index $v2 $v2Pos]]} {
-            return -1
-        }
-
-        # return the difference if the digits differed above
-        if {$firstNumDiff != 0} {
-            return $firstNumDiff
-        }
-    }
-
-    return 0
-}
-
-proc ::relmon::common::isUrlValid {url} {
-    return [expr {![catch {dict create {*}[uri::split $url]} urlParts] &&
-            ([dict get $urlParts "scheme"] in {"http" "https"}) &&
-            ([dict get $urlParts "host"] ne "")}]
-}
-
-proc ::relmon::common::urlGetHost {url} {
-    return [expr {(![catch {dict create {*}[uri::split $url]} urlParts]) ?
-                [dict get $urlParts "host"] : ""}]
-}
-
-proc ::relmon::common::normalizeHttpHeaders {headers} {
-    set httpHeaders [dict create]
-    foreach {header value} $headers {
-        set words {}
-        foreach word [split $header "-"] {
-            lappend words [string totitle $word]
-        }
-        dict set httpHeaders [join $words "-"] $value
-    }
-    return $httpHeaders
-}
-
-proc ::relmon::common::parseStateFile {stateFile} {
-    try {
-        set f [open $stateFile "r"]
-    } trap {POSIX} {errorMsg errorOptions} {
-        return -options $errorOptions \
-                "failed to open state file \"$stateFile\": $errorMsg"
-    }
-    try {
-        set state [json::json2dict [chan read $f]]
-    } trap {POSIX} {errorMsg errorOptions} {
-        return -options $errorOptions \
-                "failed to read from state file \"$stateFile\": $errorMsg"
-    } on error {errorMsg errorOptions} {
-        # the json package does not set an error code
-        dict set errorOptions "-errorcode" {RELMON JSON_PARSE_ERROR}
-        return -options $errorOptions \
-                "failed to parse state file \"$stateFile\": $errorMsg"
-    } finally {
-        close $f
-    }
-
-    return $state
-}
-
-
-namespace eval ::relmon::update {
-    # commandline option help text
-    variable usage "usage: relmon update \[-dev\] \[-c max_connections\] \[-C\
-            ca_dir\] \[-D delay\]\n\
-            \                    \[-H max_host_connections\] \[-i\
-            item\[,...\]\] \[-l logfile\]\n\
-            \                    \[-r retries\] \[-t min_time\] watchlist\
-            statefile"
-
-    # configuration options
-    variable Config [dict create \
-            "log_file" "" \
-            "log_level" "notice" \
-            "history_limit" 20 \
-            "connection_limit" 16 \
-            "host_connection_limit" 4 \
-            "transfer_time_limit" 60000 \
-            "retry_limit" 3 \
-            "host_delay" 0 \
-            "timestamp_filter" 0 \
-            "error_filter" 0 \
-            "item_filter" {} \
-            "ca_dir" "" \
-            "state_file" "" \
-            "watchlist_file" ""]
-
-    # exit status
-    variable ExitStatus
-
-    # transfer statistics
-    variable Statistics [dict create \
-            "start_time" 0 \
-            "end_time" 0 \
-            "requests" 0 \
-            "items" 0]
-
-    # watchlist
-    variable Watchlist
-
-    # ID of a delayed run of ManageTransfers
-    variable ManageTransfersId ""
-
-    # queue of pending transfers
-    variable Queue
-
-    # number of active connections per host
-    variable HostConnections
-
-    # delays before opening a new connection to a host
-    variable HostDelays
-
-    # active transfers
-    variable ActiveTransfers
-
-    # buffer for tracking the state of unfinished items
-    variable StateBuffer
-
-    # buffer needed by htmlparse::parse for constructing the preprocessed HTML
-    # document
-    variable PreprocessedHtmlBuffer
-
-    # logger handle
-    variable Log
-
-    # logfile handle
-    variable Lf
-}
-
-proc ::relmon::update::OnError {message returnOptions} {
-    # internal error, abort
-    puts stderr [dict get $returnOptions "-errorinfo"]
-
-    exit 1
-}
-
-proc ::relmon::update::CleanupBeforeExit {commandString operation} {
-    variable Lf
-
-    # close logfile
-    if {($Lf ne "") && ($Lf ni {stdin stderr})} {
-        close $Lf
-        set Lf ""
-    }
-
-    return
-}
-
-proc ::relmon::update::ParseWatchlist {watchlistFilename} {
-    variable Watchlist
-
-    set lineno 0
-    set f [open $watchlistFilename "r"]
-    try {
-        while {[chan gets $f line] != -1} {
-            set fields [textutil::split::splitx [string trim $line] {[\t ]+}]
-            incr lineno
-
-            if {([llength $fields] == 0) ||
-                    ([string index [lindex $fields 0] 0] eq "#")} {
-                # skip empty lines and comments
-                continue
-            } elseif {[llength $fields] < 3} {
-                # a line consists of a name, base URL and at least one
-                # version-matching pattern
-                return -code error -errorcode {RELMON WATCHLIST_PARSE_ERRROR} \
-                        "syntax error in \"$watchlistFilename\" line $lineno"
-            }
-
-            set patterns [lassign $fields name baseUrl]
-
-            # validate URL
-            if {![::relmon::common::isUrlValid $baseUrl]} {
-                return -code error -errorcode {RELMON WATCHLIST_PARSE_ERRROR} \
-                        "syntax error in \"$watchlistFilename\" line $lineno:\
-                        invalid base URL"
-            }
-
-            # process patterns
-            set processedPatterns {}
-            set patternIndex 0
-            foreach pattern $patterns {
-                incr patternIndex
-
-                # make trailing slashes optional except in the last
-                # version-matching pattern
-                if {($patternIndex != [llength $patterns]) &&
-                        ([string index $pattern end] eq "/")} {
-                    append pattern {?}
-                }
-
-                # ensure patterns are anchored to the end of the line
-                if {[string index $pattern end] ne "$"} {
-                    append pattern {$}
-                }
-
-                # actually validate the regular expression
-                try {
-                    set reInfo [regexp -about -- $pattern ""]
-                } on error {errorMsg} {
-                    return -code error \
-                            -errorcode {RELMON WATCHLIST_PARSE_ERRROR} \
-                            "error in \"$watchlistFilename\" line $lineno:\
-                            $errorMsg"
-                }
-                lappend processedPatterns $pattern
-            }
-            if {[lindex $reInfo 0] < 1} {
-                return -code error -errorcode {RELMON WATCHLIST_PARSE_ERRROR} \
-                        "syntax error in \"$watchlistFilename\" line $lineno:\
-                        the last regular expression must contain at least one
-                        capturing group"
-            }
-
-            dict set Watchlist $name "base_url" $baseUrl
-            dict set Watchlist $name "patterns" $processedPatterns
-        }
-    } finally {
-        close $f
-    }
-
-    return
-}
-
-proc ::relmon::update::ProcessHtmlElement {tag slash param textBehindTheTag} {
-    variable PreprocessedHtmlBuffer
-
-    # copy every "<a>" element into PreprocessedHtmlBuffer
-    if {($slash eq "") && ([string tolower $tag] eq "a")} {
-        append PreprocessedHtmlBuffer "<$tag $param></$tag>"
-    }
-
-    return
-}
-
-proc ::relmon::update::PreprocessHtml {bodyDataName} {
-    upvar 1 $bodyDataName bodyData
-    variable PreprocessedHtmlBuffer
-
-    # preprocess the document with htmlparse by constructing a new document
-    # consisting only of found "<a>" elements which then can be fed into tdom
-    # again; this is useful if parsing via tdom fails; however, htmlparse
-    # should only be used as a last resort because it is just too limited, it
-    # gets easily confused within "<script>" elements and lacks attribute
-    # parsing
-    set PreprocessedHtmlBuffer "<html><body>"
-    htmlparse::parse -cmd [namespace code ProcessHtmlElement] $bodyData
-    append PreprocessedHtmlBuffer "</body></html>"
-}
-
-proc ::relmon::update::ExtractUrls {bodyDataName contentType baseUrl
-        rePattern} {
-    upvar 1 $bodyDataName bodyData
-    set extractedUrls {}
-    set resultUrls [dict create]
-    # extract all URLs or URL fragments
-    switch -- $contentType {
-        {text/html} -
-        {application/xhtml+xml} {
-            # HTML/XHTML
-            # if tdom parsing has failed or not found any "<a>" element,
-            # preprocess the document with htmlparse and try again
-            if {[catch {dom parse -html $bodyData} doc] ||
-                    ([set rootElement [$doc documentElement]] eq "") ||
-                    ([llength [set aElements \
-                    [$rootElement selectNodes {descendant::a}]]] == 0)} {
-                try {
-                    set doc [dom parse -html [PreprocessHtml bodyData]]
-                } on error {errorMsg errorOptions} {
-                    dict set errorOptions "-errorcode" \
-                            {RELMON TDOM_PARSE_ERROR}
-                    return -options $errorOptions $errorMsg
-                }
-                set rootElement [$doc documentElement]
-                set aElements [$rootElement selectNodes {descendant::a}]
-            }
-            foreach node $aElements {
-                set href [$node getAttribute "href" ""]
-                if {$href ne ""} {
-                    lappend extractedUrls $href
-                }
-            }
-            $doc delete
-        }
-        {application/rss+xml} {
-            # RSS 2.0
-            try {
-                set doc [dom parse $bodyData]
-            } on error {errorMsg errorOptions} {
-                dict set errorOptions "-errorcode" {RELMON TDOM_PARSE_ERROR}
-                return -options $errorOptions $errorMsg
-            }
-            set rootElement [$doc documentElement]
-            if {$rootElement ne ""} {
-                foreach node [$rootElement selectNodes {descendant::link}] {
-                    set linkText [$node text]
-                    if {$linkText ne ""} {
-                        lappend extractedUrls $linkText
-                    }
-                }
-            }
-            $doc delete
-        }
-        {application/atom+xml} {
-            # Atom 1.0
-            try {
-                set doc [dom parse $bodyData]
-            } on error {errorMsg errorOptions} {
-                dict set errorOptions "-errorcode" {RELMON TDOM_PARSE_ERROR}
-                return -options $errorOptions $errorMsg
-            }
-            set rootElement [$doc documentElement]
-            if {$rootElement ne ""} {
-                foreach node [$rootElement selectNodes {descendant::link}] {
-                    set href [$node getAttribute "href" ""]
-                    if {$href ne ""} {
-                        lappend extractedUrls $href
-                    }
-                }
-            }
-            $doc delete
-        }
-        {text/plain} {
-            # plain text
-            foreach line [split $bodyData "\n"] {
-                if {$line ne ""} {
-                    lappend extractedUrls $line
-                }
-            }
-        }
-        default {
-            return -code error \
-                    -errorcode {RELMON UNSUPPORTED_CONTENT_TYPE_ERROR} \
-                    "unsupported content type \"$contentType\""
-        }
-    }
-    foreach url $extractedUrls {
-        set normalizedUrl [uri::canonicalize [uri::resolve $baseUrl $url]]
-        dict set resultUrls $normalizedUrl \
-                [expr {[regexp -line -- $rePattern $normalizedUrl] ? 1 : 0}]
-    }
-
-    return $resultUrls
-}
-
-proc ::relmon::update::StateItemAppendError {name logMsg} {
-    variable StateBuffer
-
-    dict update StateBuffer $name stateItem {
-        dict lappend stateItem "errors" $logMsg
-    }
-
-    return
-}
-
-proc ::relmon::update::HandleSuccessfulTransfer {item httpBodyName} {
-    upvar 1 $httpBodyName httpBody
-    variable Log
-    variable StateBuffer
-    variable Queue
-    variable Watchlist
-
-    set name [dict get $item "name"]
-    set url [dict get $item "url"]
-    set patternIndex [dict get $item "pattern_index"]
-    set pattern [lindex [dict get $Watchlist $name "patterns"] $patternIndex]
-
-    ${Log}::info "\"$name\": \"$url\": transfer finished"
-
-    # parse data
-    try {
-        set urls [ExtractUrls httpBody [dict get $item "content_type"] $url \
-                $pattern]
-    } trap {RELMON} {errorMsg} {
-        # continue on tdom parsing errors or when receiving documents with an
-        # unsupported content type
-        set urls [dict create]
-        set warningMsg "\"$name\": \"$url\": failed to parse data: $errorMsg"
-        ${Log}::warn $warningMsg
-        StateItemAppendError $name $warningMsg
-    }
-
-    if {$patternIndex < ([llength \
-            [dict get $Watchlist $name "patterns"]] - 1)} {
-        # if this is not the last, version-matching pattern, queue matched URLs
-        dict for {newUrl matched} $urls {
-            if {$matched} {
-                if {![::relmon::common::isUrlValid $newUrl]} {
-                    ${Log}::debug "\"$name\": \"$url\": ignoring matched but\
-                            invalid URL \"$newUrl\""
-                    continue
-                }
-
-                ${Log}::debug "\"$name\": \"$url\": queuing \"$newUrl\""
-
-                dict lappend Queue [::relmon::common::urlGetHost $newUrl] \
-                        [dict create "name" $name "url" $newUrl \
-                        "pattern_index" [expr {$patternIndex + 1}] \
-                        "content_type" "" "num_redirects" 0 "num_retries" 0]
-            } else {
-                ${Log}::debug "\"$name\": \"$url\": ignoring \"$newUrl\""
-            }
-        }
-    } else {
-        # otherwise this branch has finished, try to extract the versions and
-        # store them in the buffer
-        dict for {finalUrl matched} $urls {
-            if {$matched} {
-                regexp -line -- $pattern $finalUrl -> version
-                if {$version ne ""} {
-                    ${Log}::debug "\"$name\": \"$url\": extracted version\
-                            \"$version\" from \"$finalUrl\" found on\
-                            \"$url\""
-                    dict set StateBuffer $name "versions" $version $finalUrl
-                } else {
-                    ${og}::debug "\"$name\": \"$url\": could not extract a\
-                            version from \"$finalUrl\""
-                }
-            } else {
-                ${Log}::debug "\"$name\": \"$url\": ignoring \"$finalUrl\""
-            }
-        }
-    }
-
-    return
-}
-
-proc ::relmon::update::HandleRedirect {item httpCode httpHeaders} {
-    variable Log
-    variable Queue
-
-    set name [dict get $item "name"]
-    set url [dict get $item "url"]
-
-    if {![dict exists $httpHeaders "Location"]} {
-        # bail out in case of an invalid HTTP response
-        set warningMsg "\"$name\": \"$url\": transfer failed: invalid HTTP\
-                response"
-        ${Log}::warn $warningMsg
-        StateItemAppendError $name $warningMsg
-        return
-    }
-    set location [dict get $httpHeaders "Location"]
-
-    # sanitize URL from Location header
-    if {[uri::isrelative $location]} {
-        set redirectUrl [uri::canonicalize [uri::resolve \
-                $url $location]]
-    } else {
-        if {![::relmon::common::isUrlValid $location]} {
-            # bail out in case of an invalid redirect URL
-            set warningMsg "\"$name\": \"$url\": received invalid redirect URL\
-                    \"$location\""
-            ${Log}::warn $warningMsg
-            StateItemAppendError $name $warningMsg
-            return
-        }
-        set redirectUrl [uri::canonicalize $location]
-    }
-
-    ${Log}::notice "\"$name\": \"$url\": received redirect to \"$redirectUrl\""
-
-    # handle up to 10 redirects by re-queuing the target URL
-    if {[dict get $item "num_redirects"] < 10} {
-        ${Log}::debug "\"$name\": \"$url\": queuing \"$redirectUrl\" due to\
-                redirect"
-
-        dict lappend Queue [::relmon::common::urlGetHost $redirectUrl] \
-                [dict replace $item "url" $redirectUrl "content_type" "" \
-                "num_redirects" [expr {[dict get $item "num_redirects"] + 1}] \
-                "num_retries" 0]
-    } else {
-        set warningMsg "\"$name\": \"$url\": exceeded maximum number of\
-                redirects"
-        ${Log}::warn $warningMsg
-        StateItemAppendError $name $warningMsg
-    }
-
-    return
-}
-
-proc ::relmon::update::HandleProtocolError {item httpCode} {
-    variable Log
-    set name [dict get $item "name"]
-    set url [dict get $item "url"]
-    set warningMsg "\"$name\": \"$url\": transfer failed: $httpCode"
-    ${Log}::warn $warningMsg
-    StateItemAppendError $name $warningMsg
-    return
-}
-
-proc ::relmon::update::HandleTimeoutReset {item} {
-    variable Log
-    variable Config
-    variable Queue
-    set name [dict get $item "name"]
-    set url [dict get $item "url"]
-
-    # retry by re-queuing the target URL until reaching the limit
-    if {[dict get $item "num_retries"] < [dict get $Config "retry_limit"]} {
-        ${Log}::warn "\"$name\": \"$url\": connection timed out or was reset,\
-                retrying"
-        dict lappend Queue [::relmon::common::urlGetHost $url] \
-                [dict replace $item \
-                "num_retries" [expr {[dict get $item "num_retries"] + 1}]]
-    } else {
-        set warningMsg "\"$name\": \"$url\": exceeded maximum number of\
-                retries"
-        ${Log}::warn $warningMsg
-        StateItemAppendError $name $warningMsg
-    }
-
-    return
-}
-
-proc ::relmon::update::HandleConnectionError {item errorMsg} {
-    variable Log
-    set name [dict get $item "name"]
-    set url [dict get $item "url"]
-    set warningMsg "\"$name\": \"$url\": transfer failed: $errorMsg"
-    ${Log}::warn $warningMsg
-    StateItemAppendError $name $warningMsg
-    return
-}
-
-proc ::relmon::update::TransferCallbackWrapper {callbackCmd args} {
-    # ensure that exceptions get raised, by default http catches all errors and
-    # silently ignores them, see https://core.tcl.tk/tcl/tktview?name=1414262
-    if {[catch {eval $callbackCmd $args} -> errorOptions]} {
-        OnError [dict get $errorOptions "-errorinfo"] $errorOptions
-    }
-    return
-}
-
-proc ::relmon::update::ManageTransfers {} {
-    variable Config
-    variable ManageTransfersId
-    variable Queue
-    variable HostConnections
-    variable HostDelays
-    variable ActiveTransfers
-    variable ExitStatus
-    variable Log
-
-    after cancel $ManageTransfersId
-
-    # try to initiate new transfers
-    while {([dict size $ActiveTransfers] <
-            [dict get $Config "connection_limit"]) &&
-            ([dict size $Queue] > 0)} {
-        # find URLs in the queue with a host for which we have not reached the
-        # per-host connection limit yet and for which no delay is in effect
-        set item {}
-        dict for {host items} $Queue {
-            set now [clock milliseconds]
-
-            if {![dict exists $HostConnections $host]} {
-                dict set HostConnections $host 0
-            }
-
-            if {![dict exists $HostDelays $host]} {
-                dict set HostDelays $host $now
-            }
-
-            if {([dict get $HostConnections $host] <
-                    [dict get $Config "host_connection_limit"]) &&
-                    ([dict get $HostDelays $host] <= $now)} {
-                # pop item from the queue
-                set items [lassign $items item]
-                if {[llength $items] > 0} {
-                    dict set Queue $host $items
-                } else {
-                    dict unset Queue $host
-                }
-
-                dict incr HostConnections $host
-                # set a random delay before the next connection to this host
-                # can be made
-                dict set HostDelays $host \
-                        [expr {[clock milliseconds] + int((rand() + 0.5) *
-                        [dict get $Config "host_delay"])}]
-                break
-            }
-        }
-        # if no item could be found, the per-host connection limit for all
-        # queued URLs has been reached and no new transfers may be started
-        # at this point
-        if {$item eq {}} {
-            break
-        }
-        # otherwise start a new transfer
-        set url [dict get $item "url"]
-        set name [dict get $item "name"]
-        try {
-            set token [http::geturl $url \
-                    -timeout [dict get $Config "transfer_time_limit"] \
-                    -progress [namespace code {TransferCallbackWrapper \
-                    OnTransferProgress}] \
-                    -command [namespace code {TransferCallbackWrapper \
-                    OnTransferFinished}]]
-        } on ok {} {
-            dict set ActiveTransfers $token $item
-
-            ${Log}::info "\"$name\": \"$url\": starting transfer"
-        } on error {errorMsg} {
-            # an error occured during socket setup, e.g. a DNS lookup failure
-            set warningMsg "\"$name\": \"$url\": transfer failed: $errorMsg"
-            ${Log}::warn $warningMsg
-            StateItemAppendError $name $warningMsg
-        }
-    }
-
-    # terminate the event loop if there are no remaining transfers
-    if {([dict size $ActiveTransfers]) == 0 && ([dict size $Queue] == 0)} {
-        set ExitStatus 0
-        return
-    }
-
-    # due to per-host connection limits and per-host delays the maximum number
-    # of connections may not be reached although there are still items in the
-    # queue, in this case schedule ManageTransfers again after the smallest of
-    # the current per-host delays
-    set delay 0
-    if {([dict size $ActiveTransfers] <
-            [dict get $Config "connection_limit"]) &&
-            ([dict size $Queue] > 0)} {
-        dict for {host items} $Queue {
-            if {(![dict exists $HostConnections $host] ||
-                    ([dict get $HostConnections $host] <
-                    [dict get $Config "host_connection_limit"])) &&
-                    ([dict exists $HostDelays $host] &&
-                    ([dict get $HostDelays $host] > $now))} {
-                set hostDelay [expr {[dict get $HostDelays $host] - $now + 1}]
-                if {(($delay == 0) ||
-                        ($hostDelay < $delay))} {
-                    set delay $hostDelay
-                }
-            }
-        }
-        if {$delay > 0} {
-            set ManageTransfersId \
-                    [after $delay [namespace code ManageTransfers]]
-        }
-    }
-
-    return
-}
-
-proc ::relmon::update::OnTransferProgress {token total current} {
-    upvar #0 $token httpState
-    variable ActiveTransfers
-    variable Log
-
-    # try to determine content type and abort transfer if content type is not
-    # one that can be parsed, this is primarily to prevent accidental downloads
-    if {[dict get $ActiveTransfers $token "content_type"] eq ""} {
-        set httpHeaders [relmon::common::normalizeHttpHeaders \
-                $httpState(meta)]
-
-        if {[dict exists $httpHeaders "Content-Type"]} {
-            set contentType [string trim [lindex [split \
-                    [dict get $httpHeaders "Content-Type"] ";"] 0]]
-            dict set ActiveTransfers $token "content_type" $contentType
-            if {$contentType ni {"text/html" "application/xhtml+xml"
-                    "application/atom+xml" "application/rss+xml"
-                    "text/plain"}} {
-                ${Log}::warn "\"[dict get $ActiveTransfers $token "name"]\":\
-                        \"[dict get $ActiveTransfers $token "url"]\": content\
-                        type \"$contentType\" is not acceptable"
-                http::reset $token
-            }
-        }
-    }
-}
-
-proc ::relmon::update::OnTransferFinished {token} {
-    upvar #0 $token httpState
-    variable Config
-    variable HostConnections
-    variable Queue
-    variable ActiveTransfers
-    variable Statistics
-    variable StateBuffer
-    variable State
-    variable Log
-
-    set item [dict get $ActiveTransfers $token]
-    set name [dict get $item "name"]
-    set host [relmon::common::urlGetHost [dict get $item "url"]]
-
-    # update list of per-host connections, and number of remaining transfers
-    # for this item
-    dict unset ActiveTransfers $token
-    dict incr HostConnections $host -1
-
-    switch -- $httpState(status) {
-        {ok} {
-            # normalize headers
-            set httpHeaders [relmon::common::normalizeHttpHeaders \
-                    $httpState(meta)]
-
-            # try to determine content type
-            if {([dict get $item "content_type"] eq "") &&
-                    [dict exists $httpHeaders "Content-Type"]} {
-                dict set item "content_type" [string trim [lindex [split \
-                        [dict get $httpHeaders "Content-Type"] ";"] 0]]
-            }
-
-            # dispatch based on HTTP status code
-            set httpCode [http::ncode $token]
-            switch -glob -- $httpCode {
-                {30[12378]} {
-                    HandleRedirect $item $httpCode $httpHeaders
-                }
-                {200} {
-                    HandleSuccessfulTransfer $item httpState(body)
-                }
-                default {
-                    HandleProtocolError $item $httpState(http)
-                }
-            }
-        }
-        {reset} {
-            # aborted due to wrong content type
-        }
-        {eof} -
-        {timeout} {
-            # timeout or connection reset
-            HandleTimeoutReset $item
-        }
-        {error} {
-            # connection may have failed or been refused
-            HandleConnectionError $item [lindex $httpState(error) 0]
-        }
-    }
-
-    # check if all transfers of this item are finished
-    set itemFinished 1
-    dict for {queueHost queueItems} $Queue {
-        foreach queueItem $queueItems {
-            if {[dict get $queueItem "name"] eq $name} {
-                set itemFinished 0
-            }
-        }
-    }
-    dict for {activeToken activeItem} $ActiveTransfers {
-        if {[dict get $activeItem "name"] eq $name} {
-            set itemFinished 0
-        }
-    }
-    if {$itemFinished} {
-        set timestamp [clock milliseconds]
-
-        # create httpState item if it does not exist yet
-        if {![dict exists $State $name]} {
-            dict set State $name [dict create "versions" [dict create] \
-                    "history" [list] "timestamp" 0 "errors" [list]]
-        }
-
-        # if there are no versions, log an error message since something must
-        # be wrong
-        if {[llength [dict get $StateBuffer $name "versions"]] == 0} {
-            set warningMsg "\"$name\": no versions found"
-            ${Log}::warn $warningMsg
-            StateItemAppendError $name $warningMsg
-        }
-
-        # update httpState item
-        dict set State $name "errors" [dict get $StateBuffer $name "errors"]
-        dict set State $name "timestamp" $timestamp
-        if {[llength [dict get $StateBuffer $name "errors"]] == 0} {
-            # expire old history entries
-            set history [lrange [dict get $State $name "history"] \
-                    [expr {[llength [dict get $State $name "history"]] -
-                    [dict get $Config "history_limit"] + 1}] end]
-
-            # add currently latest available version to history if it is either
-            # newer than the previous one or if the previous one is no longer
-            # available (e.g. if it has been removed or the watchlist pattern
-            # has been changed)
-            set prevLatestVersion [lindex $history end 0]
-            set curLatestVersion [lindex \
-                    [lsort -command ::relmon::common::cmpVersion \
-                    [dict keys [dict get $StateBuffer $name "versions"]]] end]
-            if {([::relmon::common::cmpVersion $curLatestVersion \
-                    $prevLatestVersion] > 0) ||
-                    ![dict exists $StateBuffer $name "versions" \
-                    $prevLatestVersion]} {
-                lappend history [list $curLatestVersion $timestamp]
-                dict set State $name "history" $history
-            }
-            dict set State $name "versions" [dict get $StateBuffer $name \
-                    "versions"]
-        }
-        dict unset StateBuffer $name
-
-        ${Log}::notice "progress: [dict size $StateBuffer]/[dict get \
-                $Statistics "items"] items left"
-    }
-
-    http::cleanup $token
-
-    ManageTransfers
-
-    return
-}
-
-# control certificate verification and log errors during TLS handshake
-proc ::relmon::update::OnTlsHandshake {type args} {
-    variable Config
-    variable Log
-
-    switch -- ${type} {
-        {error} {
-            lassign $args {} tlsErrorMsg
-            ${Log}::error "TLS connection error: $tlsErrorMsg"
-        }
-        {verify} {
-            lassign $args {} {} {} status tlsErrorMsg
-            array set cert [lindex $args 2]
-            if {$status == 0} {
-                if {[dict get $Config "ca_dir"] eq ""} {
-                    # do not verify certificates is ca-dir was not set
-                    return 1
-                } else {
-                    set errorMsg "$tlsErrorMsg\nCertificate details:"
-                    foreach {key description} {"serial" "Serial Number"
-                            "issuer" "Issuer" "notBefore" "Not Valid Before"
-                            "notAfter" "Not Valid After" "subject" "Subject"
-                            "sha1_hash" "SHA1 Hash"} {
-                        append errorMsg "\n$description: $cert($key)"
-                    }
-                    ${Log}::error "TLS connection error: $errorMsg"
-                    return 0
-                }
-            }
-        }
-    }
-}
-
-proc ::relmon::update::main {args} {
-    variable Config
-    variable usage
-    variable Statistics
-    variable Watchlist [dict create]
-    variable Queue [dict create]
-    variable HostConnections [dict create]
-    variable HostDelays [dict create]
-    variable ActiveTransfers [dict create]
-    variable State
-    variable StateBuffer [dict create]
-    variable PreprocessedHtmlBuffer
-    variable Log
-    variable Lf ""
-    variable ExitStatus
-
-    # parse commandline
-    while {[set GetoptRet [cmdline::getopt args \
-            {c.arg C.arg d D.arg e H.arg i.arg l.arg r.arg t.arg T.arg v} \
-            OptArg OptVal]] == 1} {
-        switch -glob -- $OptArg {
-            {c} {
-                if {![string is digit -strict $OptVal] || ($OptVal <= 0)} {
-                    puts stderr "invalid value passed to \"-$OptArg\""
-                    exit 1
-                }
-                dict set Config "host_connection_limit" $OptVal
-            }
-            {C} {
-                if {![file isdirectory $OptVal]} {
-                    puts stderr "directory \"$OptVal\" is not a directory"
-                    exit 1
-                } elseif {![file readable $OptVal] ||
-                        ![file executable $OptVal]} {
-                    puts stderr "directory \"$OptVal\" is not readable"
-                    exit 1
-                }
-                dict set Config "ca_dir" $OptVal
-            }
-            {d} {
-                dict set Config "log_level" "debug"
-            }
-            {D} {
-                if {![string is digit -strict $OptVal] || ($OptVal < 0)} {
-                    puts stderr "invalid value passed to \"-$OptArg\""
-                    exit 1
-                }
-                dict set Config "host_delay" [expr {$OptVal * 1000}]
-            }
-            {e} {
-                dict set Config "error_filter" 1
-            }
-            {H} {
-                if {![string is digit -strict $OptVal] || ($OptVal <= 0)} {
-                    puts stderr "invalid value passed to \"-$OptArg\""
-                    exit 1
-                }
-                dict set Config "connection_limit" $OptVal
-            }
-            {i} {
-                foreach item [split $OptVal " "] {
-                    set item [string trim $item]
-                    if {$item ne ""} {
-                        dict lappend Config "item_filter" $item
-                    }
-                }
-            }
-            {l} {
-                dict set Config "log_file" $OptVal
-                set LogDir [file dirname $OptVal]
-                if {![file writable $LogDir] || ![file executable $LogDir]} {
-                    puts stderr "directory \"$LogDir\" is not writable"
-                    exit 1
-                }
-            }
-            {r} {
-                if {![string is digit -strict $OptVal] || ($OptVal < 0)} {
-                    puts stderr "invalid value passed to \"-$OptArg\""
-                    exit 1
-                }
-                dict set Config "retry_limit" $OptVal
-            }
-            {t} {
-                if {![string is digit -strict $OptVal] || ($OptVal < 0)} {
-                    puts stderr "invalid value passed to \"-$OptArg\""
-                    exit 1
-                }
-                dict set Config "timestamp_filter" [expr {$OptVal * 1000}]
-            }
-            {T} {
-                if {![string is digit -strict $OptVal] || ($OptVal <= 0)} {
-                    puts stderr "invalid value passed to \"-$OptArg\""
-                    exit 1
-                }
-                dict set Config "transfer_time_limit" [expr {$OptVal * 1000}]
-            }
-            {v} {
-                if {[dict get $Config "log_level"] ne "debug"} {
-                    dict set Config "log_level" "info"
-                }
-            }
-        }
-    }
-    set argc [llength $args]
-    if {$GetoptRet == -1} {
-        puts stderr "unknown command line option \"-$OptArg\""
-        puts stderr $usage
-        exit 1
-    }
-    if {$argc != 2} {
-        puts stderr $usage
-        exit 1
-    }
-    dict set Config "watchlist_file" [lindex $args 0]
-    if {![file readable [dict get $Config "watchlist_file"]]} {
-        puts stderr "watchlist file \"[dict get $Config "watchlist_file"]\"\
-                could not be read"
-        exit 1
-    }
-    set stateFile [lindex $args 1]
-    dict set Config "state_file" $stateFile
-    set StateDir [file dirname $stateFile]
-    if {![file writable $StateDir]} {
-        puts stderr "directory \"$StateDir\" is not writable"
-
-        exit 1
-    }
-
-    # install exit handler for closing the logfile, open the logfile and
-    # initialize logger
-    trace add execution exit enter CleanupBeforeExit
-    if {[dict get $Config "log_file"] ne ""} {
-        try {
-            set Lf [open [dict get $Config "log_file"] "w"]
-        } trap {POSIX} {errorMsg errorOptions} {
-            puts stderr "failed to open logfile\
-                    \"[dict get $Config "log_file"]\": $errorMsg"
-            exit 1
-        }
-    } else {
-        set Lf stderr
-    }
-    set Log [logger::init global]
-    if {[dict get $Config "log_level"] eq "debug"} {
-        set logFormat {%d \[%p\] \[%M\] %m}
-    } else {
-        set logFormat {%d \[%p\] %m}
-    }
-    logger::utils::applyAppender -appender fileAppend -appenderArgs \
-            [list -outputChannel $Lf -conversionPattern $logFormat] \
-            -serviceCmd $Log
-
-    # set default logging level
-    ${Log}::setlevel [dict get $Config "log_level"]
-
-    ${Log}::notice "relmon.tcl starting up"
-
-    # parse the watchlist
-    try {
-        ParseWatchlist [dict get $Config "watchlist_file"]
-    } trap {POSIX} {errorMsg errorOptions} - \
-    trap {RELMON} {errorMsg errorOptions} {
-        ${Log}::error $errorMsg
-        exit 1
-    }
-
-    # read the state file
-    try {
-        set State [::relmon::common::parseStateFile $stateFile]
-    } trap {POSIX ENOENT} {errorMsg} {
-        ${Log}::debug "state file \"$stateFile\" does not exist"
-        set State [dict create]
-    } trap {POSIX} {errorMsg} - \
-    trap {RELMON} {errorMsg} {
-        ${Log}::error $errorMsg
-        exit 1
-    }
-
-    # initialize queue and state buffer from the watchlist
-    dict set Statistics "start_time" [clock milliseconds]
-    dict for {name watchlistItem} $Watchlist {
-        # apply filters specified on the command line to watchlist items
-        if {([llength [dict get $Config "item_filter"]] > 0) &&
-                ($name ni [dict get $Config "item_filter"])} {
-            continue
-        }
-
-        if {[dict get $Config "error_filter"] &&
-                [dict exists $State $name "errors"] &&
-                ([llength [dict get $State $name "errors"]] == 0)} {
-            continue
-        }
-
-        if {[dict exists $State $name "timestamp"] &&
-                ([dict get $State $name "timestamp"] >
-                [dict get $Statistics "start_time"] -
-                [dict get $Config "timestamp_filter"])} {
-            continue
-        }
-
-        dict lappend Queue [::relmon::common::urlGetHost \
-                [dict get $watchlistItem "base_url"]] \
-                [dict create \
-                "name" $name \
-                "url" [dict get $watchlistItem "base_url"] \
-                "pattern_index" 0 \
-                "content_type" "" \
-                "num_redirects" 0 \
-                "num_retries" 0]
-        dict incr Statistics "items"
-        dict set StateBuffer $name [dict create "versions" [dict create] \
-                "errors" [list]]
-    }
-
-    # configure http and tls
-    http::register https 443 [list tls::socket \
-            -cadir [dict get $Config "ca_dir"] -request 1 -require 1 \
-            -command [namespace code OnTlsHandshake] -ssl2 0 -ssl3 1 -tls1 1]
-    http::config -useragent "Mozilla/5.0 (Windows NT 6.1; rv:16.0)\
-            Gecko/20100101 Firefox/16.0 relmon/$relmon::VERSION"
-
-    # handle errors while in the event loop
-    interp bgerror {} [namespace code OnError]
-
-    # enter the main loop
-    after idle [namespace code ManageTransfers]
-    vwait [namespace which -variable ExitStatus]
-
-    dict set Statistics "end_time" [clock milliseconds]
-
-    # display statistics
-    ${Log}::notice "items checked: [dict get $Statistics "items"]"
-    ${Log}::notice "time elapsed: [expr {([dict get $Statistics "end_time"] -
-        [dict get $Statistics "start_time"]) / 1000}]s"
-
-    # serialize the new state
-    set JsonStateItems {}
-    dict for {item data} $State {
-        set versions {}
-        dict for {version url} [dict get $data "versions"] {
-            lappend versions $version [json::write string $url]
-        }
-        set history {}
-        foreach historyItem [dict get $data "history"] {
-            lassign $historyItem version timestamp
-            lappend history [json::write array [json::write string $version] \
-                    $timestamp]
-        }
-        set errors {}
-        foreach errorItem [dict get $data "errors"] {
-            lappend errors [json::write string $errorItem]
-        }
-        lappend JsonStateItems $item [json::write object \
-            "versions" [json::write object {*}$versions] \
-            "history" [json::write array {*}$history] \
-            "timestamp" [dict get $data "timestamp"] \
-            "errors" [json::write array {*}$errors]]
-    }
-    set JsonState [json::write object {*}$JsonStateItems]
-
-    # try to preserve permissions and ownership
-    try {
-        set stateFileAttributes [file attributes $stateFile]
-    } trap {POSIX ENOENT} {} {
-        set stateFileAttributes {}
-    } trap {POSIX} {errorMsg errorOptions} {
-        ${Log}::error "failed to stat \"$stateFile\": $errorMsg"
-    }
-    # write the new state to a temporary file
-    set tmpFile "$stateFile.[pid].tmp"
-    try {
-        set f [open $tmpFile {RDWR CREAT EXCL TRUNC} 0600]
-    } trap {POSIX} {errorMsg errorOptions} {
-        ${Log}::error "failed to open \"$tmpFile\": $errorMsg"
-
-        exit 1
-    }
-    try {
-        chan puts -nonewline $f $JsonState
-    } trap {POSIX} {errorMsg errorOptions} {
-        catch {file delete $tmpFile}
-
-        ${Log}::error "failed to write to \"$tmpFile\": $errorMsg"
-
-        exit 1
-    } finally {
-        close $f
-    }
-    # make a backup of the previous state file
-    try {
-        file copy -force $stateFile "$stateFile~"
-    } trap {POSIX ENOENT} {} {
-        # ignore non-existing file
-    } trap {POSIX} {errorMsg errorOptions} {
-        ${Log}::error "failed to create a backup of \"$statFile\":\
-                $errorMsg"
-    }
-    # rename the temporary file to the state file name
-    try {
-        file rename -force $tmpFile $stateFile
-    } trap {POSIX} {errorMsg errorOptions} {
-        catch {file delete $tmpFile}
-
-        ${Log}::error "failed to rename \"$tmpFile\" to \"$stateFile\":\
-                $errorMsg"
-
-        exit 1
-    }
-    # restore ownership and permissions
-    try {
-        file attributes $stateFile {*}$stateFileAttributes
-    } trap {POSIX} {errorMsg errorOptions} {
-        ${Log}::error "failed to set permissions and ownership on\
-                \"$stateFile\": $errorMsg"
-
-        exit 1
-    }
-
-    # clean up
-    ${Log}::delete
-
-    exit $ExitStatus
-}
-
-
-namespace eval ::relmon::show {
-    # commandline option help text
-    variable usage "usage: relmon show statefile name..."
-}
-
-proc ::relmon::show::GetItem {stateName name} {
-    upvar 1 $stateName state
-    set item [dict get $state $name]
-
-    # format state data as plain-text
-    set output ""
-    append output "Name: $name\n"
-    append output "Latest Version:\
-            [lindex [lindex [dict get $item "history"] end] 0]\n"
-    append output "Refreshed: [clock format \
-            [expr {[dict get $item "timestamp"] / 1000}] \
-            -format {%Y-%m-%dT%H:%M:%S%z}]\n"
-    append output "Versions:\n"
-    dict for {version url} [dict get $item "versions"] {
-        append output "\t$version $url\n"
-    }
-    append output "Errors:\n"
-    if {[dict get $item "errors"] eq ""} {
-        append output "\tNone\n"
-    } else {
-        foreach errorMsg [dict get $item "errors"] {
-            append output "\t[string map {\n \n\t} [string trim $errorMsg]]\n"
-        }
-    }
-    append output "History:\n"
-    foreach historyItem [dict get $item "history"] {
-        append output "\t[lindex $historyItem 0] [clock format \
-                [expr {[lindex $historyItem 1] / 1000}] \
-                -format {%Y-%m-%dT%H:%M:%S%z}]\n"
-    }
-    return $output
-}
-
-proc ::relmon::show::main {args} {
-    variable usage
-
-    # parse commandline
-    if {[cmdline::getopt args {} OptArg OptVal] == -1} {
-        puts stderr "unknown command line option \"-$OptArg\""
-        puts stderr $usage
-        exit 1
-    }
-    if {[llength $args] < 2} {
-        puts stderr $usage
-        exit 1
-    }
-    set stateFile [lindex $args 0]
-    set names [lrange $args 1 end]
-
-    try {
-        set state [::relmon::common::parseStateFile $stateFile]
-    } trap {POSIX} {errorMsg} - trap {RELMON} {errorMsg} {
-        puts stderr $errorMsg
-        exit 1
-    }
-
-    # show each item
-    foreach name $names {
-        puts -nonewline [GetItem state $name]
-    }
-
-    exit 0
-}
-
-
-namespace eval ::relmon::list {
-    # commandline option help text
-    variable usage "usage: relmon list \[-H\] \[-f html|parseable|text\]\
-            \[-F url\]\n\
-            \                  \[-n number_items\] statefile\n\
-            \      relmon list -f atom -F url \[-n number_items\] statefile"
-
-    # configuration options
-    variable Config [dict create \
-            "format" "text" \
-            "show_history" 0 \
-            "history_limit" 100 \
-            "feed_url" ""]
-}
-
-proc ::relmon::list::FormatText {stateName includeHistory historyLimit} {
-    upvar 1 $stateName state
-    set output ""
-    append output [format "%-35s %-15s %-24s %-3s\n" "Project" "Version" \
-            "Refreshed" "St."]
-    append output [string repeat "-" 80]
-    append output "\n"
-
-    set history {}
-    dict for {name item} $state {
-        foreach historyItem [dict get $item "history"] {
-            lappend history [list [lindex $historyItem 1] $name \
-                    [lindex $historyItem 0]]
-        }
-        set latestVersion [lindex [lindex [dict get $item "history"] end] 0]
-        set timestamp [clock format [expr {[dict get $item "timestamp"] /
-                1000}] -format {%Y-%m-%dT%H:%M:%S%z}]
-        set status [expr {[llength [dict get $item "errors"]] > 0 ? "E" : ""}]
-        append output [format "%-35s %15s %-24s %-1s\n" $name $latestVersion \
-                $timestamp $status]
-    }
-    if {$includeHistory} {
-        append output "\nHistory\n"
-        append output [string repeat "-" 80]
-        append output "\n"
-        set history [lsort -decreasing -integer -index 0 $history]
-        foreach historyItem [lrange $history 0 $historyLimit] {
-            append output [format "%-24s %-35s %15s\n" \
-                    [clock format [expr {[lindex $historyItem 0] / 1000}] \
-                    -format {%Y-%m-%dT%H:%M:%S%z}] [lindex $historyItem 1] \
-                    [lindex $historyItem 2]]
-        }
-    }
-
-    return $output
-}
-
-proc ::relmon::list::FormatParseable {stateName includeHistory historyLimit} {
-    upvar 1 $stateName state
-    set output ""
-    set history {}
-    dict for {name item} $state {
-        foreach historyItem [dict get $item "history"] {
-            lappend history [list [lindex $historyItem 1] $name \
-                    [lindex $historyItem 0]]
-        }
-        set latestVersion [lindex [lindex [dict get $item "history"] end] 0]
-        if {$latestVersion eq ""} {
-            set latestVersion -
-        }
-        set timestamp [clock format [expr {[dict get $item "timestamp"] /
-                1000}] -timezone :UTC -format {%Y-%m-%dT%H:%M:%SZ}]
-        set status [expr {[llength [dict get $item "errors"]] > 0 ? "ERROR" :
-                "OK"}]
-        append output [format "%s\t%s\t%s\t%s\n" $name $latestVersion \
-                $timestamp $status]
-    }
-    if {$includeHistory} {
-        append output "\n"
-        set history [lsort -decreasing -integer -index 0 $history]
-        foreach historyItem [lrange $history 0 $historyLimit] {
-            append output [format "%s\t%s\t%s\n" [clock format \
-                    [expr {[lindex $historyItem 0] / 1000}] -timezone :UTC \
-                    -format {%Y-%m-%dT%H:%M:%SZ}] [lindex $historyItem 1] \
-                    [lindex $historyItem 2]]
-        }
-    }
-    return $output
-}
-
-proc ::relmon::list::FormatHtml {stateName includeHistory historyLimit
-        feedUrl} {
-    upvar 1 $stateName state
-
-    set output "<html>\n"
-    append output "<head>\n"
-    append output "<title>Current Releases</title>\n"
-    if {$feedUrl ne ""} {
-        append output "<link type=\"application/atom+xml\" rel=\"alternate\"\
-                title=\"Release History\"\
-                href=\"[html::html_entities $feedUrl]\"/>\n"
-    }
-    append output "</head>\n"
-    append output "<body>\n"
-    append output "<h1>Current Releases</h1>\n<table>\n<tr>\n<th>Project</th>\
-            \n<th>Version</th>\n<th>Refreshed</th>\n<th>Status</th>\n</tr>\n"
-    set history {}
-    dict for {name item} $state {
-        foreach historyItem [dict get $item "history"] {
-            lappend history [list [lindex $historyItem 1] $name \
-                    [lindex $historyItem 0]]
-        }
-        set latestVersion [lindex [lindex [dict get $item "history"] end] 0]
-        set timestamp [clock format [expr {[dict get $item "timestamp"] /
-                1000}] -format {%Y-%m-%dT%H:%M:%S%z}]
-        set status [expr {[llength [dict get $item "errors"]] > 0 ? "Error" :
-                "OK"}]
-
-        append output "<tr>\n<td>[html::html_entities $name]</td>\n"
-        if {$latestVersion ne ""} {
-            if {[dict exists $item "versions" $latestVersion]} {
-                set url [dict get $item "versions" $latestVersion]
-                append output "<td><a\
-                        href=\"[html::html_entities $url]\"\
-                        title=\"[html::html_entities\
-                        "$name $latestVersion"]\">[html::html_entities \
-                        $latestVersion]</a></td>\n"
-            } else {
-                append output "<td>[html::html_entities \
-                        $latestVersion]</td>\n"
-            }
-        } else {
-            append output "<td></td>\n"
-        }
-        append output "<td>$timestamp</td>\n"
-        append output "<td>[html::html_entities $status]</td>\n</tr>\n"
-    }
-    append output "</table>\n"
-
-    if {$includeHistory} {
-        set history [lsort -decreasing -integer -index 0 $history]
-        append output "<h1>Release History</h1>\n<table>\n"
-        append output "<tr><th>Time</th><th>Project</th><th>Version</th></tr>\n"
-        foreach historyItem [lrange $history 0 $historyLimit] {
-            set timestamp [clock format [expr {[lindex $historyItem 0] /
-                    1000}] -format {%Y-%m-%dT%H:%M:%S%z}]
-            set name [lindex $historyItem 1]
-            set version [lindex $historyItem 2]
-            append output "<tr>\n<td>$timestamp</td>\n"
-            append output "<td>[html::html_entities $name]</td>\n"
-            append output "<td>[html::html_entities $version]</td></tr>\n"
-        }
-        append output "</table>\n"
-    }
-
-    append output "</body>\n</html>\n"
-
-    return $output
-}
-
-proc ::relmon::list::FormatAtom {stateName historyLimit feedUrl} {
-    upvar 1 $stateName state
-    set host [::relmon::common::urlGetHost $feedUrl]
-    set output "<?xml version=\"1.0\" encoding=\"utf-8\"?>\
-            \n<feed xmlns=\"http://www.w3.org/2005/Atom\">\n"
-    append output "<author><name>relmon</name></author>\n"
-    append output "<title>Release History</title>\n"
-    append output "<id>[html::html_entities $feedUrl]</id>\n"
-    set history {}
-    dict for {name item} $state {
-        foreach historyItem [dict get $item "history"] {
-            lappend history [list [lindex $historyItem 1] $name \
-                    [lindex $historyItem 0]]
-        }
-    }
-    set history [lsort -decreasing -integer -index 0 $history]
-    set updated [lindex [lindex $history end] 0]
-    if {$updated eq ""} {
-        set updated [clock seconds]
-    }
-    append output "<updated>[clock format $updated \
-            -format {%Y-%m-%dT%H:%M:%S%z}]</updated>\n"
-    foreach historyItem [lrange $history 0 $historyLimit] {
-        set name [lindex $historyItem 1]
-        set version [lindex $historyItem 2]
-        set timestamp [clock format [expr {[lindex $historyItem 0] / 1000}] \
-                -format {%Y-%m-%dT%H:%M:%S%z}]
-        set id "tag:$host,[clock format [lindex $historyItem 0] \
-                -format {%Y-%m-%d}]:[uri::urn::quote $name-$version]"
-        append output "<entry>\n"
-        append output "<id>[html::html_entities $id]</id>\n"
-        append output "<updated>$timestamp</updated>\n"
-        append output "<title>[html::html_entities "$name $version"]</title>"
-        append output "<content>[html::html_entities \
-                "$name $version"]</content>\n"
-        append output "</entry>\n"
-    }
-    append output "</feed>\n"
-    return $output
-}
-
-proc ::relmon::list::main {args} {
-    variable usage
-    variable Config
-
-    # parse commandline
-    while {[set GetoptRet [cmdline::getopt args {f.arg F.arg H n.arg} OptArg \
-            OptVal]] == 1} {
-        switch -glob -- $OptArg {
-            {f} {
-                if {$OptVal ni {atom html parseable text}} {
-                    puts stderr "invalid value passed to \"-$OptArg\""
-                    exit 1
-                }
-                dict set Config "format" $OptVal
-            }
-            {F} {
-                if {[catch {dict create {*}[uri::split $OptVal]} UrlParts] ||
-                        ([dict get $UrlParts "host"] eq "")} {
-                    puts stderr "invalid value passed to \"-$OptArg\""
-                    exit 1
-                }
-                dict set Config "feed_url" $OptVal
-            }
-            {H} {
-                dict set Config "show_history" 1
-            }
-            {n} {
-                if {![string is digit -strict $OptVal] || ($OptVal <= 0)} {
-                    puts stderr "invalid value passed to \"-$OptArg\""
-                    exit 1
-                }
-                dict set Config "history_limit" [expr {$OptVal - 1}]
-            }
-        }
-    }
-    set argc [llength $args]
-    if {$GetoptRet == -1} {
-        puts stderr "unknown command line option \"-$OptArg\""
-        puts stderr $usage
-        exit 1
-    }
-    if {$argc != 1} {
-        puts stderr $usage
-        exit 1
-    }
-    if {([dict get $Config "format"] eq "atom") &&
-            ([dict get $Config "feed_url"] eq "")} {
-        puts stderr "mandatory \"-F\" option is missing"
-        puts stderr $usage
-        exit 1
-    }
-    set StateFile [lindex $args 0]
-
-    # read the state file
-    try {
-        set State [::relmon::common::parseStateFile $StateFile]
-    } trap {POSIX} {errorMsg} - trap {RELMON} {errorMsg} {
-        puts stderr $errorMsg
-        exit 1
-    }
-
-    # call formatter
-    switch -- [dict get $Config "format"] {
-        {atom} {
-            puts -nonewline [FormatAtom State \
-                    [dict get $Config "history_limit"] \
-                    [dict get $Config "feed_url"]]
-        }
-        {html} {
-            puts -nonewline [FormatHtml State \
-                    [dict get $Config "show_history"] \
-                    [dict get $Config "history_limit"] \
-                    [dict get $Config "feed_url"]]
-        }
-        {parseable} {
-            puts -nonewline [FormatParseable State \
-                    [dict get $Config "show_history"] \
-                    [dict get $Config "history_limit"]]
-        }
-        {default} {
-            puts -nonewline [FormatText State \
-                    [dict get $Config "show_history"] \
-                    [dict get $Config "history_limit"]]
-        }
-    }
-
-    exit 0
-}
-
-
-namespace eval ::relmon::help {
-    # commandline option help text
-    variable usage "usage: relmon help \[subcommand\]"
-}
-
-proc ::relmon::help::main {args} {
-    variable usage
-
-    # parse commandline
-    if {[cmdline::getopt args {} OptArg OptVal] == -1} {
-        puts stderr "unknown command line option \"-$OptArg\""
-        puts stderr $usage
-        exit 1
-    }
-    set argc [llength $args]
-    if {$argc > 1} {
-        puts stderr $usage
-        exit 1
-    }
-    set subCommand [lindex $args 0]
-    if {$subCommand ne ""} {
-        if {[info procs ::relmon::${subCommand}::main] ne ""} {
-            puts stderr [set ::relmon::${subCommand}::usage]
-        } else {
-            puts stderr "unknown subcommand \"$subCommand\""
-            puts stderr $usage
-            exit 1
-        }
-    } else {
-        foreach subCommandNs [namespace children ::relmon] {
-            if {[info procs ${subCommandNs}::main] ne ""} {
-                puts stderr [set ${subCommandNs}::usage]
-            }
-        }
-    }
-    exit 0
-}
-
-
-proc ::relmon::main {args} {
-    variable usage
-    set subArgs [lassign $args subCommand]
-
-    # generate list of subcommands
-    set subCommands {}
-    foreach subCommandNs [namespace children ::relmon] {
-        if {[info procs ${subCommandNs}::main] ne ""} {
-            lappend subCommands [namespace tail $subCommandNs]
-        }
-    }
-    if {$subCommand ni $subCommands} {
-        if {$subCommand ne ""} {
-            puts stderr "unknown subcommand \"$subCommand\""
-        }
-        foreach command $subCommands {
-            puts stderr [set relmon::${command}::usage]
-        }
-        exit 1
-    }
-
-    # dispatch subcommand
-    relmon::${subCommand}::main {*}$subArgs
-}
-
-
-relmon::main {*}$argv
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/relmon.tcl.in	Mon Oct 20 19:31:20 2014 +0200
@@ -0,0 +1,1706 @@
+#!/usr/bin/tclsh
+#
+# Copyright (C) 2014 Guido Berhoerster <guido+relmon@berhoerster.name>
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be included
+# in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+package require Tcl 8.5
+package require http
+package require tls
+package require tdom
+package require try
+package require cmdline
+package require control
+package require html
+package require htmlparse
+package require json
+package require json::write
+package require logger
+package require logger::utils
+package require textutil::split
+package require uri
+package require uri::urn
+
+
+namespace eval ::relmon {
+    # version
+    variable VERSION @VERSION@
+}
+
+
+namespace eval ::relmon::common {
+    namespace export cmpVersions isUrlValid urlGetHost normalizeHttpHeaders \
+            parseStateFile
+}
+
+# implementation of the Debian version comparison algorithm described at
+# http://www.debian.org/doc/debian-policy/ch-controlfields.html#s-f-Version
+proc ::relmon::common::cmpVersion {v1 v2} {
+    set v1Len [string length $v1]
+    set v2Len [string length $v2]
+    set v1Pos 0
+    set v2Pos 0
+    while {($v1Pos < $v1Len) || ($v2Pos < $v2Len)} {
+        set firstNumDiff 0
+        # until reaching ASCII digits in both version strings compare character
+        # values which are modified as so they are sorted in the following
+        # order:
+        # - "~"
+        # - missing character or ASCII digits
+        # - ASCII alphabet
+        # - everything else in the order of their unicode value
+        while {(($v1Pos < $v1Len) &&
+                ![string match {[0123456789]} [string index $v1 $v1Pos]]) ||
+                (($v2Pos < $v2Len) &&
+                ![string match {[0123456789]} [string index $v2 $v2Pos]])} {
+            foreach char [list [string index $v1 $v1Pos] \
+                    [string index $v2 $v2Pos]] charValueName \
+                    {v1CharValue v2CharValue} {
+                if {$char eq "~"} {
+                    set $charValueName -1
+                } elseif {$char eq ""} {
+                    set $charValueName 0
+                } elseif {[string match {[0123456789]} $char]} {
+                    set $charValueName 0
+                } elseif {[string match -nocase {[abcdefghijklmnopqrstuvwxyz]} \
+                        $char]} {
+                    set $charValueName [scan $char "%c"]
+                } else {
+                    set $charValueName [expr {[scan $char "%c"] + 0x7f + 1}]
+                }
+            }
+            if {$v1CharValue != $v2CharValue} {
+                return [expr {$v1CharValue - $v2CharValue}]
+            }
+            incr v1Pos
+            incr v2Pos
+        }
+
+        # strip leading zeros
+        while {[string index $v1 $v1Pos] eq "0"} {
+            incr v1Pos
+        }
+        while {[string index $v2 $v2Pos] eq "0"} {
+            incr v2Pos
+        }
+
+        # process digits until reaching a non-digit
+        while {[string match {[0123456789]} [string index $v1 $v1Pos]] &&
+                [string match {[0123456789]} [string index $v2 $v2Pos]]} {
+            # record the first difference between the two numbers
+            if {$firstNumDiff == 0} {
+                set firstNumDiff [expr {[string index $v1 $v1Pos] -
+                        [string index $v2 $v2Pos]}]
+            }
+            incr v1Pos
+            incr v2Pos
+        }
+
+        # return if the number of one version has more digits than the other
+        # since the one with more digits is the larger number
+        if {[string match {[0123456789]} [string index $v1 $v1Pos]]} {
+            return 1
+        } elseif {[string match {[0123456789]} [string index $v2 $v2Pos]]} {
+            return -1
+        }
+
+        # return the difference if the digits differed above
+        if {$firstNumDiff != 0} {
+            return $firstNumDiff
+        }
+    }
+
+    return 0
+}
+
+proc ::relmon::common::isUrlValid {url} {
+    return [expr {![catch {dict create {*}[uri::split $url]} urlParts] &&
+            ([dict get $urlParts "scheme"] in {"http" "https"}) &&
+            ([dict get $urlParts "host"] ne "")}]
+}
+
+proc ::relmon::common::urlGetHost {url} {
+    return [expr {(![catch {dict create {*}[uri::split $url]} urlParts]) ?
+                [dict get $urlParts "host"] : ""}]
+}
+
+proc ::relmon::common::normalizeHttpHeaders {headers} {
+    set httpHeaders [dict create]
+    foreach {header value} $headers {
+        set words {}
+        foreach word [split $header "-"] {
+            lappend words [string totitle $word]
+        }
+        dict set httpHeaders [join $words "-"] $value
+    }
+    return $httpHeaders
+}
+
+proc ::relmon::common::parseStateFile {stateFile} {
+    try {
+        set f [open $stateFile "r"]
+    } trap {POSIX} {errorMsg errorOptions} {
+        return -options $errorOptions \
+                "failed to open state file \"$stateFile\": $errorMsg"
+    }
+    try {
+        set state [json::json2dict [chan read $f]]
+    } trap {POSIX} {errorMsg errorOptions} {
+        return -options $errorOptions \
+                "failed to read from state file \"$stateFile\": $errorMsg"
+    } on error {errorMsg errorOptions} {
+        # the json package does not set an error code
+        dict set errorOptions "-errorcode" {RELMON JSON_PARSE_ERROR}
+        return -options $errorOptions \
+                "failed to parse state file \"$stateFile\": $errorMsg"
+    } finally {
+        close $f
+    }
+
+    return $state
+}
+
+
+namespace eval ::relmon::update {
+    # commandline option help text
+    variable usage "usage: relmon update \[-dev\] \[-c max_connections\] \[-C\
+            ca_dir\] \[-D delay\]\n\
+            \                    \[-H max_host_connections\] \[-i\
+            item\[,...\]\] \[-l logfile\]\n\
+            \                    \[-r retries\] \[-t min_time\] watchlist\
+            statefile"
+
+    # configuration options
+    variable Config [dict create \
+            "log_file" "" \
+            "log_level" "notice" \
+            "history_limit" 20 \
+            "connection_limit" 16 \
+            "host_connection_limit" 4 \
+            "transfer_time_limit" 60000 \
+            "retry_limit" 3 \
+            "host_delay" 0 \
+            "timestamp_filter" 0 \
+            "error_filter" 0 \
+            "item_filter" {} \
+            "ca_dir" "" \
+            "state_file" "" \
+            "watchlist_file" ""]
+
+    # exit status
+    variable ExitStatus
+
+    # transfer statistics
+    variable Statistics [dict create \
+            "start_time" 0 \
+            "end_time" 0 \
+            "requests" 0 \
+            "items" 0]
+
+    # watchlist
+    variable Watchlist
+
+    # ID of a delayed run of ManageTransfers
+    variable ManageTransfersId ""
+
+    # queue of pending transfers
+    variable Queue
+
+    # number of active connections per host
+    variable HostConnections
+
+    # delays before opening a new connection to a host
+    variable HostDelays
+
+    # active transfers
+    variable ActiveTransfers
+
+    # buffer for tracking the state of unfinished items
+    variable StateBuffer
+
+    # buffer needed by htmlparse::parse for constructing the preprocessed HTML
+    # document
+    variable PreprocessedHtmlBuffer
+
+    # logger handle
+    variable Log
+
+    # logfile handle
+    variable Lf
+}
+
+proc ::relmon::update::OnError {message returnOptions} {
+    # internal error, abort
+    puts stderr [dict get $returnOptions "-errorinfo"]
+
+    exit 1
+}
+
+proc ::relmon::update::CleanupBeforeExit {commandString operation} {
+    variable Lf
+
+    # close logfile
+    if {($Lf ne "") && ($Lf ni {stdin stderr})} {
+        close $Lf
+        set Lf ""
+    }
+
+    return
+}
+
+proc ::relmon::update::ParseWatchlist {watchlistFilename} {
+    variable Watchlist
+
+    set lineno 0
+    set f [open $watchlistFilename "r"]
+    try {
+        while {[chan gets $f line] != -1} {
+            set fields [textutil::split::splitx [string trim $line] {[\t ]+}]
+            incr lineno
+
+            if {([llength $fields] == 0) ||
+                    ([string index [lindex $fields 0] 0] eq "#")} {
+                # skip empty lines and comments
+                continue
+            } elseif {[llength $fields] < 3} {
+                # a line consists of a name, base URL and at least one
+                # version-matching pattern
+                return -code error -errorcode {RELMON WATCHLIST_PARSE_ERRROR} \
+                        "syntax error in \"$watchlistFilename\" line $lineno"
+            }
+
+            set patterns [lassign $fields name baseUrl]
+
+            # validate URL
+            if {![::relmon::common::isUrlValid $baseUrl]} {
+                return -code error -errorcode {RELMON WATCHLIST_PARSE_ERRROR} \
+                        "syntax error in \"$watchlistFilename\" line $lineno:\
+                        invalid base URL"
+            }
+
+            # process patterns
+            set processedPatterns {}
+            set patternIndex 0
+            foreach pattern $patterns {
+                incr patternIndex
+
+                # make trailing slashes optional except in the last
+                # version-matching pattern
+                if {($patternIndex != [llength $patterns]) &&
+                        ([string index $pattern end] eq "/")} {
+                    append pattern {?}
+                }
+
+                # ensure patterns are anchored to the end of the line
+                if {[string index $pattern end] ne "$"} {
+                    append pattern {$}
+                }
+
+                # actually validate the regular expression
+                try {
+                    set reInfo [regexp -about -- $pattern ""]
+                } on error {errorMsg} {
+                    return -code error \
+                            -errorcode {RELMON WATCHLIST_PARSE_ERRROR} \
+                            "error in \"$watchlistFilename\" line $lineno:\
+                            $errorMsg"
+                }
+                lappend processedPatterns $pattern
+            }
+            if {[lindex $reInfo 0] < 1} {
+                return -code error -errorcode {RELMON WATCHLIST_PARSE_ERRROR} \
+                        "syntax error in \"$watchlistFilename\" line $lineno:\
+                        the last regular expression must contain at least one
+                        capturing group"
+            }
+
+            dict set Watchlist $name "base_url" $baseUrl
+            dict set Watchlist $name "patterns" $processedPatterns
+        }
+    } finally {
+        close $f
+    }
+
+    return
+}
+
+proc ::relmon::update::ProcessHtmlElement {tag slash param textBehindTheTag} {
+    variable PreprocessedHtmlBuffer
+
+    # copy every "<a>" element into PreprocessedHtmlBuffer
+    if {($slash eq "") && ([string tolower $tag] eq "a")} {
+        append PreprocessedHtmlBuffer "<$tag $param></$tag>"
+    }
+
+    return
+}
+
+proc ::relmon::update::PreprocessHtml {bodyDataName} {
+    upvar 1 $bodyDataName bodyData
+    variable PreprocessedHtmlBuffer
+
+    # preprocess the document with htmlparse by constructing a new document
+    # consisting only of found "<a>" elements which then can be fed into tdom
+    # again; this is useful if parsing via tdom fails; however, htmlparse
+    # should only be used as a last resort because it is just too limited, it
+    # gets easily confused within "<script>" elements and lacks attribute
+    # parsing
+    set PreprocessedHtmlBuffer "<html><body>"
+    htmlparse::parse -cmd [namespace code ProcessHtmlElement] $bodyData
+    append PreprocessedHtmlBuffer "</body></html>"
+}
+
+proc ::relmon::update::ExtractUrls {bodyDataName contentType baseUrl
+        rePattern} {
+    upvar 1 $bodyDataName bodyData
+    set extractedUrls {}
+    set resultUrls [dict create]
+    # extract all URLs or URL fragments
+    switch -- $contentType {
+        {text/html} -
+        {application/xhtml+xml} {
+            # HTML/XHTML
+            # if tdom parsing has failed or not found any "<a>" element,
+            # preprocess the document with htmlparse and try again
+            if {[catch {dom parse -html $bodyData} doc] ||
+                    ([set rootElement [$doc documentElement]] eq "") ||
+                    ([llength [set aElements \
+                    [$rootElement selectNodes {descendant::a}]]] == 0)} {
+                try {
+                    set doc [dom parse -html [PreprocessHtml bodyData]]
+                } on error {errorMsg errorOptions} {
+                    dict set errorOptions "-errorcode" \
+                            {RELMON TDOM_PARSE_ERROR}
+                    return -options $errorOptions $errorMsg
+                }
+                set rootElement [$doc documentElement]
+                set aElements [$rootElement selectNodes {descendant::a}]
+            }
+            foreach node $aElements {
+                set href [$node getAttribute "href" ""]
+                if {$href ne ""} {
+                    lappend extractedUrls $href
+                }
+            }
+            $doc delete
+        }
+        {application/rss+xml} {
+            # RSS 2.0
+            try {
+                set doc [dom parse $bodyData]
+            } on error {errorMsg errorOptions} {
+                dict set errorOptions "-errorcode" {RELMON TDOM_PARSE_ERROR}
+                return -options $errorOptions $errorMsg
+            }
+            set rootElement [$doc documentElement]
+            if {$rootElement ne ""} {
+                foreach node [$rootElement selectNodes {descendant::link}] {
+                    set linkText [$node text]
+                    if {$linkText ne ""} {
+                        lappend extractedUrls $linkText
+                    }
+                }
+            }
+            $doc delete
+        }
+        {application/atom+xml} {
+            # Atom 1.0
+            try {
+                set doc [dom parse $bodyData]
+            } on error {errorMsg errorOptions} {
+                dict set errorOptions "-errorcode" {RELMON TDOM_PARSE_ERROR}
+                return -options $errorOptions $errorMsg
+            }
+            set rootElement [$doc documentElement]
+            if {$rootElement ne ""} {
+                foreach node [$rootElement selectNodes {descendant::link}] {
+                    set href [$node getAttribute "href" ""]
+                    if {$href ne ""} {
+                        lappend extractedUrls $href
+                    }
+                }
+            }
+            $doc delete
+        }
+        {text/plain} {
+            # plain text
+            foreach line [split $bodyData "\n"] {
+                if {$line ne ""} {
+                    lappend extractedUrls $line
+                }
+            }
+        }
+        default {
+            return -code error \
+                    -errorcode {RELMON UNSUPPORTED_CONTENT_TYPE_ERROR} \
+                    "unsupported content type \"$contentType\""
+        }
+    }
+    foreach url $extractedUrls {
+        set normalizedUrl [uri::canonicalize [uri::resolve $baseUrl $url]]
+        dict set resultUrls $normalizedUrl \
+                [expr {[regexp -line -- $rePattern $normalizedUrl] ? 1 : 0}]
+    }
+
+    return $resultUrls
+}
+
+proc ::relmon::update::StateItemAppendError {name logMsg} {
+    variable StateBuffer
+
+    dict update StateBuffer $name stateItem {
+        dict lappend stateItem "errors" $logMsg
+    }
+
+    return
+}
+
+proc ::relmon::update::HandleSuccessfulTransfer {item httpBodyName} {
+    upvar 1 $httpBodyName httpBody
+    variable Log
+    variable StateBuffer
+    variable Queue
+    variable Watchlist
+
+    set name [dict get $item "name"]
+    set url [dict get $item "url"]
+    set patternIndex [dict get $item "pattern_index"]
+    set pattern [lindex [dict get $Watchlist $name "patterns"] $patternIndex]
+
+    ${Log}::info "\"$name\": \"$url\": transfer finished"
+
+    # parse data
+    try {
+        set urls [ExtractUrls httpBody [dict get $item "content_type"] $url \
+                $pattern]
+    } trap {RELMON} {errorMsg} {
+        # continue on tdom parsing errors or when receiving documents with an
+        # unsupported content type
+        set urls [dict create]
+        set warningMsg "\"$name\": \"$url\": failed to parse data: $errorMsg"
+        ${Log}::warn $warningMsg
+        StateItemAppendError $name $warningMsg
+    }
+
+    if {$patternIndex < ([llength \
+            [dict get $Watchlist $name "patterns"]] - 1)} {
+        # if this is not the last, version-matching pattern, queue matched URLs
+        dict for {newUrl matched} $urls {
+            if {$matched} {
+                if {![::relmon::common::isUrlValid $newUrl]} {
+                    ${Log}::debug "\"$name\": \"$url\": ignoring matched but\
+                            invalid URL \"$newUrl\""
+                    continue
+                }
+
+                ${Log}::debug "\"$name\": \"$url\": queuing \"$newUrl\""
+
+                dict lappend Queue [::relmon::common::urlGetHost $newUrl] \
+                        [dict create "name" $name "url" $newUrl \
+                        "pattern_index" [expr {$patternIndex + 1}] \
+                        "content_type" "" "num_redirects" 0 "num_retries" 0]
+            } else {
+                ${Log}::debug "\"$name\": \"$url\": ignoring \"$newUrl\""
+            }
+        }
+    } else {
+        # otherwise this branch has finished, try to extract the versions and
+        # store them in the buffer
+        dict for {finalUrl matched} $urls {
+            if {$matched} {
+                regexp -line -- $pattern $finalUrl -> version
+                if {$version ne ""} {
+                    ${Log}::debug "\"$name\": \"$url\": extracted version\
+                            \"$version\" from \"$finalUrl\" found on\
+                            \"$url\""
+                    dict set StateBuffer $name "versions" $version $finalUrl
+                } else {
+                    ${og}::debug "\"$name\": \"$url\": could not extract a\
+                            version from \"$finalUrl\""
+                }
+            } else {
+                ${Log}::debug "\"$name\": \"$url\": ignoring \"$finalUrl\""
+            }
+        }
+    }
+
+    return
+}
+
+proc ::relmon::update::HandleRedirect {item httpCode httpHeaders} {
+    variable Log
+    variable Queue
+
+    set name [dict get $item "name"]
+    set url [dict get $item "url"]
+
+    if {![dict exists $httpHeaders "Location"]} {
+        # bail out in case of an invalid HTTP response
+        set warningMsg "\"$name\": \"$url\": transfer failed: invalid HTTP\
+                response"
+        ${Log}::warn $warningMsg
+        StateItemAppendError $name $warningMsg
+        return
+    }
+    set location [dict get $httpHeaders "Location"]
+
+    # sanitize URL from Location header
+    if {[uri::isrelative $location]} {
+        set redirectUrl [uri::canonicalize [uri::resolve \
+                $url $location]]
+    } else {
+        if {![::relmon::common::isUrlValid $location]} {
+            # bail out in case of an invalid redirect URL
+            set warningMsg "\"$name\": \"$url\": received invalid redirect URL\
+                    \"$location\""
+            ${Log}::warn $warningMsg
+            StateItemAppendError $name $warningMsg
+            return
+        }
+        set redirectUrl [uri::canonicalize $location]
+    }
+
+    ${Log}::notice "\"$name\": \"$url\": received redirect to \"$redirectUrl\""
+
+    # handle up to 10 redirects by re-queuing the target URL
+    if {[dict get $item "num_redirects"] < 10} {
+        ${Log}::debug "\"$name\": \"$url\": queuing \"$redirectUrl\" due to\
+                redirect"
+
+        dict lappend Queue [::relmon::common::urlGetHost $redirectUrl] \
+                [dict replace $item "url" $redirectUrl "content_type" "" \
+                "num_redirects" [expr {[dict get $item "num_redirects"] + 1}] \
+                "num_retries" 0]
+    } else {
+        set warningMsg "\"$name\": \"$url\": exceeded maximum number of\
+                redirects"
+        ${Log}::warn $warningMsg
+        StateItemAppendError $name $warningMsg
+    }
+
+    return
+}
+
+proc ::relmon::update::HandleProtocolError {item httpCode} {
+    variable Log
+    set name [dict get $item "name"]
+    set url [dict get $item "url"]
+    set warningMsg "\"$name\": \"$url\": transfer failed: $httpCode"
+    ${Log}::warn $warningMsg
+    StateItemAppendError $name $warningMsg
+    return
+}
+
+proc ::relmon::update::HandleTimeoutReset {item} {
+    variable Log
+    variable Config
+    variable Queue
+    set name [dict get $item "name"]
+    set url [dict get $item "url"]
+
+    # retry by re-queuing the target URL until reaching the limit
+    if {[dict get $item "num_retries"] < [dict get $Config "retry_limit"]} {
+        ${Log}::warn "\"$name\": \"$url\": connection timed out or was reset,\
+                retrying"
+        dict lappend Queue [::relmon::common::urlGetHost $url] \
+                [dict replace $item \
+                "num_retries" [expr {[dict get $item "num_retries"] + 1}]]
+    } else {
+        set warningMsg "\"$name\": \"$url\": exceeded maximum number of\
+                retries"
+        ${Log}::warn $warningMsg
+        StateItemAppendError $name $warningMsg
+    }
+
+    return
+}
+
+proc ::relmon::update::HandleConnectionError {item errorMsg} {
+    variable Log
+    set name [dict get $item "name"]
+    set url [dict get $item "url"]
+    set warningMsg "\"$name\": \"$url\": transfer failed: $errorMsg"
+    ${Log}::warn $warningMsg
+    StateItemAppendError $name $warningMsg
+    return
+}
+
+proc ::relmon::update::TransferCallbackWrapper {callbackCmd args} {
+    # ensure that exceptions get raised, by default http catches all errors and
+    # silently ignores them, see https://core.tcl.tk/tcl/tktview?name=1414262
+    if {[catch {eval $callbackCmd $args} -> errorOptions]} {
+        OnError [dict get $errorOptions "-errorinfo"] $errorOptions
+    }
+    return
+}
+
+proc ::relmon::update::ManageTransfers {} {
+    variable Config
+    variable ManageTransfersId
+    variable Queue
+    variable HostConnections
+    variable HostDelays
+    variable ActiveTransfers
+    variable ExitStatus
+    variable Log
+
+    after cancel $ManageTransfersId
+
+    # try to initiate new transfers
+    while {([dict size $ActiveTransfers] <
+            [dict get $Config "connection_limit"]) &&
+            ([dict size $Queue] > 0)} {
+        # find URLs in the queue with a host for which we have not reached the
+        # per-host connection limit yet and for which no delay is in effect
+        set item {}
+        dict for {host items} $Queue {
+            set now [clock milliseconds]
+
+            if {![dict exists $HostConnections $host]} {
+                dict set HostConnections $host 0
+            }
+
+            if {![dict exists $HostDelays $host]} {
+                dict set HostDelays $host $now
+            }
+
+            if {([dict get $HostConnections $host] <
+                    [dict get $Config "host_connection_limit"]) &&
+                    ([dict get $HostDelays $host] <= $now)} {
+                # pop item from the queue
+                set items [lassign $items item]
+                if {[llength $items] > 0} {
+                    dict set Queue $host $items
+                } else {
+                    dict unset Queue $host
+                }
+
+                dict incr HostConnections $host
+                # set a random delay before the next connection to this host
+                # can be made
+                dict set HostDelays $host \
+                        [expr {[clock milliseconds] + int((rand() + 0.5) *
+                        [dict get $Config "host_delay"])}]
+                break
+            }
+        }
+        # if no item could be found, the per-host connection limit for all
+        # queued URLs has been reached and no new transfers may be started
+        # at this point
+        if {$item eq {}} {
+            break
+        }
+        # otherwise start a new transfer
+        set url [dict get $item "url"]
+        set name [dict get $item "name"]
+        try {
+            set token [http::geturl $url \
+                    -timeout [dict get $Config "transfer_time_limit"] \
+                    -progress [namespace code {TransferCallbackWrapper \
+                    OnTransferProgress}] \
+                    -command [namespace code {TransferCallbackWrapper \
+                    OnTransferFinished}]]
+        } on ok {} {
+            dict set ActiveTransfers $token $item
+
+            ${Log}::info "\"$name\": \"$url\": starting transfer"
+        } on error {errorMsg} {
+            # an error occured during socket setup, e.g. a DNS lookup failure
+            set warningMsg "\"$name\": \"$url\": transfer failed: $errorMsg"
+            ${Log}::warn $warningMsg
+            StateItemAppendError $name $warningMsg
+        }
+    }
+
+    # terminate the event loop if there are no remaining transfers
+    if {([dict size $ActiveTransfers]) == 0 && ([dict size $Queue] == 0)} {
+        set ExitStatus 0
+        return
+    }
+
+    # due to per-host connection limits and per-host delays the maximum number
+    # of connections may not be reached although there are still items in the
+    # queue, in this case schedule ManageTransfers again after the smallest of
+    # the current per-host delays
+    set delay 0
+    if {([dict size $ActiveTransfers] <
+            [dict get $Config "connection_limit"]) &&
+            ([dict size $Queue] > 0)} {
+        dict for {host items} $Queue {
+            if {(![dict exists $HostConnections $host] ||
+                    ([dict get $HostConnections $host] <
+                    [dict get $Config "host_connection_limit"])) &&
+                    ([dict exists $HostDelays $host] &&
+                    ([dict get $HostDelays $host] > $now))} {
+                set hostDelay [expr {[dict get $HostDelays $host] - $now + 1}]
+                if {(($delay == 0) ||
+                        ($hostDelay < $delay))} {
+                    set delay $hostDelay
+                }
+            }
+        }
+        if {$delay > 0} {
+            set ManageTransfersId \
+                    [after $delay [namespace code ManageTransfers]]
+        }
+    }
+
+    return
+}
+
+proc ::relmon::update::OnTransferProgress {token total current} {
+    upvar #0 $token httpState
+    variable ActiveTransfers
+    variable Log
+
+    # try to determine content type and abort transfer if content type is not
+    # one that can be parsed, this is primarily to prevent accidental downloads
+    if {[dict get $ActiveTransfers $token "content_type"] eq ""} {
+        set httpHeaders [relmon::common::normalizeHttpHeaders \
+                $httpState(meta)]
+
+        if {[dict exists $httpHeaders "Content-Type"]} {
+            set contentType [string trim [lindex [split \
+                    [dict get $httpHeaders "Content-Type"] ";"] 0]]
+            dict set ActiveTransfers $token "content_type" $contentType
+            if {$contentType ni {"text/html" "application/xhtml+xml"
+                    "application/atom+xml" "application/rss+xml"
+                    "text/plain"}} {
+                ${Log}::warn "\"[dict get $ActiveTransfers $token "name"]\":\
+                        \"[dict get $ActiveTransfers $token "url"]\": content\
+                        type \"$contentType\" is not acceptable"
+                http::reset $token
+            }
+        }
+    }
+}
+
+proc ::relmon::update::OnTransferFinished {token} {
+    upvar #0 $token httpState
+    variable Config
+    variable HostConnections
+    variable Queue
+    variable ActiveTransfers
+    variable Statistics
+    variable StateBuffer
+    variable State
+    variable Log
+
+    set item [dict get $ActiveTransfers $token]
+    set name [dict get $item "name"]
+    set host [relmon::common::urlGetHost [dict get $item "url"]]
+
+    # update list of per-host connections, and number of remaining transfers
+    # for this item
+    dict unset ActiveTransfers $token
+    dict incr HostConnections $host -1
+
+    switch -- $httpState(status) {
+        {ok} {
+            # normalize headers
+            set httpHeaders [relmon::common::normalizeHttpHeaders \
+                    $httpState(meta)]
+
+            # try to determine content type
+            if {([dict get $item "content_type"] eq "") &&
+                    [dict exists $httpHeaders "Content-Type"]} {
+                dict set item "content_type" [string trim [lindex [split \
+                        [dict get $httpHeaders "Content-Type"] ";"] 0]]
+            }
+
+            # dispatch based on HTTP status code
+            set httpCode [http::ncode $token]
+            switch -glob -- $httpCode {
+                {30[12378]} {
+                    HandleRedirect $item $httpCode $httpHeaders
+                }
+                {200} {
+                    HandleSuccessfulTransfer $item httpState(body)
+                }
+                default {
+                    HandleProtocolError $item $httpState(http)
+                }
+            }
+        }
+        {reset} {
+            # aborted due to wrong content type
+        }
+        {eof} -
+        {timeout} {
+            # timeout or connection reset
+            HandleTimeoutReset $item
+        }
+        {error} {
+            # connection may have failed or been refused
+            HandleConnectionError $item [lindex $httpState(error) 0]
+        }
+    }
+
+    # check if all transfers of this item are finished
+    set itemFinished 1
+    dict for {queueHost queueItems} $Queue {
+        foreach queueItem $queueItems {
+            if {[dict get $queueItem "name"] eq $name} {
+                set itemFinished 0
+            }
+        }
+    }
+    dict for {activeToken activeItem} $ActiveTransfers {
+        if {[dict get $activeItem "name"] eq $name} {
+            set itemFinished 0
+        }
+    }
+    if {$itemFinished} {
+        set timestamp [clock milliseconds]
+
+        # create httpState item if it does not exist yet
+        if {![dict exists $State $name]} {
+            dict set State $name [dict create "versions" [dict create] \
+                    "history" [list] "timestamp" 0 "errors" [list]]
+        }
+
+        # if there are no versions, log an error message since something must
+        # be wrong
+        if {[llength [dict get $StateBuffer $name "versions"]] == 0} {
+            set warningMsg "\"$name\": no versions found"
+            ${Log}::warn $warningMsg
+            StateItemAppendError $name $warningMsg
+        }
+
+        # update httpState item
+        dict set State $name "errors" [dict get $StateBuffer $name "errors"]
+        dict set State $name "timestamp" $timestamp
+        if {[llength [dict get $StateBuffer $name "errors"]] == 0} {
+            # expire old history entries
+            set history [lrange [dict get $State $name "history"] \
+                    [expr {[llength [dict get $State $name "history"]] -
+                    [dict get $Config "history_limit"] + 1}] end]
+
+            # add currently latest available version to history if it is either
+            # newer than the previous one or if the previous one is no longer
+            # available (e.g. if it has been removed or the watchlist pattern
+            # has been changed)
+            set prevLatestVersion [lindex $history end 0]
+            set curLatestVersion [lindex \
+                    [lsort -command ::relmon::common::cmpVersion \
+                    [dict keys [dict get $StateBuffer $name "versions"]]] end]
+            if {([::relmon::common::cmpVersion $curLatestVersion \
+                    $prevLatestVersion] > 0) ||
+                    ![dict exists $StateBuffer $name "versions" \
+                    $prevLatestVersion]} {
+                lappend history [list $curLatestVersion $timestamp]
+                dict set State $name "history" $history
+            }
+            dict set State $name "versions" [dict get $StateBuffer $name \
+                    "versions"]
+        }
+        dict unset StateBuffer $name
+
+        ${Log}::notice "progress: [dict size $StateBuffer]/[dict get \
+                $Statistics "items"] items left"
+    }
+
+    http::cleanup $token
+
+    ManageTransfers
+
+    return
+}
+
+# control certificate verification and log errors during TLS handshake
+proc ::relmon::update::OnTlsHandshake {type args} {
+    variable Config
+    variable Log
+
+    switch -- ${type} {
+        {error} {
+            lassign $args {} tlsErrorMsg
+            ${Log}::error "TLS connection error: $tlsErrorMsg"
+        }
+        {verify} {
+            lassign $args {} {} {} status tlsErrorMsg
+            array set cert [lindex $args 2]
+            if {$status == 0} {
+                if {[dict get $Config "ca_dir"] eq ""} {
+                    # do not verify certificates is ca-dir was not set
+                    return 1
+                } else {
+                    set errorMsg "$tlsErrorMsg\nCertificate details:"
+                    foreach {key description} {"serial" "Serial Number"
+                            "issuer" "Issuer" "notBefore" "Not Valid Before"
+                            "notAfter" "Not Valid After" "subject" "Subject"
+                            "sha1_hash" "SHA1 Hash"} {
+                        append errorMsg "\n$description: $cert($key)"
+                    }
+                    ${Log}::error "TLS connection error: $errorMsg"
+                    return 0
+                }
+            }
+        }
+    }
+}
+
+proc ::relmon::update::main {args} {
+    variable Config
+    variable usage
+    variable Statistics
+    variable Watchlist [dict create]
+    variable Queue [dict create]
+    variable HostConnections [dict create]
+    variable HostDelays [dict create]
+    variable ActiveTransfers [dict create]
+    variable State
+    variable StateBuffer [dict create]
+    variable PreprocessedHtmlBuffer
+    variable Log
+    variable Lf ""
+    variable ExitStatus
+
+    # parse commandline
+    while {[set GetoptRet [cmdline::getopt args \
+            {c.arg C.arg d D.arg e H.arg i.arg l.arg r.arg t.arg T.arg v} \
+            OptArg OptVal]] == 1} {
+        switch -glob -- $OptArg {
+            {c} {
+                if {![string is digit -strict $OptVal] || ($OptVal <= 0)} {
+                    puts stderr "invalid value passed to \"-$OptArg\""
+                    exit 1
+                }
+                dict set Config "host_connection_limit" $OptVal
+            }
+            {C} {
+                if {![file isdirectory $OptVal]} {
+                    puts stderr "directory \"$OptVal\" is not a directory"
+                    exit 1
+                } elseif {![file readable $OptVal] ||
+                        ![file executable $OptVal]} {
+                    puts stderr "directory \"$OptVal\" is not readable"
+                    exit 1
+                }
+                dict set Config "ca_dir" $OptVal
+            }
+            {d} {
+                dict set Config "log_level" "debug"
+            }
+            {D} {
+                if {![string is digit -strict $OptVal] || ($OptVal < 0)} {
+                    puts stderr "invalid value passed to \"-$OptArg\""
+                    exit 1
+                }
+                dict set Config "host_delay" [expr {$OptVal * 1000}]
+            }
+            {e} {
+                dict set Config "error_filter" 1
+            }
+            {H} {
+                if {![string is digit -strict $OptVal] || ($OptVal <= 0)} {
+                    puts stderr "invalid value passed to \"-$OptArg\""
+                    exit 1
+                }
+                dict set Config "connection_limit" $OptVal
+            }
+            {i} {
+                foreach item [split $OptVal " "] {
+                    set item [string trim $item]
+                    if {$item ne ""} {
+                        dict lappend Config "item_filter" $item
+                    }
+                }
+            }
+            {l} {
+                dict set Config "log_file" $OptVal
+                set LogDir [file dirname $OptVal]
+                if {![file writable $LogDir] || ![file executable $LogDir]} {
+                    puts stderr "directory \"$LogDir\" is not writable"
+                    exit 1
+                }
+            }
+            {r} {
+                if {![string is digit -strict $OptVal] || ($OptVal < 0)} {
+                    puts stderr "invalid value passed to \"-$OptArg\""
+                    exit 1
+                }
+                dict set Config "retry_limit" $OptVal
+            }
+            {t} {
+                if {![string is digit -strict $OptVal] || ($OptVal < 0)} {
+                    puts stderr "invalid value passed to \"-$OptArg\""
+                    exit 1
+                }
+                dict set Config "timestamp_filter" [expr {$OptVal * 1000}]
+            }
+            {T} {
+                if {![string is digit -strict $OptVal] || ($OptVal <= 0)} {
+                    puts stderr "invalid value passed to \"-$OptArg\""
+                    exit 1
+                }
+                dict set Config "transfer_time_limit" [expr {$OptVal * 1000}]
+            }
+            {v} {
+                if {[dict get $Config "log_level"] ne "debug"} {
+                    dict set Config "log_level" "info"
+                }
+            }
+        }
+    }
+    set argc [llength $args]
+    if {$GetoptRet == -1} {
+        puts stderr "unknown command line option \"-$OptArg\""
+        puts stderr $usage
+        exit 1
+    }
+    if {$argc != 2} {
+        puts stderr $usage
+        exit 1
+    }
+    dict set Config "watchlist_file" [lindex $args 0]
+    if {![file readable [dict get $Config "watchlist_file"]]} {
+        puts stderr "watchlist file \"[dict get $Config "watchlist_file"]\"\
+                could not be read"
+        exit 1
+    }
+    set stateFile [lindex $args 1]
+    dict set Config "state_file" $stateFile
+    set StateDir [file dirname $stateFile]
+    if {![file writable $StateDir]} {
+        puts stderr "directory \"$StateDir\" is not writable"
+
+        exit 1
+    }
+
+    # install exit handler for closing the logfile, open the logfile and
+    # initialize logger
+    trace add execution exit enter CleanupBeforeExit
+    if {[dict get $Config "log_file"] ne ""} {
+        try {
+            set Lf [open [dict get $Config "log_file"] "w"]
+        } trap {POSIX} {errorMsg errorOptions} {
+            puts stderr "failed to open logfile\
+                    \"[dict get $Config "log_file"]\": $errorMsg"
+            exit 1
+        }
+    } else {
+        set Lf stderr
+    }
+    set Log [logger::init global]
+    if {[dict get $Config "log_level"] eq "debug"} {
+        set logFormat {%d \[%p\] \[%M\] %m}
+    } else {
+        set logFormat {%d \[%p\] %m}
+    }
+    logger::utils::applyAppender -appender fileAppend -appenderArgs \
+            [list -outputChannel $Lf -conversionPattern $logFormat] \
+            -serviceCmd $Log
+
+    # set default logging level
+    ${Log}::setlevel [dict get $Config "log_level"]
+
+    ${Log}::notice "relmon.tcl starting up"
+
+    # parse the watchlist
+    try {
+        ParseWatchlist [dict get $Config "watchlist_file"]
+    } trap {POSIX} {errorMsg errorOptions} - \
+    trap {RELMON} {errorMsg errorOptions} {
+        ${Log}::error $errorMsg
+        exit 1
+    }
+
+    # read the state file
+    try {
+        set State [::relmon::common::parseStateFile $stateFile]
+    } trap {POSIX ENOENT} {errorMsg} {
+        ${Log}::debug "state file \"$stateFile\" does not exist"
+        set State [dict create]
+    } trap {POSIX} {errorMsg} - \
+    trap {RELMON} {errorMsg} {
+        ${Log}::error $errorMsg
+        exit 1
+    }
+
+    # initialize queue and state buffer from the watchlist
+    dict set Statistics "start_time" [clock milliseconds]
+    dict for {name watchlistItem} $Watchlist {
+        # apply filters specified on the command line to watchlist items
+        if {([llength [dict get $Config "item_filter"]] > 0) &&
+                ($name ni [dict get $Config "item_filter"])} {
+            continue
+        }
+
+        if {[dict get $Config "error_filter"] &&
+                [dict exists $State $name "errors"] &&
+                ([llength [dict get $State $name "errors"]] == 0)} {
+            continue
+        }
+
+        if {[dict exists $State $name "timestamp"] &&
+                ([dict get $State $name "timestamp"] >
+                [dict get $Statistics "start_time"] -
+                [dict get $Config "timestamp_filter"])} {
+            continue
+        }
+
+        dict lappend Queue [::relmon::common::urlGetHost \
+                [dict get $watchlistItem "base_url"]] \
+                [dict create \
+                "name" $name \
+                "url" [dict get $watchlistItem "base_url"] \
+                "pattern_index" 0 \
+                "content_type" "" \
+                "num_redirects" 0 \
+                "num_retries" 0]
+        dict incr Statistics "items"
+        dict set StateBuffer $name [dict create "versions" [dict create] \
+                "errors" [list]]
+    }
+
+    # configure http and tls
+    http::register https 443 [list tls::socket \
+            -cadir [dict get $Config "ca_dir"] -request 1 -require 1 \
+            -command [namespace code OnTlsHandshake] -ssl2 0 -ssl3 1 -tls1 1]
+    http::config -useragent "Mozilla/5.0 (Windows NT 6.1; rv:16.0)\
+            Gecko/20100101 Firefox/16.0 relmon/$relmon::VERSION"
+
+    # handle errors while in the event loop
+    interp bgerror {} [namespace code OnError]
+
+    # enter the main loop
+    after idle [namespace code ManageTransfers]
+    vwait [namespace which -variable ExitStatus]
+
+    dict set Statistics "end_time" [clock milliseconds]
+
+    # display statistics
+    ${Log}::notice "items checked: [dict get $Statistics "items"]"
+    ${Log}::notice "time elapsed: [expr {([dict get $Statistics "end_time"] -
+        [dict get $Statistics "start_time"]) / 1000}]s"
+
+    # serialize the new state
+    set JsonStateItems {}
+    dict for {item data} $State {
+        set versions {}
+        dict for {version url} [dict get $data "versions"] {
+            lappend versions $version [json::write string $url]
+        }
+        set history {}
+        foreach historyItem [dict get $data "history"] {
+            lassign $historyItem version timestamp
+            lappend history [json::write array [json::write string $version] \
+                    $timestamp]
+        }
+        set errors {}
+        foreach errorItem [dict get $data "errors"] {
+            lappend errors [json::write string $errorItem]
+        }
+        lappend JsonStateItems $item [json::write object \
+            "versions" [json::write object {*}$versions] \
+            "history" [json::write array {*}$history] \
+            "timestamp" [dict get $data "timestamp"] \
+            "errors" [json::write array {*}$errors]]
+    }
+    set JsonState [json::write object {*}$JsonStateItems]
+
+    # try to preserve permissions and ownership
+    try {
+        set stateFileAttributes [file attributes $stateFile]
+    } trap {POSIX ENOENT} {} {
+        set stateFileAttributes {}
+    } trap {POSIX} {errorMsg errorOptions} {
+        ${Log}::error "failed to stat \"$stateFile\": $errorMsg"
+    }
+    # write the new state to a temporary file
+    set tmpFile "$stateFile.[pid].tmp"
+    try {
+        set f [open $tmpFile {RDWR CREAT EXCL TRUNC} 0600]
+    } trap {POSIX} {errorMsg errorOptions} {
+        ${Log}::error "failed to open \"$tmpFile\": $errorMsg"
+
+        exit 1
+    }
+    try {
+        chan puts -nonewline $f $JsonState
+    } trap {POSIX} {errorMsg errorOptions} {
+        catch {file delete $tmpFile}
+
+        ${Log}::error "failed to write to \"$tmpFile\": $errorMsg"
+
+        exit 1
+    } finally {
+        close $f
+    }
+    # make a backup of the previous state file
+    try {
+        file copy -force $stateFile "$stateFile~"
+    } trap {POSIX ENOENT} {} {
+        # ignore non-existing file
+    } trap {POSIX} {errorMsg errorOptions} {
+        ${Log}::error "failed to create a backup of \"$statFile\":\
+                $errorMsg"
+    }
+    # rename the temporary file to the state file name
+    try {
+        file rename -force $tmpFile $stateFile
+    } trap {POSIX} {errorMsg errorOptions} {
+        catch {file delete $tmpFile}
+
+        ${Log}::error "failed to rename \"$tmpFile\" to \"$stateFile\":\
+                $errorMsg"
+
+        exit 1
+    }
+    # restore ownership and permissions
+    try {
+        file attributes $stateFile {*}$stateFileAttributes
+    } trap {POSIX} {errorMsg errorOptions} {
+        ${Log}::error "failed to set permissions and ownership on\
+                \"$stateFile\": $errorMsg"
+
+        exit 1
+    }
+
+    # clean up
+    ${Log}::delete
+
+    exit $ExitStatus
+}
+
+
+namespace eval ::relmon::show {
+    # commandline option help text
+    variable usage "usage: relmon show statefile name..."
+}
+
+proc ::relmon::show::GetItem {stateName name} {
+    upvar 1 $stateName state
+    set item [dict get $state $name]
+
+    # format state data as plain-text
+    set output ""
+    append output "Name: $name\n"
+    append output "Latest Version:\
+            [lindex [lindex [dict get $item "history"] end] 0]\n"
+    append output "Refreshed: [clock format \
+            [expr {[dict get $item "timestamp"] / 1000}] \
+            -format {%Y-%m-%dT%H:%M:%S%z}]\n"
+    append output "Versions:\n"
+    dict for {version url} [dict get $item "versions"] {
+        append output "\t$version $url\n"
+    }
+    append output "Errors:\n"
+    if {[dict get $item "errors"] eq ""} {
+        append output "\tNone\n"
+    } else {
+        foreach errorMsg [dict get $item "errors"] {
+            append output "\t[string map {\n \n\t} [string trim $errorMsg]]\n"
+        }
+    }
+    append output "History:\n"
+    foreach historyItem [dict get $item "history"] {
+        append output "\t[lindex $historyItem 0] [clock format \
+                [expr {[lindex $historyItem 1] / 1000}] \
+                -format {%Y-%m-%dT%H:%M:%S%z}]\n"
+    }
+    return $output
+}
+
+proc ::relmon::show::main {args} {
+    variable usage
+
+    # parse commandline
+    if {[cmdline::getopt args {} OptArg OptVal] == -1} {
+        puts stderr "unknown command line option \"-$OptArg\""
+        puts stderr $usage
+        exit 1
+    }
+    if {[llength $args] < 2} {
+        puts stderr $usage
+        exit 1
+    }
+    set stateFile [lindex $args 0]
+    set names [lrange $args 1 end]
+
+    try {
+        set state [::relmon::common::parseStateFile $stateFile]
+    } trap {POSIX} {errorMsg} - trap {RELMON} {errorMsg} {
+        puts stderr $errorMsg
+        exit 1
+    }
+
+    # show each item
+    foreach name $names {
+        puts -nonewline [GetItem state $name]
+    }
+
+    exit 0
+}
+
+
+namespace eval ::relmon::list {
+    # commandline option help text
+    variable usage "usage: relmon list \[-H\] \[-f html|parseable|text\]\
+            \[-F url\]\n\
+            \                  \[-n number_items\] statefile\n\
+            \      relmon list -f atom -F url \[-n number_items\] statefile"
+
+    # configuration options
+    variable Config [dict create \
+            "format" "text" \
+            "show_history" 0 \
+            "history_limit" 100 \
+            "feed_url" ""]
+}
+
+proc ::relmon::list::FormatText {stateName includeHistory historyLimit} {
+    upvar 1 $stateName state
+    set output ""
+    append output [format "%-35s %-15s %-24s %-3s\n" "Project" "Version" \
+            "Refreshed" "St."]
+    append output [string repeat "-" 80]
+    append output "\n"
+
+    set history {}
+    dict for {name item} $state {
+        foreach historyItem [dict get $item "history"] {
+            lappend history [list [lindex $historyItem 1] $name \
+                    [lindex $historyItem 0]]
+        }
+        set latestVersion [lindex [lindex [dict get $item "history"] end] 0]
+        set timestamp [clock format [expr {[dict get $item "timestamp"] /
+                1000}] -format {%Y-%m-%dT%H:%M:%S%z}]
+        set status [expr {[llength [dict get $item "errors"]] > 0 ? "E" : ""}]
+        append output [format "%-35s %15s %-24s %-1s\n" $name $latestVersion \
+                $timestamp $status]
+    }
+    if {$includeHistory} {
+        append output "\nHistory\n"
+        append output [string repeat "-" 80]
+        append output "\n"
+        set history [lsort -decreasing -integer -index 0 $history]
+        foreach historyItem [lrange $history 0 $historyLimit] {
+            append output [format "%-24s %-35s %15s\n" \
+                    [clock format [expr {[lindex $historyItem 0] / 1000}] \
+                    -format {%Y-%m-%dT%H:%M:%S%z}] [lindex $historyItem 1] \
+                    [lindex $historyItem 2]]
+        }
+    }
+
+    return $output
+}
+
+proc ::relmon::list::FormatParseable {stateName includeHistory historyLimit} {
+    upvar 1 $stateName state
+    set output ""
+    set history {}
+    dict for {name item} $state {
+        foreach historyItem [dict get $item "history"] {
+            lappend history [list [lindex $historyItem 1] $name \
+                    [lindex $historyItem 0]]
+        }
+        set latestVersion [lindex [lindex [dict get $item "history"] end] 0]
+        if {$latestVersion eq ""} {
+            set latestVersion -
+        }
+        set timestamp [clock format [expr {[dict get $item "timestamp"] /
+                1000}] -timezone :UTC -format {%Y-%m-%dT%H:%M:%SZ}]
+        set status [expr {[llength [dict get $item "errors"]] > 0 ? "ERROR" :
+                "OK"}]
+        append output [format "%s\t%s\t%s\t%s\n" $name $latestVersion \
+                $timestamp $status]
+    }
+    if {$includeHistory} {
+        append output "\n"
+        set history [lsort -decreasing -integer -index 0 $history]
+        foreach historyItem [lrange $history 0 $historyLimit] {
+            append output [format "%s\t%s\t%s\n" [clock format \
+                    [expr {[lindex $historyItem 0] / 1000}] -timezone :UTC \
+                    -format {%Y-%m-%dT%H:%M:%SZ}] [lindex $historyItem 1] \
+                    [lindex $historyItem 2]]
+        }
+    }
+    return $output
+}
+
+proc ::relmon::list::FormatHtml {stateName includeHistory historyLimit
+        feedUrl} {
+    upvar 1 $stateName state
+
+    set output "<html>\n"
+    append output "<head>\n"
+    append output "<title>Current Releases</title>\n"
+    if {$feedUrl ne ""} {
+        append output "<link type=\"application/atom+xml\" rel=\"alternate\"\
+                title=\"Release History\"\
+                href=\"[html::html_entities $feedUrl]\"/>\n"
+    }
+    append output "</head>\n"
+    append output "<body>\n"
+    append output "<h1>Current Releases</h1>\n<table>\n<tr>\n<th>Project</th>\
+            \n<th>Version</th>\n<th>Refreshed</th>\n<th>Status</th>\n</tr>\n"
+    set history {}
+    dict for {name item} $state {
+        foreach historyItem [dict get $item "history"] {
+            lappend history [list [lindex $historyItem 1] $name \
+                    [lindex $historyItem 0]]
+        }
+        set latestVersion [lindex [lindex [dict get $item "history"] end] 0]
+        set timestamp [clock format [expr {[dict get $item "timestamp"] /
+                1000}] -format {%Y-%m-%dT%H:%M:%S%z}]
+        set status [expr {[llength [dict get $item "errors"]] > 0 ? "Error" :
+                "OK"}]
+
+        append output "<tr>\n<td>[html::html_entities $name]</td>\n"
+        if {$latestVersion ne ""} {
+            if {[dict exists $item "versions" $latestVersion]} {
+                set url [dict get $item "versions" $latestVersion]
+                append output "<td><a\
+                        href=\"[html::html_entities $url]\"\
+                        title=\"[html::html_entities\
+                        "$name $latestVersion"]\">[html::html_entities \
+                        $latestVersion]</a></td>\n"
+            } else {
+                append output "<td>[html::html_entities \
+                        $latestVersion]</td>\n"
+            }
+        } else {
+            append output "<td></td>\n"
+        }
+        append output "<td>$timestamp</td>\n"
+        append output "<td>[html::html_entities $status]</td>\n</tr>\n"
+    }
+    append output "</table>\n"
+
+    if {$includeHistory} {
+        set history [lsort -decreasing -integer -index 0 $history]
+        append output "<h1>Release History</h1>\n<table>\n"
+        append output "<tr><th>Time</th><th>Project</th><th>Version</th></tr>\n"
+        foreach historyItem [lrange $history 0 $historyLimit] {
+            set timestamp [clock format [expr {[lindex $historyItem 0] /
+                    1000}] -format {%Y-%m-%dT%H:%M:%S%z}]
+            set name [lindex $historyItem 1]
+            set version [lindex $historyItem 2]
+            append output "<tr>\n<td>$timestamp</td>\n"
+            append output "<td>[html::html_entities $name]</td>\n"
+            append output "<td>[html::html_entities $version]</td></tr>\n"
+        }
+        append output "</table>\n"
+    }
+
+    append output "</body>\n</html>\n"
+
+    return $output
+}
+
+proc ::relmon::list::FormatAtom {stateName historyLimit feedUrl} {
+    upvar 1 $stateName state
+    set host [::relmon::common::urlGetHost $feedUrl]
+    set output "<?xml version=\"1.0\" encoding=\"utf-8\"?>\
+            \n<feed xmlns=\"http://www.w3.org/2005/Atom\">\n"
+    append output "<author><name>relmon</name></author>\n"
+    append output "<title>Release History</title>\n"
+    append output "<id>[html::html_entities $feedUrl]</id>\n"
+    set history {}
+    dict for {name item} $state {
+        foreach historyItem [dict get $item "history"] {
+            lappend history [list [lindex $historyItem 1] $name \
+                    [lindex $historyItem 0]]
+        }
+    }
+    set history [lsort -decreasing -integer -index 0 $history]
+    set updated [lindex [lindex $history end] 0]
+    if {$updated eq ""} {
+        set updated [clock seconds]
+    }
+    append output "<updated>[clock format $updated \
+            -format {%Y-%m-%dT%H:%M:%S%z}]</updated>\n"
+    foreach historyItem [lrange $history 0 $historyLimit] {
+        set name [lindex $historyItem 1]
+        set version [lindex $historyItem 2]
+        set timestamp [clock format [expr {[lindex $historyItem 0] / 1000}] \
+                -format {%Y-%m-%dT%H:%M:%S%z}]
+        set id "tag:$host,[clock format [lindex $historyItem 0] \
+                -format {%Y-%m-%d}]:[uri::urn::quote $name-$version]"
+        append output "<entry>\n"
+        append output "<id>[html::html_entities $id]</id>\n"
+        append output "<updated>$timestamp</updated>\n"
+        append output "<title>[html::html_entities "$name $version"]</title>"
+        append output "<content>[html::html_entities \
+                "$name $version"]</content>\n"
+        append output "</entry>\n"
+    }
+    append output "</feed>\n"
+    return $output
+}
+
+proc ::relmon::list::main {args} {
+    variable usage
+    variable Config
+
+    # parse commandline
+    while {[set GetoptRet [cmdline::getopt args {f.arg F.arg H n.arg} OptArg \
+            OptVal]] == 1} {
+        switch -glob -- $OptArg {
+            {f} {
+                if {$OptVal ni {atom html parseable text}} {
+                    puts stderr "invalid value passed to \"-$OptArg\""
+                    exit 1
+                }
+                dict set Config "format" $OptVal
+            }
+            {F} {
+                if {[catch {dict create {*}[uri::split $OptVal]} UrlParts] ||
+                        ([dict get $UrlParts "host"] eq "")} {
+                    puts stderr "invalid value passed to \"-$OptArg\""
+                    exit 1
+                }
+                dict set Config "feed_url" $OptVal
+            }
+            {H} {
+                dict set Config "show_history" 1
+            }
+            {n} {
+                if {![string is digit -strict $OptVal] || ($OptVal <= 0)} {
+                    puts stderr "invalid value passed to \"-$OptArg\""
+                    exit 1
+                }
+                dict set Config "history_limit" [expr {$OptVal - 1}]
+            }
+        }
+    }
+    set argc [llength $args]
+    if {$GetoptRet == -1} {
+        puts stderr "unknown command line option \"-$OptArg\""
+        puts stderr $usage
+        exit 1
+    }
+    if {$argc != 1} {
+        puts stderr $usage
+        exit 1
+    }
+    if {([dict get $Config "format"] eq "atom") &&
+            ([dict get $Config "feed_url"] eq "")} {
+        puts stderr "mandatory \"-F\" option is missing"
+        puts stderr $usage
+        exit 1
+    }
+    set StateFile [lindex $args 0]
+
+    # read the state file
+    try {
+        set State [::relmon::common::parseStateFile $StateFile]
+    } trap {POSIX} {errorMsg} - trap {RELMON} {errorMsg} {
+        puts stderr $errorMsg
+        exit 1
+    }
+
+    # call formatter
+    switch -- [dict get $Config "format"] {
+        {atom} {
+            puts -nonewline [FormatAtom State \
+                    [dict get $Config "history_limit"] \
+                    [dict get $Config "feed_url"]]
+        }
+        {html} {
+            puts -nonewline [FormatHtml State \
+                    [dict get $Config "show_history"] \
+                    [dict get $Config "history_limit"] \
+                    [dict get $Config "feed_url"]]
+        }
+        {parseable} {
+            puts -nonewline [FormatParseable State \
+                    [dict get $Config "show_history"] \
+                    [dict get $Config "history_limit"]]
+        }
+        {default} {
+            puts -nonewline [FormatText State \
+                    [dict get $Config "show_history"] \
+                    [dict get $Config "history_limit"]]
+        }
+    }
+
+    exit 0
+}
+
+
+namespace eval ::relmon::help {
+    # commandline option help text
+    variable usage "usage: relmon help \[subcommand\]"
+}
+
+proc ::relmon::help::main {args} {
+    variable usage
+
+    # parse commandline
+    if {[cmdline::getopt args {} OptArg OptVal] == -1} {
+        puts stderr "unknown command line option \"-$OptArg\""
+        puts stderr $usage
+        exit 1
+    }
+    set argc [llength $args]
+    if {$argc > 1} {
+        puts stderr $usage
+        exit 1
+    }
+    set subCommand [lindex $args 0]
+    if {$subCommand ne ""} {
+        if {[info procs ::relmon::${subCommand}::main] ne ""} {
+            puts stderr [set ::relmon::${subCommand}::usage]
+        } else {
+            puts stderr "unknown subcommand \"$subCommand\""
+            puts stderr $usage
+            exit 1
+        }
+    } else {
+        foreach subCommandNs [namespace children ::relmon] {
+            if {[info procs ${subCommandNs}::main] ne ""} {
+                puts stderr [set ${subCommandNs}::usage]
+            }
+        }
+    }
+    exit 0
+}
+
+
+proc ::relmon::main {args} {
+    variable usage
+    set subArgs [lassign $args subCommand]
+
+    # generate list of subcommands
+    set subCommands {}
+    foreach subCommandNs [namespace children ::relmon] {
+        if {[info procs ${subCommandNs}::main] ne ""} {
+            lappend subCommands [namespace tail $subCommandNs]
+        }
+    }
+    if {$subCommand ni $subCommands} {
+        if {$subCommand ne ""} {
+            puts stderr "unknown subcommand \"$subCommand\""
+        }
+        foreach command $subCommands {
+            puts stderr [set relmon::${command}::usage]
+        }
+        exit 1
+    }
+
+    # dispatch subcommand
+    relmon::${subCommand}::main {*}$subArgs
+}
+
+
+relmon::main {*}$argv