Merge branch 'master' into wifi.flood

This commit is contained in:
evilsocket 2018-03-19 12:54:29 +01:00
commit 8444a783a1
No known key found for this signature in database
GPG key ID: 1564D7F30393A456
19 changed files with 279 additions and 82 deletions

View file

@ -187,7 +187,7 @@ build_macos_amd64 && create_archive bettercap_macos_amd64_$VERSION.zip
build_android_arm && create_archive bettercap_android_arm_$VERSION.zip build_android_arm && create_archive bettercap_android_arm_$VERSION.zip
build_windows_amd64 && create_exe_archive bettercap_windows_amd64_$VERSION.zip build_windows_amd64 && create_exe_archive bettercap_windows_amd64_$VERSION.zip
build_linux_arm7_static && create_archive bettercap_linux_arm7_$VERSION.zip build_linux_arm7_static && create_archive bettercap_linux_arm7_$VERSION.zip
build_linux_arm7hf_static && create_archive bettercap_linux_arm7hf_$VERSION.zip # build_linux_arm7hf_static && create_archive bettercap_linux_arm7hf_$VERSION.zip
build_linux_mips_static && create_archive bettercap_linux_mips_$VERSION.zip build_linux_mips_static && create_archive bettercap_linux_mips_$VERSION.zip
build_linux_mipsle_static && create_archive bettercap_linux_mipsle_$VERSION.zip build_linux_mipsle_static && create_archive bettercap_linux_mipsle_$VERSION.zip
build_linux_mips64_static && create_archive bettercap_linux_mips64_$VERSION.zip build_linux_mips64_static && create_archive bettercap_linux_mips64_$VERSION.zip

View file

@ -2,7 +2,7 @@ package core
const ( const (
Name = "bettercap" Name = "bettercap"
Version = "2.1" Version = "2.2"
Author = "Simone 'evilsocket' Margaritelli" Author = "Simone 'evilsocket' Margaritelli"
Website = "https://bettercap.org/" Website = "https://bettercap.org/"
) )

View file

