Project

General

Profile

root / trunk / email_updates.sh @ 87

1
#!/bin/bash
2

    
3
# $Id: email_updates.sh 87 2014-10-13 19:34:00Z deoren $
4
# $HeadURL: file:///var/www/svn/whyaskwhy.org/projects/email_updates/trunk/email_updates.sh $
5

    
6
# Purpose:
7
#   This script is intended to be run once daily to report any patches
8
#   available for the OS. If a particular patch has been reported previously
9
#   the goal is to NOT report it again (TODO: unless requested via FLAG).
10

    
11
# Compatiblity notes:
12
#  * This script needs to be compatible with:
13
#    "GNU bash, version 3.2.25(1)-release (x86_64-redhat-linux-gnu)"
14
#    as that is the oldest version of Bash that I'll be using this script with.
15
#  * Tested on RHELv5.x, CentOSv5.x & CentOSv6.x; basic update and LOCKSS repos
16

    
17
# References:
18
#
19
#   * http://projects.whyaskwhy.org/projects/email-updates/wiki/Custom_Settings
20
#
21
#   * http://quickies.andreaolivato.net/post/133473114/using-sqlite3-in-bash
22
#   * The Definitive Guide to SQLite, 2e
23
#   * http://www.thegeekstuff.com/2010/06/bash-array-tutorial/
24
#   * http://stackoverflow.com/questions/5431909/bash-functions-return-boolean-to-be-used-in-if
25
#   * http://mywiki.wooledge.org/BashPitfalls
26
#   * http://stackoverflow.com/questions/1063347/passing-arrays-as-parameters-in-bash
27

    
28

    
29
#########################
30
# Settings
31
#########################
32

    
33
# Custom file that allows overriding all predefined settings
34
# http://projects.whyaskwhy.org/projects/email-updates/wiki/Custom_Settings
35
OVERRIDES_FILE="/etc/whyaskwhy.org/email_updates.conf"
36

    
37
# Not a bad idea to run with this enabled for a while after big changes
38
# and have cron deliver the output to you for verification
39
DEBUG_ON=1
40

    
41
# Usually not needed
42
VERBOSE_DEBUG_ON=0
43

    
44
# Useful for testing where we don't want to bang on upstream servers too much
45
SKIP_UPSTREAM_SYNC=0
46

    
47
# Used to determine whether up2date, yum or apt-get should be used to
48
# calculate the available updates
49
MATCH_RHEL4='Red Hat Enterprise Linux.*4'
50
MATCH_RHEL5='Red Hat Enterprise Linux.*5'
51
MATCH_UBUNTU='Ubuntu'
52
MATCH_CENTOS='CentOS'
53

    
54
# Used when providing host info via email (if enabled)
55
MATCH_IFCONFIG_FULL='^\s+inet addr:[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+'
56
MATCH_IFCONFIG_IPS_ONLY='[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+' 
57

    
58
# Mash the contents into a single string - not creating an array via ()
59
RELEASE_INFO=$(cat /etc/*release)
60

    
61
# Let's not rely on a crontab to be configured properly. Instead, let's go 
62
# ahead and append to what's already set with the most important entries. 
63
# PATH lookups are short-circuited on first match anyway, so lookup times
64
# should be trivial.
65
PATH="${PATH}:/usr/sbin:/usr/bin:/sbin:/bin"
66

    
67
# Redmine tags
68
EMAIL_TAG_PROJECT="server-support"
69
EMAIL_TAG_CATEGORY="Patch"
70
EMAIL_TAG_STATUS="Assigned"
71

    
72
# Set this to a valid email address if you want to have this
73
# report appear to come from that address.
74
EMAIL_SENDER=""
75

    
76
# Where should the email containing the list of updates go?
77
EMAIL_DEST="updates-notification@example.org"
78

    
79
# Should we include the IP Address and full hostname of the system sending
80
# the email? This could be useful if this script is deployed on a system
81
# that is being prepped to replace another one (i.e., same hostname).
82
EMAIL_INCLUDE_HOST_INFO="1"
83

    
84

    
85
TEMP_FILE="/tmp/updates_list_$$.tmp"
86
TODAY=$(date "+%B %d %Y")
87

    
88
# Schema for database:
89
DB_STRUCTURE="CREATE TABLE reported_updates (id INTEGER PRIMARY KEY, package TEXT, time TIMESTAMP NOT NULL DEFAULT (datetime('now','localtime')));"
90

    
91
# In which field in the database is patch information stored?
92
DB_PATCH_FIELD=2
93

    
94
DB_FILE="/var/cache/email_updates/reported_updates.db"
95

    
96
# FIXME: Create Bash function instead of using external dirname tool?
97
DB_FILE_DIR=$(dirname ${DB_FILE})
98

    
99
# Anything required for this script to run properly
100
DEPENDENCIES=(
101

    
102
    sqlite3
103
    mailx
104

    
105
)
106

    
107
#------------------------------
108
# Internal Field Separator
109
#------------------------------
110
# Backup of IFS
111
# FIXME: Needed for anything?
112
OIFS=${IFS}
113

    
114
# Set to newlines only so spaces won't trigger a new array entry and so loops
115
# will only consider items separated by newlines to be the next in the loop
116
IFS=$'\n'
117

    
118

    
119

    
120
# Allow overriding any of the predefined settings above
121
# http://projects.whyaskwhy.org/projects/email-updates/wiki/Custom_Settings
122
#
123
# FIXME: Verify permissions first before importing file
124
#
125
if [ -f ${OVERRIDES_FILE} ]; then
126
    . ${OVERRIDES_FILE}
127
fi
128

    
129

    
130
#########################
131
# Functions
132
#########################
133

    
134
verify_dependencies() {
135

    
136
    # Verify that all dependencies are present
137
    # sqlite3, mail|mailx, ?
138
    if [[ "${DEBUG_ON}" -ne 0 ]]; then
139
        echo -e '\n\n************************'
140
        echo "Dependency checks"
141
        echo -e   '************************'
142
    fi
143

    
144
    for dependency in ${DEPENDENCIES[@]}
145
    do
146
        # Debug output
147
        #echo "$(which ${dependency}) ${dependency}"
148

    
149
        # Try to locate the dependency within the path. If found, compare
150
        # the basename of the dependency against the full path. If there
151
        # is a match, consider the required dependency present on the system
152
        if [[ "$(which ${dependency})" =~ "${dependency}" ]]; then
153
            if [[ "${DEBUG_ON}" -ne 0 ]]; then
154
                echo "[I] ${dependency} found."
155
            fi
156
        else
157
            echo "[!] ${dependency} missing. Please install then try again."
158
            exit 1
159
        fi
160

    
161
    done
162

    
163
}
164

    
165

    
166
initialize_db() {
167

    
168
    if [[ "${DEBUG_ON}" -ne 0 ]]; then
169
        echo -e '\n\n************************'
170
        echo "Initializing Database"
171
        echo -e   '************************'
172
    fi
173

    
174
    # Check if cache dir already exists
175
    if [[ ! -d ${DB_FILE_DIR} ]]; then
176
        if [[ "${DEBUG_ON}" -ne 0 ]]; then
177
            echo "[I] Creating ${DB_FILE_DIR}"
178
        fi
179
        mkdir ${DB_FILE_DIR}
180
    fi
181

    
182
    # Check if database already exists
183
    if [[ -f ${DB_FILE} ]]; then
184
        if [[ "${DEBUG_ON}" -ne 0 ]]; then
185
            echo "[I] ${DB_FILE} already exists, leaving it be."
186
        fi
187
        return 0
188
    else
189
        # if not, create it
190
        if [[ "${DEBUG_ON}" -ne 0 ]]; then
191
            echo "[I] Creating ${DB_FILE}"
192
        fi
193
        sqlite3 ${DB_FILE} ${DB_STRUCTURE}
194
    fi
195

    
196
}
197

    
198
# http://stackoverflow.com/a/2990533
199
# Used to print to the screen from within functions that rely on returning data
200
# to a variable via stdout
201
echoerr() { echo -e "$@" 1>&2; }
202

    
203
sanitize_string () {
204

    
205
    # This process removes extraneous spaces from update strings in order to
206
    # change lines like these:
207
    #
208
    # xorg-x11-server-Xnest.i386              1.1.1-48.91.el5_8.2               update
209
    # libxml2-dev [2.7.6.dfsg-1ubuntu1.9] (2.7.6.dfsg-1ubuntu1.10 Ubuntu:10.04/lucid-updates) []
210
    #
211
    # into lines like these:
212
    # xorg-x11-server-Xnest.i386-1.1.1-48.91.el5_8.2
213
    # libxml2-dev-2.7.6.dfsg-1ubuntu1.9
214
    #
215
    # It does this by:
216
    # ------------------------------------------------------------------------
217
    #  #1) Filtering out lines that do not include numbers (they're not kept)
218
    #  #2) Replacing instances of multiple spaces with only one instance
219
    #  #3) Using a single space as a delimiter, grab fields 1 and 2
220
    #  #4) Replace any of '[', ']', '(', ')' or a leading spaces with nothing
221
    #  #5) Replace the first space encountered with a '-' character
222
    # ------------------------------------------------------------------------
223

    
224
    if $(echo "${1}" | grep -qE '[0-9]'); then
225
        echo "${1}" \
226
            | grep -Ev '^[[:blank:]]{1,}$' \
227
            | tr -s ' ' \
228
            | cut -d' ' -f1,2 \
229
            | sed -r 's/([][(\)]|^\s)//g' \
230
            | sed -r 's/ /-/'
231
    fi
232

    
233
}
234

    
235
is_patch_already_reported() {
236

    
237
    # $1 should equal the quoted patch that we're checking
238
    # By this point it should have already been cleaned by sanitize_string()
239
    patch_to_check="${1}"
240

    
241
    if [[ "${DEBUG_ON}" -ne 0 ]]; then
242
        echoerr "\n[I] Checking \"$1\" against previously reported updates ..."
243
    fi
244

    
245
    # Rely on the sanitized string having fields separated by spaces so we can 
246
    # grab the first field (no version info) and use that as a search term
247
    package_prefix=$(echo ${1} | cut -d' ' -f 1)
248

    
249
    sql_query_match_first_field="SELECT * FROM reported_updates WHERE package LIKE '${package_prefix}%' ORDER BY time DESC"
250

    
251
    previously_reported_updates=($(sqlite3 "${DB_FILE}" "${sql_query_match_first_field}" | cut -d '|' -f 2)) 
252

    
253
    for previously_reported_update in ${previously_reported_updates[@]}
254
    do
255
        if [[ "${VERBOSE_DEBUG_ON}" -ne 0 ]]; then
256
            echoerr "[I] SQL QUERY MATCH:" $previously_reported_update
257
        fi
258

    
259
        # Assume that old database entries may need multiple spaces 
260
        # stripped from strings so we can accurately compare them
261
        stripped_prev_reported_update=$(sanitize_string ${previously_reported_update})
262

    
263
        # See if the selected patch has already been reported
264
        if [[ "${stripped_prev_reported_update}" == "${patch_to_check}" ]]; then
265
            # Report a match, and exit loop
266
            return 0
267
        fi
268
    done
269
    
270
    # If we get this far, report no match
271
    return 1
272
}
273

    
274

    
275
print_patch_arrays() {
276

    
277
    # This function is useful for getting debug output "on demand"
278
    # when the global debug option is disabled
279

    
280
    #NOTE: Relies on global variables
281

    
282
    echo -e '\n\n***************************************************'
283
    #echo "${#UNREPORTED_UPDATES[@]} unreported update(s) are available"
284
    echo "UNREPORTED UPDATES"
285
    echo -e '***************************************************\n'
286
    echo -e "  ${#UNREPORTED_UPDATES[@]} unreported update(s) are available\n"
287

    
288
    for unreported_update in "${UNREPORTED_UPDATES[@]}"
289
    do
290
        echo "  * ${unreported_update}"
291
    done
292

    
293
    echo -e '\n***************************************************'
294
    #echo "${#SKIPPED_UPDATES[@]} skipped update(s) are available"
295
    echo "SKIPPED UPDATES"
296
    echo -e '***************************************************\n'
297
    echo -e "  ${#SKIPPED_UPDATES[@]} skipped update(s) are available\n"
298

    
299
    for skipped_update in "${SKIPPED_UPDATES[@]}"
300
    do
301
        echo "  * ${skipped_update}"
302
    done
303

    
304
}
305

    
306

    
307
email_report() {
308

    
309
    # $@ is ALL arguments to this function, i.e., the unreported patches
310
    updates=(${@})
311

    
312
    # Use $1 array function argument
313
    NUMBER_OF_UPDATES="${#updates[@]}"
314
    EMAIL_SUBJECT="${HOSTNAME}: ${NUMBER_OF_UPDATES} update(s) are available"
315

    
316
    # Write updates to the temp file
317
    for update in "${updates[@]}"
318
    do
319
        echo "${update}" >> ${TEMP_FILE}
320
    done
321

    
322
    echo " " >> ${TEMP_FILE}
323

    
324
    # Tag report with Redmine compliant keywords
325
    # http://www.redmine.org/projects/redmine/wiki/RedmineReceivingEmails
326
    echo "Project: ${EMAIL_TAG_PROJECT}" >> ${TEMP_FILE}
327
    echo "Category: ${EMAIL_TAG_CATEGORY}" >> ${TEMP_FILE}
328
    echo "Status: ${EMAIL_TAG_STATUS}" >> ${TEMP_FILE}
329

    
330
    # If we're to include host specific info ...
331
    if [[ "${EMAIL_INCLUDE_HOST_INFO}" -ne 0 ]]; then
332

    
333
        echo -e "\nHostname: $(hostname -f)" >> ${TEMP_FILE}
334
        echo -e "\nIP Address(es):\n----------------------------------" >> ${TEMP_FILE}
335
        # FIXME: This is ugly, but works on RHEL5 and newer
336
        echo $(ifconfig | grep -Po "${MATCH_IFCONFIG_FULL}" | grep -v '127.0.0' | grep -Po "${MATCH_IFCONFIG_IPS_ONLY}") >> ${TEMP_FILE}
337

    
338
    fi
339

    
340
    # Send the report via email
341
    # If user chose to masquerade this email as a specific user, set the value
342
    if [[ ! -z ${EMAIL_SENDER} ]]; then
343
        mail -s "${EMAIL_SUBJECT}" --append=FROM:${EMAIL_SENDER} ${EMAIL_DEST} < ${TEMP_FILE}
344
    else
345
        # otherwise, just use whatever user account this script runs as
346
        # (which is usually root)
347
        mail -s "${EMAIL_SUBJECT}" ${EMAIL_DEST} < ${TEMP_FILE}
348
    fi
349

    
350
}
351

    
352

    
353
record_reported_patches() {
354

    
355
    # $@ is ALL arguments to this function, i.e., the unreported patches
356
    updates=(${@})
357

    
358
    # Add reported patches to the database
359

    
360
    for update in "${updates[@]}"
361
    do
362
        sqlite3 ${DB_FILE} "INSERT INTO reported_updates (package) VALUES (\"${update}\");"
363
    done
364

    
365
}
366

    
367

    
368
sync_packages_list () {
369

    
370
    # Update index of available packages for the OS
371

    
372
    THIS_DISTRO=$(detect_supported_distros)
373

    
374
    case "${THIS_DISTRO}" in
375
        up2date )
376
            # FIXME: There isn't a "run from cache" option to use later on that
377
            #        I am aware of, so we'll just do a single run later
378
            :
379
            ;;
380
        apt )
381
            # Skip upstream sync unless running in production mode
382
            if [[ "${SKIP_UPSTREAM_SYNC}" -eq 0 ]]; then
383
                apt-get update > /dev/null
384
            fi
385
            ;;
386
        yum )
387
            # Skip upstream sync unless running in production mode
388
            if [[ "${SKIP_UPSTREAM_SYNC}" -eq 0 ]]; then
389

    
390
                # Fixes #120
391
                #
392
                # Toss stdout, but only toss the one RHEL status message from 
393
                # stderr that just mentions the system is receiving updates
394
                # from Red Hat Subscription Management
395
                yum check-update 2> >(grep -v 'This system is receiving')  \
396
                    > /dev/null
397

    
398
            fi
399
            ;;
400
    esac
401

    
402
}
403

    
404

    
405
detect_supported_distros () {
406

    
407
    if [[ "${RELEASE_INFO}" =~ ${MATCH_RHEL4} ]]; then
408
        echo "up2date"
409
    fi
410

    
411
    if [[ "${RELEASE_INFO}" =~ ${MATCH_RHEL5} ]]; then
412
        echo "yum"
413
    fi
414

    
415
    if [[ "${RELEASE_INFO}" =~ ${MATCH_CENTOS} ]]; then
416
        echo "yum"
417
    fi
418

    
419
    if [[ "${RELEASE_INFO}" =~ ${MATCH_UBUNTU} ]]; then
420
        echo "apt"
421
    fi
422

    
423
}
424

    
425

    
426
calculate_updates_via_up2date() {
427

    
428
    local -a RAW_UPDATES_ARRAY
429

    
430
    # Capture output in array so we can clean and return it
431
    RAW_UPDATES_ARRAY=($(up2date --list | grep -i -E -w "${UP2DATE_MATCH_ON}"))
432

    
433
    for update in "${RAW_UPDATES_ARRAY[@]}"
434
    do
435
        # Return cleaned up string
436
        echo $(sanitize_string ${update})
437
    done
438

    
439
}
440

    
441

    
442
calculate_updates_via_yum() {
443

    
444
    declare -a YUM_CHECKUPDATE_OUTPUT
445
 
446
    # Capturing output in array so we can more easily filter out what we're not
447
    # interested in considering an "update". Don't toss lines without a number
448
    # yet; sanitize_string() handles that. We need "Obsoleting Packages"
449
    # in place as a cut-off marker. We're also tossing (see #120)
450
    # the one RHEL status message from stderr  that just mentions the system 
451
    # is receiving updates from Red Hat Subscription Management
452
    YUM_CHECKUPDATE_OUTPUT=(
453
        $(yum check-update 2> >(grep -v 'This system is receiving'))
454
    )
455

    
456
    for line in "${YUM_CHECKUPDATE_OUTPUT[@]}"
457
     do
458
        # If we've gotten this far it means we have passed all available
459
        # updates and yum is telling us what old packages it will remove
460
        if [[ "${line}" =~ "Obsoleting Packages" ]]; then
461
            if [[ "${DEBUG_ON}" -ne 0 ]]; then
462
                echoerr "Hit marker, breaking loop"
463
            fi
464

    
465
            break
466
        else
467
            echo $(sanitize_string ${line})
468
        fi
469
     done
470

    
471
}
472

    
473

    
474
calculate_updates_via_apt() {
475
    local -a RAW_UPDATES_ARRAY
476

    
477
    # Capture output in array so we can clean and return it
478
    # Using the follwing syntax mainly as a reminder that it's available
479
    RAW_UPDATES_ARRAY=($(apt-get dist-upgrade -s | grep 'Conf' | cut -c 6-))
480

    
481
    for update in "${RAW_UPDATES_ARRAY[@]}"
482
    do
483
        # Return cleaned up string
484
        echo $(sanitize_string ${update})
485
    done
486

    
487
}
488

    
489

    
490
calculate_updates_available () {
491

    
492
    THIS_DISTRO=$(detect_supported_distros)
493

    
494
    case "${THIS_DISTRO}" in
495
        up2date ) calculate_updates_via_up2date
496
            ;;
497
        apt     ) calculate_updates_via_apt
498
            ;;
499
        yum     ) calculate_updates_via_yum
500
            ;;
501
    esac
502

    
503

    
504
}
505

    
506

    
507
#############################
508
# Setup
509
#############################
510

    
511
# Make sure we have sqlite3, mailx and other necessary tools installed
512
verify_dependencies
513

    
514
# Create SQLite DB if it doesn't already exist
515
initialize_db
516

    
517

    
518
if [[ "${DEBUG_ON}" -ne 0 ]]; then
519
    echo -e '\n\n************************'
520
    echo "Checking for updates ..."
521
    echo -e   '************************'
522
fi
523

    
524
# Run apt-get update, yum check-update or other applicable commands
525
# to synchronize this systems local packages list with upstream server
526
# so we can determine which patches/updates need to be installed
527
sync_packages_list
528

    
529

    
530

    
531
#############################
532
# Main Code
533
#############################
534

    
535

    
536
# Create an array containing all updates, one per array member
537
AVAILABLE_UPDATES=($(calculate_updates_available))
538

    
539

    
540
# If updates are available ...
541
if [[ ${#AVAILABLE_UPDATES[@]} -gt 0 ]]; then
542

    
543
    declare -a UNREPORTED_UPDATES SKIPPED_UPDATES
544

    
545
    for update in "${AVAILABLE_UPDATES[@]}"
546
    do
547
        # Check to see if the patch has been previously reported
548
        if $(is_patch_already_reported ${update}); then
549

    
550
            # Skip the update, but log it for troubleshooting purposes
551
            SKIPPED_UPDATES=("${SKIPPED_UPDATES[@]}" "${update}")
552

    
553
            if [[ "${VERBOSE_DEBUG_ON}" -ne 0 ]]; then
554
                echo "[SKIP] ${update}"
555
            fi
556

    
557
        else
558
            # Add the update to an array to be reported
559
            # FIXME: There is a bug here that results in a duplicate item
560
            UNREPORTED_UPDATES=("${UNREPORTED_UPDATES[@]}" "${update}")
561

    
562
            if [[ "${VERBOSE_DEBUG_ON}" -ne 0 ]]; then
563
                echo "[INCL] ${update}"
564
            fi
565
        fi
566
    done
567

    
568
    # Only print out the list of unreported and skipped updates if we're in
569
    # debug mode.
570
    if [[ "${DEBUG_ON}" -ne 0 ]]; then
571
        print_patch_arrays
572
    fi
573

    
574
    # If we're not in debug mode, send an email
575
    if [[ "${DEBUG_ON}" -eq 0 ]]; then
576
        # If there are no updates, DON'T send an email
577
        if [[ ! ${#UNREPORTED_UPDATES[@]} -gt 0 ]]; then
578
            :
579
        else
580
            # There ARE updates, so send the email
581
            email_report "${UNREPORTED_UPDATES[@]}"
582
        fi
583
    fi
584

    
585
    record_reported_patches "${UNREPORTED_UPDATES[@]}"
586

    
587
else
588

    
589
    if [[ "${DEBUG_ON}" -ne 0 ]]; then
590
        echo -e '\n\n************************'
591
        echo "No updates found"
592
        echo -e   '************************'
593
    fi
594

    
595
    # The "do nothing" operator in case DEBUG_ON is off
596
    # FIXME: Needed?
597
    :
598

    
599
fi