Project

General

Profile

root / trunk / email_updates.sh @ 79

1
#!/bin/bash
2

    
3
# $Id: email_updates.sh 79 2013-08-01 20:26:36Z 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
#   * http://quickies.andreaolivato.net/post/133473114/using-sqlite3-in-bash
19
#   * The Definitive Guide to SQLite, 2e
20
#   * http://www.thegeekstuff.com/2010/06/bash-array-tutorial/
21
#   * http://stackoverflow.com/questions/5431909/bash-functions-return-boolean-to-be-used-in-if
22
#   * http://mywiki.wooledge.org/BashPitfalls
23
#   * http://stackoverflow.com/questions/1063347/passing-arrays-as-parameters-in-bash
24

    
25

    
26
#########################
27
# Settings
28
#########################
29

    
30
# Not a bad idea to run with this enabled for a while after big changes
31
# and have cron deliver the output to you for verification
32
DEBUG_ON=1
33

    
34
# Usually not needed
35
VERBOSE_DEBUG_ON=0
36

    
37
# Useful for testing where we don't want to bang on upstream servers too much
38
SKIP_UPSTREAM_SYNC=0
39

    
40
# Used to determine whether up2date, yum or apt-get should be used to
41
# calculate the available updates
42
MATCH_RHEL4='Red Hat Enterprise Linux.*4'
43
MATCH_RHEL5='Red Hat Enterprise Linux.*5'
44
MATCH_UBUNTU='Ubuntu'
45
MATCH_CENTOS='CentOS'
46

    
47
# Used when providing host info via email (if enabled)
48
MATCH_IFCONFIG_FULL='^\s+inet addr:[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+'
49
MATCH_IFCONFIG_IPS_ONLY='[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+' 
50

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

    
54
# Just in case it's not already there (for sqlite3)
55
PATH="${PATH}:/usr/bin"
56

    
57
# Redmine tags
58
EMAIL_TAG_PROJECT="server-support"
59
EMAIL_TAG_CATEGORY="Patch"
60
EMAIL_TAG_STATUS="Assigned"
61

    
62
# Set this to a valid email address if you want to have this
63
# report appear to come from that address.
64
EMAIL_SENDER=""
65

    
66
# Where should the email containing the list of updates go?
67
EMAIL_DEST="updates-notification@example.org"
68

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

    
74

    
75
TEMP_FILE="/tmp/updates_list_$$.tmp"
76
TODAY=$(date "+%B %d %Y")
77

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

    
81
# In which field in the database is patch information stored?
82
DB_PATCH_FIELD=2
83

    
84
DB_FILE="/var/cache/email_updates/reported_updates.db"
85

    
86
# FIXME: Create Bash function instead of using external dirname tool?
87
DB_FILE_DIR=$(dirname ${DB_FILE})
88

    
89
# Anything required for this script to run properly
90
DEPENDENCIES=(
91

    
92
    sqlite3
93
    mailx
94

    
95
)
96

    
97
#------------------------------
98
# Internal Field Separator
99
#------------------------------
100
# Backup of IFS
101
# FIXME: Needed for anything?
102
OIFS=${IFS}
103

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

    
108
#########################
109
# Functions
110
#########################
111

    
112
verify_dependencies() {
113

    
114
    # Verify that all dependencies are present
115
    # sqlite3, mail|mailx, ?
116
    if [[ "${DEBUG_ON}" -ne 0 ]]; then
117
        echo -e '\n\n************************'
118
        echo "Dependency checks"
119
        echo -e   '************************'
120
    fi
121

    
122
    for dependency in ${DEPENDENCIES[@]}
123
    do
124
        # Debug output
125
        #echo "$(which ${dependency}) ${dependency}"
126

    
127
        # Try to locate the dependency within the path. If found, compare
128
        # the basename of the dependency against the full path. If there
129
        # is a match, consider the required dependency present on the system
130
        if [[ "$(which ${dependency})" =~ "${dependency}" ]]; then
131
            if [[ "${DEBUG_ON}" -ne 0 ]]; then
132
                echo "[I] ${dependency} found."
133
            fi
134
        else
135
            echo "[!] ${dependency} missing. Please install then try again."
136
            exit 1
137
        fi
138

    
139
    done
140

    
141
}
142

    
143

    
144
initialize_db() {
145

    
146
    if [[ "${DEBUG_ON}" -ne 0 ]]; then
147
        echo -e '\n\n************************'
148
        echo "Initializing Database"
149
        echo -e   '************************'
150
    fi
151

    
152
    # Check if cache dir already exists
153
    if [[ ! -d ${DB_FILE_DIR} ]]; then
154
        if [[ "${DEBUG_ON}" -ne 0 ]]; then
155
            echo "[I] Creating ${DB_FILE_DIR}"
156
        fi
157
        mkdir ${DB_FILE_DIR}
158
    fi
159

    
160
    # Check if database already exists
161
    if [[ -f ${DB_FILE} ]]; then
162
        if [[ "${DEBUG_ON}" -ne 0 ]]; then
163
            echo "[I] ${DB_FILE} already exists, leaving it be."
164
        fi
165
        return 0
166
    else
167
        # if not, create it
168
        if [[ "${DEBUG_ON}" -ne 0 ]]; then
169
            echo "[I] Creating ${DB_FILE}"
170
        fi
171
        sqlite3 ${DB_FILE} ${DB_STRUCTURE}
172
    fi
173

    
174
}
175

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

    
181
sanitize_string () {
182

    
183
    # This process removes extraneous spaces from update strings in order to
184
    # change lines like these:
185
    #
186
    # xorg-x11-server-Xnest.i386              1.1.1-48.91.el5_8.2               update
187
    # libxml2-dev [2.7.6.dfsg-1ubuntu1.9] (2.7.6.dfsg-1ubuntu1.10 Ubuntu:10.04/lucid-updates) []
188
    #
189
    # into lines like these:
190
    # xorg-x11-server-Xnest.i386-1.1.1-48.91.el5_8.2
191
    # libxml2-dev-2.7.6.dfsg-1ubuntu1.9
192
    #
193
    # It does this by:
194
    # ------------------------------------------------------------------------
195
    #  #1) Filtering out lines that do not include numbers (they're not kept)
196
    #  #2) Replacing instances of multiple spaces with only one instance
197
    #  #3) Using a single space as a delimiter, grab fields 1 and 2
198
    #  #4) Replace any of '[', ']', '(', ')' or a leading spaces with nothing
199
    #  #5) Replace the first space encountered with a '-' character
200
    # ------------------------------------------------------------------------
201

    
202
    if $(echo "${1}" | grep -qE '[0-9]'); then
203
        echo "${1}" \
204
            | grep -Ev '^[[:blank:]]{1,}$' \
205
            | tr -s ' ' \
206
            | cut -d' ' -f1,2 \
207
            | sed -r 's/([][(\)]|^\s)//g' \
208
            | sed -r 's/ /-/'
209
    fi
210

    
211
}
212

    
213
is_patch_already_reported() {
214

    
215
    # $1 should equal the quoted patch that we're checking
216
    # By this point it should have already been cleaned by sanitize_string()
217
    patch_to_check="${1}"
218

    
219
    if [[ "${DEBUG_ON}" -ne 0 ]]; then
220
        echoerr "\n[I] Checking \"$1\" against previously reported updates ..."
221
    fi
222

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

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

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

    
231
    for previously_reported_update in ${previously_reported_updates[@]}
232
    do
233
        if [[ "${VERBOSE_DEBUG_ON}" -ne 0 ]]; then
234
            echoerr "[I] SQL QUERY MATCH:" $previously_reported_update
235
        fi
236

    
237
        # Assume that old database entries may need multiple spaces 
238
        # stripped from strings so we can accurately compare them
239
        stripped_prev_reported_update=$(sanitize_string ${previously_reported_update})
240

    
241
        # See if the selected patch has already been reported
242
        if [[ "${stripped_prev_reported_update}" == "${patch_to_check}" ]]; then
243
            # Report a match, and exit loop
244
            return 0
245
        fi
246
    done
247
    
248
    # If we get this far, report no match
249
    return 1
250
}
251

    
252

    
253
print_patch_arrays() {
254

    
255
    # This function is useful for getting debug output "on demand"
256
    # when the global debug option is disabled
257

    
258
    #NOTE: Relies on global variables
259

    
260
    echo -e '\n\n***************************************************'
261
    #echo "${#UNREPORTED_UPDATES[@]} unreported update(s) are available"
262
    echo "UNREPORTED UPDATES"
263
    echo -e '***************************************************\n'
264
    echo -e "  ${#UNREPORTED_UPDATES[@]} unreported update(s) are available\n"
265

    
266
    for unreported_update in "${UNREPORTED_UPDATES[@]}"
267
    do
268
        echo "  * ${unreported_update}"
269
    done
270

    
271
    echo -e '\n***************************************************'
272
    #echo "${#SKIPPED_UPDATES[@]} skipped update(s) are available"
273
    echo "SKIPPED UPDATES"
274
    echo -e '***************************************************\n'
275
    echo -e "  ${#SKIPPED_UPDATES[@]} skipped update(s) are available\n"
276

    
277
    for skipped_update in "${SKIPPED_UPDATES[@]}"
278
    do
279
        echo "  * ${skipped_update}"
280
    done
281

    
282
}
283

    
284

    
285
email_report() {
286

    
287
    # $@ is ALL arguments to this function, i.e., the unreported patches
288
    updates=(${@})
289

    
290
    # Use $1 array function argument
291
    NUMBER_OF_UPDATES="${#updates[@]}"
292
    EMAIL_SUBJECT="${HOSTNAME}: ${NUMBER_OF_UPDATES} update(s) are available"
293

    
294
    # Write updates to the temp file
295
    for update in "${updates[@]}"
296
    do
297
        echo "${update}" >> ${TEMP_FILE}
298
    done
299

    
300
    echo " " >> ${TEMP_FILE}
301

    
302
    # Tag report with Redmine compliant keywords
303
    # http://www.redmine.org/projects/redmine/wiki/RedmineReceivingEmails
304
    echo "Project: ${EMAIL_TAG_PROJECT}" >> ${TEMP_FILE}
305
    echo "Category: ${EMAIL_TAG_CATEGORY}" >> ${TEMP_FILE}
306
    echo "Status: ${EMAIL_TAG_STATUS}" >> ${TEMP_FILE}
307

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

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

    
316
    fi
317

    
318
    # Send the report via email
319
    # If user chose to masquerade this email as a specific user, set the value
320
    if [[ ! -z ${EMAIL_SENDER} ]]; then
321
        mail -s "${EMAIL_SUBJECT}" --append=FROM:${EMAIL_SENDER} ${EMAIL_DEST} < ${TEMP_FILE}
322
    else
323
        # otherwise, just use whatever user account this script runs as
324
        # (which is usually root)
325
        mail -s "${EMAIL_SUBJECT}" ${EMAIL_DEST} < ${TEMP_FILE}
326
    fi
327

    
328
}
329

    
330

    
331
record_reported_patches() {
332

    
333
    # $@ is ALL arguments to this function, i.e., the unreported patches
334
    updates=(${@})
335

    
336
    # Add reported patches to the database
337

    
338
    for update in "${updates[@]}"
339
    do
340
        sqlite3 ${DB_FILE} "INSERT INTO reported_updates (package) VALUES (\"${update}\");"
341
    done
342

    
343
}
344

    
345

    
346
sync_packages_list () {
347

    
348
    # Update index of available packages for the OS
349

    
350
    THIS_DISTRO=$(detect_supported_distros)
351

    
352
    case "${THIS_DISTRO}" in
353
        up2date )
