Add Support for Timed Campaigns (#1184)

This builds on the work from @c-f in #1090 to fully add support for "timed" campaigns, in which the emails are spaced apart as opposed to all being sent at once.
1205-drop-campaigns
Jordan Wright 2018-09-02 11:17:52 -05:00 committed by GitHub
parent 9f334281ab
commit 7dcf30f277
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 304 additions and 100 deletions

View File

@ -2,7 +2,6 @@ language: go
sudo: false
go:
- 1.8
- 1.9
- tip

View File

@ -0,0 +1,6 @@
-- +goose Up
-- SQL in this section is executed when the migration is applied.
ALTER TABLE campaigns ADD COLUMN send_by_date DATETIME;
-- +goose Down
-- SQL in this section is executed when the migration is rolled back.

View File

@ -0,0 +1,6 @@
-- +goose Up
-- SQL in this section is executed when the migration is applied.
ALTER TABLE campaigns ADD COLUMN send_by_date DATETIME;
-- +goose Down
-- SQL in this section is executed when the migration is rolled back.

View File

@ -83,7 +83,6 @@ func (mw *MailWorker) Start(ctx context.Context) {
return
case ms := <-mw.Queue:
go func(ctx context.Context, ms []Mail) {
log.Infof("Mailer got %d mail to send", len(ms))
dialer, err := ms[0].GetDialer()
if err != nil {
errorMail(err, ms)

View File

@ -17,6 +17,7 @@ type Campaign struct {
Name string `json:"name" sql:"not null"`
CreatedDate time.Time `json:"created_date"`
LaunchDate time.Time `json:"launch_date"`
SendByDate time.Time `json:"send_by_date"`
CompletedDate time.Time `json:"completed_date"`
TemplateId int64 `json:"-"`
Template Template `json:"template"`
@ -52,6 +53,7 @@ type CampaignSummary struct {
Id int64 `json:"id"`
CreatedDate time.Time `json:"created_date"`
LaunchDate time.Time `json:"launch_date"`
SendByDate time.Time `json:"send_by_date"`
CompletedDate time.Time `json:"completed_date"`
Status string `json:"status"`
Name string `json:"name"`
@ -120,6 +122,10 @@ var ErrPageNotFound = errors.New("Page not found")
// ErrSMTPNotFound indicates a sending profile specified by the user does not exist in the database
var ErrSMTPNotFound = errors.New("Sending profile not found")
// ErrInvalidSendByDate indicates that the user specified a send by date that occurs before the
// launch date
var ErrInvalidSendByDate = errors.New("The launch date must be before the \"send emails by\" date")
// RecipientParameter is the URL parameter that points to the result ID for a recipient.
const RecipientParameter = "rid"
@ -136,6 +142,8 @@ func (c *Campaign) Validate() error {
return ErrPageNotSpecified
case c.SMTP.Name == "":
return ErrSMTPNotSpecified
case !c.SendByDate.IsZero() && !c.LaunchDate.IsZero() && c.SendByDate.Before(c.LaunchDate):
return ErrInvalidSendByDate
}
return nil
}
@ -218,6 +226,27 @@ func (c *Campaign) getFromAddress() string {
return c.SMTP.FromAddress
}
// generateSendDate creates a sendDate
func (c *Campaign) generateSendDate(idx int, totalRecipients int) time.Time {
// If no send date is specified, just return the launch date
if c.SendByDate.IsZero() || c.SendByDate.Equal(c.LaunchDate) {
return c.LaunchDate
}
// Otherwise, we can calculate the range of minutes to send emails
// (since we only poll once per minute)
totalMinutes := c.SendByDate.Sub(c.LaunchDate).Minutes()
// Next, we can determine how many minutes should elapse between emails
minutesPerEmail := totalMinutes / float64(totalRecipients)
// Then, we can calculate the offset for this particular email
offset := int(minutesPerEmail * float64(idx))
// Finally, we can just add this offset to the launch date to determine
// when the email should be sent
return c.LaunchDate.Add(time.Duration(offset) * time.Minute)
}
// getCampaignStats returns a CampaignStats object for the campaign with the given campaign ID.
// It also backfills numbers as appropriate with a running total, so that the values are aggregated.
func getCampaignStats(cid int64) (CampaignStats, error) {
@ -387,10 +416,16 @@ func PostCampaign(c *Campaign, uid int64) error {
} else {
c.LaunchDate = c.LaunchDate.UTC()
}
if !c.SendByDate.IsZero() {
c.SendByDate = c.SendByDate.UTC()
}
if c.LaunchDate.Before(c.CreatedDate) || c.LaunchDate.Equal(c.CreatedDate) {
c.Status = CAMPAIGN_IN_PROGRESS
}
// Check to make sure all the groups already exist
// Also, later we'll need to know the total number of recipients (counting
// duplicates is ok for now), so we'll do that here to save a loop.
totalRecipients := 0
for i, g := range c.Groups {
c.Groups[i], err = GetGroupByName(g.Name, uid)
if err == gorm.ErrRecordNotFound {
@ -402,6 +437,7 @@ func PostCampaign(c *Campaign, uid int64) error {
log.Error(err)
return err
}
totalRecipients += len(c.Groups[i].Targets)
}
// Check to make sure the template exists
t, err := GetTemplateByName(c.Template.Name, uid)
@ -454,6 +490,7 @@ func PostCampaign(c *Campaign, uid int64) error {
}
// Insert all the results
resultMap := make(map[string]bool)
recipientIndex := 0
for _, g := range c.Groups {
// Insert a result for each target in the group
for _, t := range g.Targets {
@ -463,6 +500,7 @@ func PostCampaign(c *Campaign, uid int64) error {
continue
}
resultMap[t.Email] = true
sendDate := c.generateSendDate(recipientIndex, totalRecipients)
r := &Result{
BaseRecipient: BaseRecipient{
Email: t.Email,
@ -473,11 +511,11 @@ func PostCampaign(c *Campaign, uid int64) error {
Status: STATUS_SCHEDULED,
CampaignId: c.Id,
UserId: c.UserId,
SendDate: c.LaunchDate,
SendDate: sendDate,
Reported: false,
ModifiedDate: c.CreatedDate,
}
if c.Status == CAMPAIGN_IN_PROGRESS {
if r.SendDate.Before(c.CreatedDate) || r.SendDate.Equal(c.CreatedDate) {
r.Status = STATUS_SENDING
}
err = r.GenerateId()
@ -492,11 +530,13 @@ func PostCampaign(c *Campaign, uid int64) error {
}).Error(err)
}
c.Results = append(c.Results, *r)
err = GenerateMailLog(c, r)
log.Infof("Creating maillog for %s to send at %s\n", r.Email, sendDate)
err = GenerateMailLog(c, r, sendDate)
if err != nil {
log.Error(err)
continue
}
recipientIndex++
}
}
err = db.Save(c).Error

81
models/campaign_test.go Normal file
View File

@ -0,0 +1,81 @@
package models
import (
"time"
check "gopkg.in/check.v1"
)
func (s *ModelsSuite) TestGenerateSendDate(c *check.C) {
campaign := s.createCampaignDependencies(c)
// Test that if no launch date is provided, the campaign's creation date
// is used.
err := PostCampaign(&campaign, campaign.UserId)
c.Assert(err, check.Equals, nil)
c.Assert(campaign.LaunchDate, check.Equals, campaign.CreatedDate)
ms, err := GetMailLogsByCampaign(campaign.Id)
c.Assert(err, check.Equals, nil)
for _, m := range ms {
c.Assert(m.SendDate, check.Equals, campaign.CreatedDate)
}
// Test that if no send date is provided, all the emails are sent at the
// campaign's launch date
campaign = s.createCampaignDependencies(c)
campaign.LaunchDate = time.Now().UTC()
err = PostCampaign(&campaign, campaign.UserId)
c.Assert(err, check.Equals, nil)
ms, err = GetMailLogsByCampaign(campaign.Id)
c.Assert(err, check.Equals, nil)
for _, m := range ms {
c.Assert(m.SendDate, check.Equals, campaign.LaunchDate)
}
// Finally, test that if a send date is provided, the emails are staggered
// correctly.
campaign = s.createCampaignDependencies(c)
campaign.LaunchDate = time.Now().UTC()
campaign.SendByDate = campaign.LaunchDate.Add(2 * time.Minute)
err = PostCampaign(&campaign, campaign.UserId)
c.Assert(err, check.Equals, nil)
ms, err = GetMailLogsByCampaign(campaign.Id)
c.Assert(err, check.Equals, nil)
sendingOffset := 2 / float64(len(ms))
for i, m := range ms {
expectedOffset := int(sendingOffset * float64(i))
expectedDate := campaign.LaunchDate.Add(time.Duration(expectedOffset) * time.Minute)
c.Assert(m.SendDate, check.Equals, expectedDate)
}
}
func (s *ModelsSuite) TestCampaignDateValidation(c *check.C) {
campaign := s.createCampaignDependencies(c)
// If both are zero, then the campaign should start immediately with no
// send by date
err := campaign.Validate()
c.Assert(err, check.Equals, nil)
// If the launch date is specified, then the send date is optional
campaign = s.createCampaignDependencies(c)
campaign.LaunchDate = time.Now().UTC()
err = campaign.Validate()
c.Assert(err, check.Equals, nil)
// If the send date is greater than the launch date, then there's no
//problem
campaign = s.createCampaignDependencies(c)
campaign.LaunchDate = time.Now().UTC()
campaign.SendByDate = campaign.LaunchDate.Add(1 * time.Minute)
err = campaign.Validate()
c.Assert(err, check.Equals, nil)
// If the send date is less than the launch date, then there's an issue
campaign = s.createCampaignDependencies(c)
campaign.LaunchDate = time.Now().UTC()
campaign.SendByDate = campaign.LaunchDate.Add(-1 * time.Minute)
err = campaign.Validate()
c.Assert(err, check.Equals, ErrInvalidSendByDate)
}

View File

@ -38,12 +38,12 @@ type MailLog struct {
// GenerateMailLog creates a new maillog for the given campaign and
// result. It sets the initial send date to match the campaign's launch date.
func GenerateMailLog(c *Campaign, r *Result) error {
func GenerateMailLog(c *Campaign, r *Result, sendDate time.Time) error {
m := &MailLog{
UserId: c.UserId,
CampaignId: c.Id,
RId: r.RId,
SendDate: c.LaunchDate,
SendDate: sendDate,
}
err = db.Save(m).Error
return err

View File

@ -193,7 +193,7 @@ func (s *ModelsSuite) TestGenerateMailLog(ch *check.C) {
result := Result{
RId: "abc1234",
}
err := GenerateMailLog(&campaign, &result)
err := GenerateMailLog(&campaign, &result, campaign.LaunchDate)
ch.Assert(err, check.Equals, nil)
m := MailLog{}

View File

@ -47,6 +47,8 @@ func (s *ModelsSuite) createCampaignDependencies(ch *check.C, optional ...string
group.Targets = []Target{
Target{BaseRecipient: BaseRecipient{Email: "test1@example.com", FirstName: "First", LastName: "Example"}},
Target{BaseRecipient: BaseRecipient{Email: "test2@example.com", FirstName: "Second", LastName: "Example"}},
Target{BaseRecipient: BaseRecipient{Email: "test3@example.com", FirstName: "Second", LastName: "Example"}},
Target{BaseRecipient: BaseRecipient{Email: "test4@example.com", FirstName: "Second", LastName: "Example"}},
}
group.UserId = 1
ch.Assert(PostGroup(&group), check.Equals, nil)

View File

@ -1,6 +1,7 @@
package models
import (
"fmt"
"net/mail"
"regexp"
"time"
@ -58,6 +59,25 @@ func (s *ModelsSuite) TestResultScheduledStatus(ch *check.C) {
}
}
func (s *ModelsSuite) TestResultVariableStatus(ch *check.C) {
c := s.createCampaignDependencies(ch)
c.LaunchDate = time.Now().UTC()
c.SendByDate = c.LaunchDate.Add(2 * time.Minute)
ch.Assert(PostCampaign(&c, c.UserId), check.Equals, nil)
// The campaign has a window smaller than our group size, so we expect some
// emails to be sent immediately, while others will be scheduled
for _, r := range c.Results {
if r.SendDate.Before(c.CreatedDate) || r.SendDate.Equal(c.CreatedDate) {
fmt.Println("SENDING")
ch.Assert(r.Status, check.Equals, STATUS_SENDING)
} else {
fmt.Println("SCHEDULED")
ch.Assert(r.Status, check.Equals, STATUS_SCHEDULED)
}
}
}
func (s *ModelsSuite) TestDuplicateResults(ch *check.C) {
group := Group{Name: "Test Group"}
group.Targets = []Target{

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -357,14 +357,15 @@ var renderDevice = function (event_details) {
}
function renderTimeline(data) {
console.log(data)
record = {
"first_name": data[2],
"last_name": data[3],
"email": data[4],
"position": data[5],
"status": data[6],
"send_date": data[7],
"reported": data[8]
"reported": data[7],
"send_date": data[8]
}
results = '<div class="timeline col-sm-12 well well-lg">' +
'<h6>Timeline for ' + escapeHtml(record.first_name) + ' ' + escapeHtml(record.last_name) +

View File

@ -33,6 +33,10 @@ function launch() {
});
})
// Validate our fields
var send_by_date = $("#send_by_date").val()
if (send_by_date != "") {
send_by_date = moment(send_by_date, "MM/DD/YYYY hh:mm a").utc().format()
}
campaign = {
name: $("#name").val(),
template: {
@ -46,7 +50,8 @@ function launch() {
name: $("#profile").select2("data")[0].text
},
launch_date: moment($("#launch_date").val(), "MM/DD/YYYY hh:mm a").utc().format(),
groups: groups
send_by_date: send_by_date || null,
groups: groups,
}
console.log("Launching campaign at time: " + campaign.launch_date)
// Submit the campaign
@ -267,6 +272,13 @@ $(document).ready(function () {
"showTodayButton": true,
"defaultDate": moment()
})
$("#send_by_date").datetimepicker({
"widgetPositioning": {
"vertical": "bottom"
},
"showTodayButton": true,
"useCurrent": false
})
// Setup multiple modals
// Code based on http://miles-by-motorcycle.com/static/bootstrap-modal/index.html
$('.modal').on('hidden.bs.modal', function (event) {

View File

@ -3,24 +3,35 @@
<div class="row">
<div class="col-sm-3 col-md-2 sidebar">
<ul class="nav nav-sidebar">
<li><a href="/">Dashboard</a>
<li>
<a href="/">Dashboard</a>
</li>
<li class="active"><a href="/campaigns">Campaigns</a>
<li class="active">
<a href="/campaigns">Campaigns</a>
</li>
<li><a href="/users">Users &amp; Groups</a>
<li>
<a href="/users">Users &amp; Groups</a>
</li>
<li><a href="/templates">Email Templates</a>
<li>
<a href="/templates">Email Templates</a>
</li>
<li><a href="/landing_pages">Landing Pages</a>
<li>
<a href="/landing_pages">Landing Pages</a>
</li>
<li><a href="/sending_profiles">Sending Profiles</a>
<li>
<a href="/sending_profiles">Sending Profiles</a>
</li>
<li><a href="/settings">Settings</a>
<li>
<a href="/settings">Settings</a>
</li>
<li><hr></li>
<li><a href="https://gophish.gitbooks.io/user-guide/content/">User Guide</a>
<li>
<hr>
</li>
<li><a href="/api/">API Documentation</a>
<li>
<a href="https://gophish.gitbooks.io/user-guide/content/">User Guide</a>
</li>
<li>
<a href="/api/">API Documentation</a>
</li>
</ul>
</div>
@ -34,7 +45,8 @@
</div>
<div id="flashes" class="row"></div>
<div class="row">
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#modal" onclick="edit('new')"><i class="fa fa-plus"></i> New Campaign</button>
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#modal" onclick="edit('new')">
<i class="fa fa-plus"></i> New Campaign</button>
</div>
&nbsp;
<div id="loading">
@ -62,87 +74,105 @@
</div>
<!-- Modal -->
<div class="modal fade" id="modal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close" onclick="dismiss()"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="campaignModalLabel">New Campaign</h4>
</div>
<div class="modal-body" id="modal_body">
<div class="row" id="modal.flashes"></div>
<div class="form-group">
<label for="name">Name:</label>
<input type="text" class="form-control" id="name" placeholder="Campaign name" autofocus>
<label class="control-label" for="template">Email Template:</label>
<select class="form-control" placeholder="Template Name" id="template"/>
<option></option>
</select>
<label class="control-label" for="page">Landing Page:</label>
<select class="form-control" placeholder="Landing Page" id="page"/>
<option></option>
</select>
<label class="control-label" for="url">URL: <i class="fa fa-question-circle" data-toggle="tooltip" data-placement="right" title="Location of gophish listener (must be reachable by targets!)"></i></label>
<input type="text" class="form-control" placeholder="http://192.168.1.1" id="url"/>
<label class="control-label" for="url">Schedule: </label>
<input type="text" class="form-control" id="launch_date"/>
<label class="control-label" for="profile">Sending Profile:</label>
<div class="input-group">
<select class="form-control" placeholder="Sending Profile" id="profile"/>
<option></option>
</select>
<span class="input-group-btn">
<button type="button" data-toggle="modal" data-target="#sendTestEmailModal" class="btn btn-primary button"><i class="fa fa-envelope"></i> Send Test Email</button>
</span>
</div>
<label class="control-label" for="users">Groups:</label>
<select class="form-control" id="users" multiple="multiple"></select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal" onclick="dismiss()">Close</button>
<button type="button" id="launchButton" class="btn btn-primary" onclick="launch()"><i class="fa fa-rocket"></i> Launch Campaign</button>
</div>
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close" onclick="dismiss()">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title" id="campaignModalLabel">New Campaign</h4>
</div>
<div class="modal-body" id="modal_body">
<div class="row" id="modal.flashes"></div>
<div class="form-group">
<label for="name">Name:</label>
<input type="text" class="form-control" id="name" placeholder="Campaign name" autofocus>
<label class="control-label" for="template">Email Template:</label>
<select class="form-control" placeholder="Template Name" id="template" />
<option></option>
</select>
<label class="control-label" for="page">Landing Page:</label>
<select class="form-control" placeholder="Landing Page" id="page" />
<option></option>
</select>
<label class="control-label" for="url">URL:
<i class="fa fa-question-circle" data-toggle="tooltip" data-placement="right" title="Location of Gophish listener (must be reachable by targets!)"></i>
</label>
<input type="text" class="form-control" placeholder="http://192.168.1.1" id="url" />
<div class="row">
<div class="col-md-6">
<label class="control-label" for="url">Launch Date </label>
<input type="text" class="form-control" id="launch_date" />
</div>
<div class="col-md-6">
<label class="control-label" for="delay">Send Emails By (Optional)
<i class="fa fa-question-circle" data-toggle="tooltip" data-placement="right" title="If specified, Gophish will send emails evenly between the campaign launch and this date."></i>
</label>
<input type="text" class="form-control" id="send_by_date" />
</div>
</div>
<label class="control-label" for="profile">Sending Profile:</label>
<div class="input-group">
<select class="form-control" placeholder="Sending Profile" id="profile" />
<option></option>
</select>
<span class="input-group-btn">
<button type="button" data-toggle="modal" data-target="#sendTestEmailModal" class="btn btn-primary button">
<i class="fa fa-envelope"></i> Send Test Email</button>
</span>
</div>
<label class="control-label" for="users">Groups:</label>
<select class="form-control" id="users" multiple="multiple"></select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal" onclick="dismiss()">Close</button>
<button type="button" id="launchButton" class="btn btn-primary" onclick="launch()">
<i class="fa fa-rocket"></i> Launch Campaign</button>
</div>
</div>
</div>
</div>
</div>
<!-- Send Test Email Modal -->
<div class="modal" id="sendTestEmailModal" tabindex="-1" role="dialog" aria-labelledby="modalLabel">
<div class="modal-dialog" role="document">
<div class="modal-content">
<!-- New Email Modal -->
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="sendTestEmailModalTitle">Send Test Email</h4>
</div>
<div class="modal-body">
<div class="row" id="sendTestEmailModal.flashes"></div>
<div class="row">
<div class="col-sm-12">
<label class="control-label" for="to">Send Test Email to:</label>
</div>
<br>
<div class="col-sm-2">
<input type="text" class="form-control" placeholder="First Name" name="to_first_name">
</div>
<div class="col-sm-2">
<input type="text" class="form-control" placeholder="Last Name" name="to_last_name">
</div>
<div class="col-sm-4">
<input type="email" class="form-control" placeholder="Email" name="to_email" required>
</div>
<div class="col-sm-4">
<input type="text" class="form-control" placeholder="Position" name="to_position">
<div class="modal-dialog" role="document">
<div class="modal-content">
<!-- New Email Modal -->
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title" id="sendTestEmailModalTitle">Send Test Email</h4>
</div>
<div class="modal-body">
<div class="row" id="sendTestEmailModal.flashes"></div>
<div class="row">
<div class="col-sm-12">
<label class="control-label" for="to">Send Test Email to:</label>
</div>
<br>
<div class="col-sm-2">
<input type="text" class="form-control" placeholder="First Name" name="to_first_name">
</div>
<div class="col-sm-2">
<input type="text" class="form-control" placeholder="Last Name" name="to_last_name">
</div>
<div class="col-sm-4">
<input type="email" class="form-control" placeholder="Email" name="to_email" required>
</div>
<div class="col-sm-4">
<input type="text" class="form-control" placeholder="Position" name="to_position">
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" data-dismiss="modal" class="btn btn-default">Cancel</button>
<button type="button" class="btn btn-primary" id="sendTestModalSubmit" onclick="sendTestEmail()"><i class="fa fa-envelope"></i> Send</button>
<div class="modal-footer">
<button type="button" data-dismiss="modal" class="btn btn-default">Cancel</button>
<button type="button" class="btn btn-primary" id="sendTestModalSubmit" onclick="sendTestEmail()">
<i class="fa fa-envelope"></i> Send</button>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
{{end}} {{define "scripts"}}
<script src="/js/dist/app/campaigns.min.js"></script>
{{end}}

View File

@ -79,7 +79,14 @@ func (w *Worker) LaunchCampaign(c models.Campaign) {
// This is required since you cannot pass a slice of values
// that implements an interface as a slice of that interface.
mailEntries := []mailer.Mail{}
currentTime := time.Now().UTC()
for _, m := range ms {
// Only send the emails scheduled to be sent for the past minute to
// respect the campaign scheduling options
if m.SendDate.After(currentTime) {
m.Unlock()
continue
}
mailEntries = append(mailEntries, m)
}
mailer.Mailer.Queue <- mailEntries
@ -88,7 +95,8 @@ func (w *Worker) LaunchCampaign(c models.Campaign) {
// SendTestEmail sends a test email
func (w *Worker) SendTestEmail(s *models.EmailRequest) error {
go func() {
mailer.Mailer.Queue <- []mailer.Mail{s}
ms := []mailer.Mail{s}
mailer.Mailer.Queue <- ms
}()
return <-s.ErrorChan
}