@ -19,7 +19,7 @@ type Options struct {
func ParseOptions() (Options, error) { func ParseOptions() (Options, error) {
o := Options{ o := Options{
InterfaceName: flag.String("iface", "", "Network interface to bind to, if empty the default interface will be auto selected."), InterfaceName: flag.String("iface", "", "Network interface to bind to, if empty the default interface will be auto selected."),
AutoStart: flag.String("autostart", "events.stream, net.recon", "Comma separated list of modules to auto start."), AutoStart: flag.String("autostart", "events.stream, net.recon, update.check", "Comma separated list of modules to auto start."),
Caplet: flag.String("caplet", "", "Read commands from this file and execute them in the interactive session."), Caplet: flag.String("caplet", "", "Read commands from this file and execute them in the interactive session."),
Debug: flag.Bool("debug", false, "Print debug messages."), Debug: flag.Bool("debug", false, "Print debug messages."),
Silent: flag.Bool("silent", false, "Suppress all logs which are not errors."), Silent: flag.Bool("silent", false, "Suppress all logs which are not errors."),

View file

@ -5,6 +5,15 @@ import (
"os" "os"
) )
const (
DEBUG = iota
INFO
IMPORTANT
WARNING
ERROR
FATAL
)
// https://misc.flogisoft.com/bash/tip_colors_and_formatting // https://misc.flogisoft.com/bash/tip_colors_and_formatting
var ( var (
BOLD = "\033[1m" BOLD = "\033[1m"
@ -26,6 +35,24 @@ var (
RESET = "\033[0m" RESET = "\033[0m"
LogLabels = map[int]string{
DEBUG: "dbg",
INFO: "inf",
IMPORTANT: "imp",
WARNING: "war",
ERROR: "err",
FATAL: "!!!",
}
LogColors = map[int]string{
DEBUG: DIM + FG_BLACK + BG_DGRAY,
INFO: FG_WHITE + BG_GREEN,
IMPORTANT: FG_WHITE + BG_LBLUE,
WARNING: FG_WHITE + BG_YELLOW,
ERROR: FG_WHITE + BG_RED,
FATAL: FG_WHITE + BG_RED + BOLD,
}
HasColors = true HasColors = true
) )
@ -51,38 +78,20 @@ func InitSwag(disableColors bool) {
BG_YELLOW = "" BG_YELLOW = ""
BG_LBLUE = "" BG_LBLUE = ""
RESET = "" RESET = ""
LogColors = map[int]string{
DEBUG: "",
INFO: "",
IMPORTANT: "",
WARNING: "",
ERROR: "",
FATAL: "",
}
HasColors = false HasColors = false
} }
} }
const (
DEBUG = iota
INFO
IMPORTANT
WARNING
ERROR
FATAL
)
var (
LogLabels = map[int]string{
DEBUG: "dbg",
INFO: "inf",
IMPORTANT: "imp",
WARNING: "war",
ERROR: "err",
FATAL: "!!!",
}
LogColors = map[int]string{
DEBUG: DIM + FG_BLACK + BG_DGRAY,
INFO: FG_WHITE + BG_GREEN,
IMPORTANT: FG_WHITE + BG_LBLUE,
WARNING: FG_WHITE + BG_YELLOW,
ERROR: FG_WHITE + BG_RED,
FATAL: FG_WHITE + BG_RED + BOLD,
}
)
// W for Wrap // W for Wrap
func W(e, s string) string { func W(e, s string) string {
return e + s + RESET return e + s + RESET

View file

@ -35,26 +35,14 @@ func (f WindowsFirewall) IsForwardingEnabled() bool {
} }
} }
func (f WindowsFirewall) isSuccess(output string) bool {
if trimmed := core.Trim(strings.ToUpper(output)); trimmed == "" || strings.Contains(trimmed, "OK") == true {
return true
} else {
return false
}
}
func (f WindowsFirewall) EnableForwarding(enabled bool) error { func (f WindowsFirewall) EnableForwarding(enabled bool) error {
v := "enabled" v := "enabled"
if enabled == false { if enabled == false {
v = "disabled" v = "disabled"
} }
out, err := core.Exec("netsh", []string{"interface", "ipv4", "set", "interface", fmt.Sprintf("%d", f.iface.Index), fmt.Sprintf("forwarding=\"%s\"", v)})
if err != nil {
return err
}
if f.isSuccess(out) == false { if _, err := core.Exec("netsh", []string{"interface", "ipv4", "set", "interface", fmt.Sprintf("%d", f.iface.Index), fmt.Sprintf("forwarding=\"%s\"", v)}); err != nil {
return fmt.Errorf("Unexpected netsh output: %s", out) return err
} }
return nil return nil
@ -90,15 +78,10 @@ func (f *WindowsFirewall) AllowPort(port int, address string, proto string, allo
cmd = []string{"advfirewall", "firewall", "delete", "rule", nameField, protoField, portField} cmd = []string{"advfirewall", "firewall", "delete", "rule", nameField, protoField, portField}
} }
out, err := core.Exec("netsh", cmd) if _, err := core.Exec("netsh", cmd); err != nil {
if err != nil {
return err return err
} }
if f.isSuccess(out) == false {
return fmt.Errorf("Unexpected netsh output: %s", out)
}
return nil return nil
} }
@ -116,14 +99,10 @@ func (f *WindowsFirewall) EnableRedirection(r *Redirection, enabled bool) error
rule = append([]string{"interface", "portproxy", "delete", "v4tov4"}, rule...) rule = append([]string{"interface", "portproxy", "delete", "v4tov4"}, rule...)
} }
out, err := core.Exec("netsh", rule) if _, err := core.Exec("netsh", rule); err != nil {
if err != nil {
return err return err
} }
if f.isSuccess(out) == false {
return fmt.Errorf("Unexpected netsh output: %s", out)
}
return nil return nil
} }

View file