354
            # FIXME: There isn't a "run from cache" option to use later on that
355
            #        I am aware of, so we'll just do a single run later
356
            :
357
            ;;
358
        apt )
359
            # Skip upstream sync unless running in production mode
360
            if [[ "${SKIP_UPSTREAM_SYNC}" -eq 0 ]]; then
361
                apt-get update > /dev/null
362
            fi
363
            ;;
364
        yum )
365
            # Skip upstream sync unless running in production mode
366
            if [[ "${SKIP_UPSTREAM_SYNC}" -eq 0 ]]; then
367
                yum check-update > /dev/null
368
            fi
369
            ;;
370
    esac
371

    
372
}
373

    
374

    
375
detect_supported_distros () {
376

    
377
    if [[ "${RELEASE_INFO}" =~ ${MATCH_RHEL4} ]]; then
378
        echo "up2date"
379
    fi
380

    
381
    if [[ "${RELEASE_INFO}" =~ ${MATCH_RHEL5} ]]; then
382
        echo "yum"
383
    fi
384

    
385
    if [[ "${RELEASE_INFO}" =~ ${MATCH_CENTOS} ]]; then
386
        echo "yum"
387
    fi
388

    
389
    if [[ "${RELEASE_INFO}" =~ ${MATCH_UBUNTU} ]]; then
390
        echo "apt"
391
    fi
392

    
393
}
394

    
395

    
396
calculate_updates_via_up2date() {
397

    
398
    local -a RAW_UPDATES_ARRAY
399

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

    
403
    for update in "${RAW_UPDATES_ARRAY[@]}"
404
    do
405
        # Return cleaned up string
406
        echo $(sanitize_string ${update})
407
    done
408

    
409
}
410

    
411

    
412
calculate_updates_via_yum() {
413

    
414
    declare -a YUM_CHECKUPDATE_OUTPUT
415
 
416
    # Capturing output in array so we can more easily filter out what we're not
417
    # interested in considering an "update". Don't toss lines without a number
418
    # yet; sanitize_string() handles that. We need "Obsoleting Packages"
419
    # in place as a cut-off marker
420
    YUM_CHECKUPDATE_OUTPUT=($(yum check-update -C))
421

    
422
    for line in "${YUM_CHECKUPDATE_OUTPUT[@]}"
423
     do
424
        # If we've gotten this far it means we have passed all available
425
        # updates and yum is telling us what old packages it will remove
426
        if [[ "${line}" =~ "Obsoleting Packages" ]]; then
427
            if [[ "${DEBUG_ON}" -ne 0 ]]; then
428
                echoerr "Hit marker, breaking loop"
429
            fi
430

    
431
            break
432
        else
433
            echo $(sanitize_string ${line})
434
        fi
435
     done
436

    
437
}
438

    
439

    
440
calculate_updates_via_apt() {
441
    local -a RAW_UPDATES_ARRAY
442

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

    
447
    for update in "${RAW_UPDATES_ARRAY[@]}"
448
    do
449
        # Return cleaned up string
450
        echo $(sanitize_string ${update})
451
    done
452

    
453
}
454

    
455

    
456
calculate_updates_available () {
457

    
458
    THIS_DISTRO=$(detect_supported_distros)
459

    
460
    case "${THIS_DISTRO}" in
461
        up2date ) calculate_updates_via_up2date
462
            ;;
463
        apt     ) calculate_updates_via_apt
464
            ;;
465
        yum     ) calculate_updates_via_yum
466
            ;;
467
    esac
468

    
469

    
470
}
471

    
472

    
473
#############################
474
# Setup
475
#############################
476

    
477
# Make sure we have sqlite3, mailx and other necessary tools installed
478
verify_dependencies
479

    
480
# Create SQLite DB if it doesn't already exist
481
initialize_db
482

    
483

    
484
if [[ "${DEBUG_ON}" -ne 0 ]]; then
485
    echo -e '\n\n************************'
486
    echo "Checking for updates ..."
487
    echo -e   '************************'
488
fi
489

    
490
# Run apt-get update, yum check-update or other applicable commands
491
# to synchronize this systems local packages list with upstream server
492
# so we can determine which patches/updates need to be installed
493
sync_packages_list
494

    
495

    
496

    
497
#############################
498
# Main Code
499
#############################
500

    
501

    
502
# Create an array containing all updates, one per array member
503
AVAILABLE_UPDATES=($(calculate_updates_available))
504

    
505

    
506
# If updates are available ...
507
if [[ ${#AVAILABLE_UPDATES[@]} -gt 0 ]]; then
508

    
509
    declare -a UNREPORTED_UPDATES SKIPPED_UPDATES
510

    
511
    for update in "${AVAILABLE_UPDATES[@]}"
512
    do
513
        # Check to see if the patch has been previously reported
514
        if $(is_patch_already_reported ${update}); then
515

    
516
            # Skip the update, but log it for troubleshooting purposes
517
            SKIPPED_UPDATES=("${SKIPPED_UPDATES[@]}" "${update}")
518

    
519
            if [[ "${VERBOSE_DEBUG_ON}" -ne 0 ]]; then
520
                echo "[SKIP] ${update}"
521
            fi
522

    
523
        else
524
            # Add the update to an array to be reported
525
            # FIXME: There is a bug here that results in a duplicate item
526
            UNREPORTED_UPDATES=("${UNREPORTED_UPDATES[@]}" "${update}")
527

    
528
            if [[ "${VERBOSE_DEBUG_ON}" -ne 0 ]]; then
529
                echo "[INCL] ${update}"
530
            fi
531
        fi
532
    done
533

    
534
    # Only print out the list of unreported and skipped updates if we're in
535
    # debug mode.
536
    if [[ "${DEBUG_ON}" -ne 0 ]]; then
537
        print_patch_arrays
538
    fi
539

    
540
    # If we're not in debug mode, send an email
541
    if [[ "${DEBUG_ON}" -eq 0 ]]; then
542
        # If there are no updates, DON'T send an email
543
        if [[ ! ${#UNREPORTED_UPDATES[@]} -gt 0 ]]; then
544
            :
545
        else
546
            # There ARE updates, so send the email
547
            email_report "${UNREPORTED_UPDATES[@]}"
548
        fi
549
    fi
550

    
551
    record_reported_patches "${UNREPORTED_UPDATES[@]}"
552

    
553
else
554

    
555
    if [[ "${DEBUG_ON}" -ne 0 ]]; then
556
        echo -e '\n\n************************'
557
        echo "No updates found"
558
        echo -e   '************************'
559
    fi
560

    
561
    # The "do nothing" operator in case DEBUG_ON is off
562
    # FIXME: Needed?
563
    :
564

    
565
fi