@ -40,6 +40,7 @@ func main() {
sess.Register(modules.NewEventsStream(sess)) sess.Register(modules.NewEventsStream(sess))
sess.Register(modules.NewTicker(sess)) sess.Register(modules.NewTicker(sess))
sess.Register(modules.NewUpdateModule(sess))
sess.Register(modules.NewMacChanger(sess)) sess.Register(modules.NewMacChanger(sess))
sess.Register(modules.NewProber(sess)) sess.Register(modules.NewProber(sess))
sess.Register(modules.NewDiscovery(sess)) sess.Register(modules.NewDiscovery(sess))

View file

@ -153,12 +153,11 @@ func (p *ArpSpoofer) parseTargets(targets string) (err error) {
targets = strings.Replace(targets, mac, "", -1) targets = strings.Replace(targets, mac, "", -1)
} }
targets = strings.TrimLeft(targets, ", ") targets = strings.Trim(targets, ", ")
targets = strings.TrimRight(targets, ", ")
log.Debug("Parsing IP range %s", targets) log.Debug("Parsing IP range %s", targets)
if len(p.macs) == 0 || targets != "" { if len(p.macs) == 0 || targets != "" {
list, err := iprange.Parse(targets) list, err := iprange.ParseList(targets)
if err != nil { if err != nil {
return fmt.Errorf("Error while parsing arp.spoof.targets variable '%s': %s.", targets, err) return fmt.Errorf("Error while parsing arp.spoof.targets variable '%s': %s.", targets, err)
} }

View file

@ -1,6 +1,7 @@
package modules package modules
import ( import (
"encoding/base64"
"io/ioutil" "io/ioutil"
"sync" "sync"
@ -109,6 +110,69 @@ func (s *ProxyScript) defineBuiltins() error {
return otto.Value{} return otto.Value{}
}) })
// log debug
s.VM.Set("log_debug", func(call otto.FunctionCall) otto.Value {
for _, v := range call.ArgumentList {
log.Debug("%s", v.String())
}
return otto.Value{}
})
// log info
s.VM.Set("log_info", func(call otto.FunctionCall) otto.Value {
for _, v := range call.ArgumentList {
log.Info("%s", v.String())
}
return otto.Value{}
})
// log warning
s.VM.Set("log_warn", func(call otto.FunctionCall) otto.Value {
for _, v := range call.ArgumentList {
log.Warning("%s", v.String())
}
return otto.Value{}
})
// log error
s.VM.Set("log_error", func(call otto.FunctionCall) otto.Value {
for _, v := range call.ArgumentList {
log.Error("%s", v.String())
}
return otto.Value{}
})
// log fatal
s.VM.Set("log_fatal", func(call otto.FunctionCall) otto.Value {
for _, v := range call.ArgumentList {
log.Fatal("%s", v.String())
}
return otto.Value{}
})
// javascript btoa function
s.VM.Set("btoa", func(call otto.FunctionCall) otto.Value {
varValue := base64.StdEncoding.EncodeToString([]byte(call.Argument(0).String()))
v, err := s.VM.ToValue(varValue)
if err != nil {
return errOtto("Could not convert to string: %s", varValue)
}
return v
})
// javascript atob function
s.VM.Set("atob", func(call otto.FunctionCall) otto.Value {
varValue, err := base64.StdEncoding.DecodeString(call.Argument(0).String())
if err != nil {
return errOtto("Could not decode string: %s", call.Argument(0).String())
}
v, err := s.VM.ToValue(string(varValue))
if err != nil {
return errOtto("Could not convert to string: %s", varValue)
}
return v
})
// read or write environment variable // read or write environment variable
s.VM.Set("env", func(call otto.FunctionCall) otto.Value { s.VM.Set("env", func(call otto.FunctionCall) otto.Value {
argv := call.ArgumentList argv := call.ArgumentList

View file

@ -10,6 +10,8 @@ import (
"github.com/bettercap/bettercap/core" "github.com/bettercap/bettercap/core"
"github.com/bettercap/bettercap/network" "github.com/bettercap/bettercap/network"
"github.com/bettercap/bettercap/session" "github.com/bettercap/bettercap/session"
"github.com/google/go-github/github"
) )
const eventTimeFormat = "15:04:05" const eventTimeFormat = "15:04:05"
@ -23,7 +25,6 @@ func (s *EventsStream) viewLogEvent(e session.Event) {
} }
func (s *EventsStream) viewWiFiEvent(e session.Event) { func (s *EventsStream) viewWiFiEvent(e session.Event) {
if strings.HasPrefix(e.Tag, "wifi.ap.") { if strings.HasPrefix(e.Tag, "wifi.ap.") {
ap := e.Data.(*network.AccessPoint) ap := e.Data.(*network.AccessPoint)
vend := "" vend := ""
@ -167,6 +168,16 @@ func (s *EventsStream) viewSynScanEvent(e session.Event) {
core.Bold(se.Address)) core.Bold(se.Address))
} }
func (s *EventsStream) viewUpdateEvent(e session.Event) {
update := e.Data.(*github.RepositoryRelease)
fmt.Fprintf(s.output, "[%s] [%s] An update to version %s is available at %s\n",
e.Time.Format(eventTimeFormat),
core.Bold(core.Yellow(e.Tag)),
core.Bold(*update.TagName),
*update.HTMLURL)
}
func (s *EventsStream) View(e session.Event, refresh bool) { func (s *EventsStream) View(e session.Event, refresh bool) {
if e.Tag == "sys.log" { if e.Tag == "sys.log" {
s.viewLogEvent(e) s.viewLogEvent(e)
@ -180,8 +191,10 @@ func (s *EventsStream) View(e session.Event, refresh bool) {
s.viewModuleEvent(e) s.viewModuleEvent(e)
} else if strings.HasPrefix(e.Tag, "net.sniff.") { } else if strings.HasPrefix(e.Tag, "net.sniff.") {
s.viewSnifferEvent(e) s.viewSnifferEvent(e)
} else if strings.HasPrefix(e.Tag, "syn.scan.") { } else if e.Tag == "syn.scan" {
s.viewSynScanEvent(e) s.viewSynScanEvent(e)
} else if e.Tag == "update.available" {
s.viewUpdateEvent(e)
} else { } else {
fmt.Fprintf(s.output, "[%s] [%s] %v\n", e.Time.Format(eventTimeFormat), core.Green(e.Tag), e) fmt.Fprintf(s.output, "[%s] [%s] %v\n", e.Time.Format(eventTimeFormat), core.Green(e.Tag), e)
} }

View file

@ -39,9 +39,6 @@ func (t *CookieTracker) keyOf(req *http.Request) string {
} }
func (t *CookieTracker) IsClean(req *http.Request) bool { func (t *CookieTracker) IsClean(req *http.Request) bool {
// t.RLock()
// defer t.RUnlock()
// we only clean GET requests // we only clean GET requests
if req.Method != "GET" { if req.Method != "GET" {
return true return true
@ -53,6 +50,9 @@ func (t *CookieTracker) IsClean(req *http.Request) bool {
return true return true
} }
t.RLock()
defer t.RUnlock()
// was it already processed? // was it already processed?
if _, found := t.set[t.keyOf(req)]; found == true { if _, found := t.set[t.keyOf(req)]; found == true {
return true return true
@ -65,8 +65,7 @@ func (t *CookieTracker) IsClean(req *http.Request) bool {
func (t *CookieTracker) Track(req *http.Request) { func (t *CookieTracker) Track(req *http.Request) {
t.Lock() t.Lock()
defer t.Unlock() defer t.Unlock()
reqKey := t.keyOf(req) t.set[t.keyOf(req)] = true
t.set[reqKey] = true
} }
func (t *CookieTracker) Expire(req *http.Request) *http.Response { func (t *CookieTracker) Expire(req *http.Request) *http.Response {

View file

@ -88,6 +88,16 @@ func (j *JSRequest) WasModified() bool {
return false return false
} }
func (j *JSRequest) Header(name, deflt string) string {
name = strings.ToLower(name)
for _, h := range j.Headers {
if name == strings.ToLower(h.Name) {
return h.Value
}
}
return deflt
}
func (j *JSRequest) ReadBody() string { func (j *JSRequest) ReadBody() string {
raw, err := ioutil.ReadAll(j.req.Body) raw, err := ioutil.ReadAll(j.req.Body)
if err != nil { if err != nil {

View file

@ -73,6 +73,17 @@ func (j *JSResponse) WasModified() bool {
return false return false
} }
func (j *JSResponse) Header(name, deflt string) string {
name = strings.ToLower(name)
for _, header := range strings.Split(j.Headers, "\n") {
parts := strings.SplitN(core.Trim(header), ":", 2)
if len(parts) == 2 && strings.ToLower(parts[0]) == name {
return parts[1]
}
}
return deflt
}
func (j *JSResponse) ToResponse(req *http.Request) (resp *http.Response) { func (j *JSResponse) ToResponse(req *http.Request) (resp *http.Response) {
resp = goproxy.NewResponse(req, j.ContentType, j.Status, j.Body) resp = goproxy.NewResponse(req, j.ContentType, j.Status, j.Body)
if j.Headers != "" { if j.Headers != "" {

View file

@ -38,7 +38,7 @@ func NewSynScanner(s *session.Session) *SynScanner {
waitGroup: &sync.WaitGroup{}, waitGroup: &sync.WaitGroup{},
} }
ss.AddHandler(session.NewModuleHandler("syn.scan IP-RANGE START-PORT END-PORT", "syn.scan ([^\\s]+) (\\d+)([\\s\\d]*)", ss.AddHandler(session.NewModuleHandler("syn.scan IP-RANGE [START-PORT] [END-PORT]", "syn.scan ([^\\s]+) ?(\\d+)?([\\s\\d]*)?",
"Perform a syn port scanning against an IP address within the provided ports range.", "Perform a syn port scanning against an IP address within the provided ports range.",
func(args []string) error { func(args []string) error {
if ss.Running() == true { if ss.Running() == true {
@ -50,10 +50,12 @@ func NewSynScanner(s *session.Session) *SynScanner {
return fmt.Errorf("Error while parsing IP range '%s': %s", args[0], err) return fmt.Errorf("Error while parsing IP range '%s': %s", args[0], err)
} }
argc := len(args)
ss.addresses = list.Expand() ss.addresses = list.Expand()
ss.startPort = 0 ss.startPort = 1
ss.endPort = 0 ss.endPort = 65535
if argc > 1 && core.Trim(args[1]) != "" {
if ss.startPort, err = strconv.Atoi(core.Trim(args[1])); err != nil { if ss.startPort, err = strconv.Atoi(core.Trim(args[1])); err != nil {
return fmt.Errorf("Invalid START-PORT: %s", err) return fmt.Errorf("Invalid START-PORT: %s", err)
} }
@ -62,8 +64,8 @@ func NewSynScanner(s *session.Session) *SynScanner {
ss.startPort = 65535 ss.startPort = 65535
} }
ss.endPort = ss.startPort ss.endPort = ss.startPort
}
argc := len(args)
if argc > 2 && core.Trim(args[2]) != "" { if argc > 2 && core.Trim(args[2]) != "" {
if ss.endPort, err = strconv.Atoi(core.Trim(args[2])); err != nil { if ss.endPort, err = strconv.Atoi(core.Trim(args[2])); err != nil {
return fmt.Errorf("Invalid END-PORT: %s", err) return fmt.Errorf("Invalid END-PORT: %s", err)

96
modules/update.go Normal file
View file

@ -0,0 +1,96 @@
package modules
import (
"context"
"math"
"strconv"
"strings"
"github.com/bettercap/bettercap/core"
"github.com/bettercap/bettercap/log"
"github.com/bettercap/bettercap/session"
"github.com/google/go-github/github"
)
type UpdateModule struct {
session.SessionModule
client *github.Client
}
func NewUpdateModule(s *session.Session) *UpdateModule {
u := &UpdateModule{
SessionModule: session.NewSessionModule("update", s),
client: github.NewClient(nil),
}
u.AddHandler(session.NewModuleHandler("update.check on", "",
"Check latest available stable version and compare it with the one being used.",
func(args []string) error {
return u.Start()
}))
return u
}
func (u *UpdateModule) Name() string {
return "update"
}
func (u *UpdateModule) Description() string {
return "A module to check for bettercap's updates."
}
func (u *UpdateModule) Author() string {
return "Simone Margaritelli <evilsocket@protonmail.com>"
}
func (u *UpdateModule) Configure() error {
return nil
}
func (u *UpdateModule) Stop() error {
return nil
}
func (u *UpdateModule) versionToNum(ver string) float64 {
if ver[0] == 'v' {
ver = ver[1:]
}
n := 0.0
parts := strings.Split(ver, ".")
nparts := len(parts)
// reverse
for i := nparts/2 - 1; i >= 0; i-- {
opp := nparts - 1 - i
parts[i], parts[opp] = parts[opp], parts[i]
}
for i, e := range parts {
ev, _ := strconv.Atoi(e)
n += float64(ev) * math.Pow10(i)
}
return n
}
func (u *UpdateModule) Start() error {
return u.SetRunning(true, func() {
defer u.SetRunning(false, nil)
log.Info("Checking latest stable release ...")
if releases, _, err := u.client.Repositories.ListReleases(context.Background(), "bettercap", "bettercap", nil); err == nil {
latest := releases[0]
if u.versionToNum(core.Version) < u.versionToNum(*latest.TagName) {
u.Session.Events.Add("update.available", latest)
} else {
log.Info("You are running %s which is the latest stable version.", core.Bold(core.Version))
}
} else {
log.Error("Error while fetching latest release info from GitHub: %s", err)
}
})
}

View file

@ -38,6 +38,10 @@ func NewBLE(newcb BLEDevNewCallback, lostcb BLEDevLostCallback) *BLE {
} }
} }
func (b *BLE) Get(id string) (dev *BLEDevice, found bool) {
return
}
func (b *BLE) MarshalJSON() ([]byte, error) { func (b *BLE) MarshalJSON() ([]byte, error) {
doc := bleJSON{ doc := bleJSON{
Devices: make([]*BLEDevice, 0), Devices: make([]*BLEDevice, 0),

View file

@ -8493,6 +8493,7 @@ F88E85 Comtrend
607EDD Microsoft Mobile Oy 607EDD Microsoft Mobile Oy
F88096 Elsys Equipamentos Eletrônicos Ltda F88096 Elsys Equipamentos Eletrônicos Ltda
E0B9E5 Technicolor E0B9E5 Technicolor
E2B9E5 Technicolor
0CBF15 Genetec 0CBF15 Genetec
000B5D Fujitsu Limited 000B5D Fujitsu Limited
F4CAE5 Freebox SAS F4CAE5 Freebox SAS

View file

@ -10315,6 +10315,7 @@ var oui = map[string]string {
"5cf286": "Ieee Registration Authority", "5cf286": "Ieee Registration Authority",
"001098": "Starnet Technologies", "001098": "Starnet Technologies",
"001099": "InnoMedia", "001099": "InnoMedia",
"e2b9e5": "Technicolor",
"24ee3a": "Chengdu Yingji Electronic Hi-tech Co", "24ee3a": "Chengdu Yingji Electronic Hi-tech Co",
"00093f": "Double-Win Enterpirse CO.", "00093f": "Double-Win Enterpirse CO.",
"0026c6": "Intel Corporate", "0026c6": "Intel Corporate",

View file

@ -57,7 +57,7 @@ func NewEventPool(debug bool, silent bool) *EventPool {
func (p *EventPool) Listen() <-chan Event { func (p *EventPool) Listen() <-chan Event {
p.Lock() p.Lock()
defer p.Unlock() defer p.Unlock()
l := make(chan Event, 1) l := make(chan Event, 255)
p.listeners = append(p.listeners, l) p.listeners = append(p.listeners, l)
return l return l
} }
@ -86,6 +86,7 @@ func (p *EventPool) Add(tag string, data interface{}) {
select { select {
case l <- e: case l <- e:
default: default:
fmt.Fprintf(os.Stderr, "Message not sent!\n")
} }
} }
} }

View file

@ -8,6 +8,7 @@ import (
"net" "net"
"os" "os"
"os/signal" "os/signal"
"regexp"
"runtime" "runtime"
"runtime/pprof" "runtime/pprof"
"sort" "sort"
@ -32,6 +33,8 @@ var (
ErrAlreadyStarted = errors.New("Module is already running.") ErrAlreadyStarted = errors.New("Module is already running.")
ErrAlreadyStopped = errors.New("Module is not running.") ErrAlreadyStopped = errors.New("Module is not running.")
reCmdSpaceCleaner = regexp.MustCompile(`^([^\s]+)\s+(.+)$`)
) )
type Session struct { type Session struct {
@ -254,7 +257,6 @@ func (s *Session) Close() {
} }
s.Firewall.Restore() s.Firewall.Restore()
s.Queue.Stop()
if *s.Options.EnvFile != "" { if *s.Options.EnvFile != "" {
envFile, _ := core.ExpandPath(*s.Options.EnvFile) envFile, _ := core.ExpandPath(*s.Options.EnvFile)
@ -480,6 +482,11 @@ func (s *Session) RunCaplet(filename string) error {
func (s *Session) Run(line string) error { func (s *Session) Run(line string) error {
line = core.TrimRight(line) line = core.TrimRight(line)
// remove extra spaces after the first command
// so that 'arp.spoof on' is normalized
// to 'arp.spoof on' (fixes #178)
line = reCmdSpaceCleaner.ReplaceAllString(line, "$1 $2")
for _, h := range s.CoreHandlers { for _, h := range s.CoreHandlers {
if parsed, args := h.Parse(line); parsed == true { if parsed, args := h.Parse(line); parsed == true {
return h.Exec(args, s) return h.Exec(args, s)