├── .gitignore ├── Makefile ├── README.md ├── bin └── .gitkeep ├── cmd └── main.go ├── go.mod ├── internal ├── config.go ├── logger.go └── reader.go ├── ip_spec.png ├── log_by_key.png ├── log_by_pass.png ├── ssh_notify.conf └── ssh_notify.service /.gitignore: -------------------------------------------------------------------------------- 1 | main 2 | .idea 3 | /bin/* 4 | !/bin/.gitkeep -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | COMMIT?=$(shell git rev-parse HEAD) 2 | BUILD_TIME?=$(shell date -u '+%Y-%m-%d_%H:%M:%S') 3 | 4 | SERVER_NAME := $(or ${name},${name},"") 5 | TG_TOKEN := $(or ${tg_token},${tg_token},"") 6 | TG_CHAT := $(or ${tg_chat},${tg_chat},"") 7 | SLACK_TOKEN := $(or ${sl_token},${sl_token},"") 8 | SLACK_CHANEL := $(or ${sl_chn},${sl_chn},"") 9 | KNOWN_IPS := $(or ${ips},${ips},"") 10 | 11 | help: 12 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 13 | 14 | build: ## Builds binary 15 | @echo "-- building binary" 16 | go build -ldflags "-X main.buildHash=${COMMIT} -X main.buildTime=${BUILD_TIME}" -o ./bin/ssh_notify ./cmd 17 | 18 | set_binary: build ## Copy binary to /usr/bin 19 | @echo "-- copy binary" 20 | sudo cp ./bin/ssh_notify /usr/bin/ 21 | 22 | install: set_binary ## Install service 23 | @echo "-- create sample config" 24 | cp ssh_notify.conf ssh_notify.conf.tmp 25 | 26 | @echo "-- set config values" 27 | 28 | @sed -i s/test_test_server_name/$(SERVER_NAME)/g ssh_notify.conf.tmp 29 | @sed -i s/YOUR_TELEGRAM_BOT_TOKEN_HERE/$(TG_TOKEN)/g ssh_notify.conf.tmp 30 | @sed -i s/YOUR_TELEGRAM_CHAT_HERE/$(TG_CHAT)/g ssh_notify.conf.tmp 31 | @sed -i s/YOUR_SLACK_BOT_TOKEN_HERE/$(SLACK_TOKEN)/g ssh_notify.conf.tmp 32 | @sed -i s/YOUR_SLACK_CHANNEL_HERE/$(SLACK_CHANEL)/g ssh_notify.conf.tmp 33 | @sed -i s/SET_KNOWN_IP_LIST_HERE/$(KNOWN_IPS)/g ssh_notify.conf.tmp 34 | 35 | sudo cp ssh_notify.conf.tmp /etc/ssh_notify.conf 36 | rm ssh_notify.conf.tmp 37 | 38 | @echo "-- creating service" 39 | sudo mkdir -p /etc/systemd/system 40 | sudo cp ssh_notify.service /etc/systemd/system/ssh_notify.service 41 | 42 | @echo "-- enable service" 43 | sudo service ssh_notify start && sudo systemctl enable ssh_notify 44 | 45 | remove: 46 | @echo "-- remove service" 47 | sudo service ssh_notify stop 48 | sudo systemctl disable ssh_notify 49 | sudo rm /etc/systemd/system/ssh_notify.service 50 | sudo rm /etc/ssh_notify.conf 51 | 52 | .PHONY: remove install set_binary build help 53 | .DEFAULT_GOAL := help -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Info 2 | 3 | Notify about new ssh login on your server into telegram/slack 4 | 5 | ## Sample 6 | > notify on key login 7 | 8 | ![Repo_List](log_by_key.png) 9 | 10 | 11 | > notify on password login 12 | 13 | ![Repo_List](log_by_pass.png) 14 | 15 | > notify if list of known ips specified (known/unknown ip login) 16 | 17 | ![Repo_List](ip_spec.png) 18 | 19 | ## Build 20 | You'll need go v1.13 or later 21 | 22 | ### Install Go 23 | ```shell script 24 | sudo add-apt-repository ppa:longsleep/golang-backports 25 | sudo apt-get update 26 | sudo apt-get install golang-go 27 | # or via snap 28 | snap install go --classic 29 | ``` 30 | 31 | ### Ansible script 32 | script for create for `admin` user 33 | roles/ssh_notify/tasks/main.yml 34 | ```yaml 35 | --- 36 | - name: Install go 37 | community.general.snap: 38 | name: go 39 | classic: yes 40 | channel: 1.18/stable 41 | 42 | - name: Clone a github repository 43 | become: yes 44 | # become_user: admin 45 | become_method: sudo 46 | git: 47 | repo: https://github.com/abergasov/ssh_notify 48 | dest: /home/admin/go/src/ssh_notify 49 | clone: yes 50 | update: yes 51 | 52 | - name: Run install target 53 | timeout: 30 54 | no_log: True 55 | become: yes 56 | become_method: sudo 57 | ansible.builtin.command: make name={{ server_name }} tg_token={{ tg_token }} tg_chat={{ tg_chat }} install 58 | args: 59 | chdir: /home/admin/go/src/ssh_notify 60 | ``` 61 | 62 | playbook.yaml 63 | ```yaml 64 | # VPN 65 | --- 66 | - hosts: "vpns" 67 | user: root 68 | vars_files: 69 | - secret 70 | - vars/main.yml 71 | become: true 72 | roles: 73 | ... 74 | - ssh_notify 75 | ... 76 | ``` 77 | 78 | ### Install via make 79 | ```shell script 80 | mkdir -p "$HOME/go/src" 81 | cd "$HOME/go/src" 82 | git clone https://github.com/abergasov/ssh_notify.git 83 | ``` 84 | Create with telegram notify only 85 | ```shell script 86 | make name=dev_server tg_token=12312312312 tg_chat=-123 install 87 | ``` 88 | 89 | Create with full config 90 | ```shell script 91 | make name=dev_server tg_token=123122 tg_chat=-123 sl_token=123 sl_chn=123 ips=127.0.0.1:PersonalVPN install 92 | ``` 93 | 94 | Remove 95 | ```shell script 96 | make remove 97 | ``` 98 | 99 | ### Manually install 100 | ```shell script 101 | mkdir -p "$HOME/go/src" 102 | cd "$HOME/go/src" 103 | git clone https://github.com/abergasov/ssh_notify.git 104 | cd ssh_notify 105 | go build main.go 106 | ``` 107 | 108 | ### Set config 109 | ```shell script 110 | sudo touch /etc/ssh_notify.conf && sudo nano /etc/ssh_notify.conf 111 | ``` 112 | 113 | Sample config 114 | ```shell script /etc/ssh_notify.conf 115 | SSHLogFile = /var/log/auth.log 116 | ServerName = test_test_server_name 117 | 118 | # telegram settings (optional, just do not set if not need) 119 | TelegramBotToken = YOUR_BOT_TOKEN_HERE 120 | TelegramNotifyChat = YOUR_CHAT_HERE 121 | 122 | # slack settings (optional, just do not set if not need) 123 | SlackBotToken = YOUR_BOT_TOKEN_HERE 124 | SlackTargetChannel = YOUR_CHANNEL_HERE 125 | 126 | # list of known ips separated by comma (deploy bot, personal vpn, etc...) 127 | KnownIps = 35.243.248.170:Gitlab ; 35.190.190.84 :Gitlab ; 35.229.20.217:Gitlab;127.0.0.1:PersonalVPN 128 | ``` 129 | 130 | ### Create service and run 131 | ```shell script 132 | sudo nano /lib/systemd/system/ssh_notify.service 133 | ``` 134 | Put text 135 | ```shell script 136 | [Unit] 137 | Description=notify on every ssh_login 138 | 139 | [Service] 140 | Type=simple 141 | Restart=always 142 | RestartSec=5s 143 | ExecStart=PATH_TO_HOMEDIR/go/src/ssh_notify/main 144 | 145 | [Install] 146 | WantedBy=multi-user.target 147 | ``` 148 | 149 | Start service 150 | ```shell script 151 | sudo service ssh_notify start 152 | sudo systemctl enable ssh_notify 153 | ``` 154 | 155 | ### Logs 156 | ```bash 157 | sudo journalctl -f -u ssh_notify.service 158 | ``` 159 | -------------------------------------------------------------------------------- /bin/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abergasov/ssh_notify/bc2ad3cd8c4f08c72bb3fa8777c827538cdf52f8/bin/.gitkeep -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "ssh_notify/internal" 7 | ) 8 | 9 | var conf = internal.New() 10 | 11 | var ( 12 | buildTime = "_dev" 13 | buildHash = "_dev" 14 | ) 15 | 16 | func main() { 17 | log.Println("App build time:", buildTime) 18 | log.Println("App build hash:", buildHash) 19 | 20 | if !fileExist(conf.SSHLogFile) { 21 | log.Print("ssh log file not found") 22 | log.Print("check file exists at", conf.SSHLogFile) 23 | log.Print("also you can set another file through the config") 24 | return 25 | } 26 | if !fileHasReadPermissions(conf.SSHLogFile) { 27 | log.Print("internal has't read permissions to file", conf.SSHLogFile) 28 | log.Print("make sure that the application starts as root") 29 | return 30 | } 31 | internal.LogMessage("ssh notify service started", "server: "+conf.ServerName) 32 | log.Print("Log file ok, start watch", conf.SSHLogFile) 33 | internal.Tail(conf.SSHLogFile) 34 | } 35 | 36 | func fileExist(fileName string) bool { 37 | info, err := os.Stat(fileName) 38 | if os.IsNotExist(err) { 39 | return false 40 | } 41 | return !info.IsDir() 42 | } 43 | 44 | func fileHasReadPermissions(fileName string) bool { 45 | file, err := os.OpenFile(fileName, os.O_RDONLY, 0666) 46 | if err != nil { 47 | if os.IsPermission(err) { 48 | return false 49 | } 50 | log.Print("error while open log file") 51 | log.Print(err.Error()) 52 | return false 53 | } 54 | defer file.Close() 55 | return true 56 | } 57 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module ssh_notify 2 | 3 | go 1.16 4 | -------------------------------------------------------------------------------- /internal/config.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "log" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | type Config struct { 12 | SSHLogFile string 13 | ServerName string 14 | TelegramBotToken string 15 | TelegramNotifyChat string 16 | SlackToken string 17 | SlackChannel string 18 | KnownIps map[string]string 19 | } 20 | 21 | var conf *Config 22 | 23 | func New() *Config { 24 | parseredConf := readConf() 25 | conf = &Config{ 26 | SSHLogFile: getVariableOrDefault(parseredConf, "SSHLogFile", "/var/log/auth.log"), 27 | TelegramBotToken: getVariableOrDefault(parseredConf, "TelegramBotToken", ""), 28 | TelegramNotifyChat: getVariableOrDefault(parseredConf, "TelegramNotifyChat", ""), 29 | ServerName: getVariableOrDefault(parseredConf, "ServerName", "default_server_name"), 30 | SlackToken: getVariableOrDefault(parseredConf, "SlackBotToken", ""), 31 | SlackChannel: getVariableOrDefault(parseredConf, "SlackTargetChannel", ""), 32 | KnownIps: make(map[string]string), 33 | } 34 | 35 | ips := getVariableOrDefault(parseredConf, "KnownIps", "") 36 | if len(ips) != 0 { 37 | servers := strings.Split(ips, ";") 38 | for _, str := range servers { 39 | srv := strings.Split(strings.TrimSpace(str), ":") 40 | if len(srv) != 2 { 41 | continue 42 | } 43 | conf.KnownIps[strings.TrimSpace(srv[0])] = strings.TrimSpace(srv[1]) 44 | } 45 | } 46 | log.Print("Config loaded") 47 | log.Print("Log file", conf.SSHLogFile) 48 | log.Print("Server name", conf.ServerName) 49 | log.Print("Telegram chat", conf.TelegramNotifyChat) 50 | return conf 51 | } 52 | 53 | func readConf() *map[string]string { 54 | file, err := os.Open("/etc/ssh_notify.conf") 55 | if err != nil { 56 | log.Print("Can't open internal config file /etc/ssh_notify.conf") 57 | panic(err.Error()) 58 | } 59 | defer file.Close() 60 | reader := bufio.NewReader(file) 61 | tmpConf := map[string]string{} 62 | for { 63 | line, err := reader.ReadString('\n') 64 | 65 | if err == io.EOF { 66 | break 67 | } 68 | 69 | equal := strings.Index(line, "=") 70 | if equal == -1 { 71 | continue 72 | } 73 | if key := strings.TrimSpace(line[:equal]); len(key) > 0 { 74 | if len(line) <= equal { 75 | continue 76 | } 77 | tmpConf[key] = strings.TrimSpace(line[equal+1:]) 78 | } 79 | 80 | if err != nil { 81 | panic(err.Error()) 82 | } 83 | } 84 | return &tmpConf 85 | } 86 | 87 | func getVariableOrDefault(tmpConf *map[string]string, name string, defaultValue string) string { 88 | for key, val := range *tmpConf { 89 | if key == name { 90 | return val 91 | } 92 | } 93 | return defaultValue 94 | } 95 | -------------------------------------------------------------------------------- /internal/logger.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | ) 11 | 12 | func LogMessage(message string, additionalData ...string) { 13 | values := []string{message} 14 | for i := range additionalData { 15 | values = append(values, additionalData[i]) 16 | } 17 | messagePrepared := strings.Join(values, "\n") 18 | if conf.TelegramBotToken != "" && conf.TelegramNotifyChat != "" { 19 | go TelegramMessage(messagePrepared, "html") 20 | } 21 | if conf.SlackToken != "" && conf.SlackChannel != "" { 22 | go SlackMessage(messagePrepared) 23 | } 24 | } 25 | 26 | func SlackMessage(message string) { 27 | data := url.Values{} 28 | data.Set("token", conf.SlackToken) 29 | data.Set("channel", conf.SlackChannel) 30 | data.Set("text", message) 31 | 32 | apiUrl := "https://slack.com/api/chat.postMessage" 33 | resp, err := http.Post(apiUrl, "application/x-www-form-urlencoded", strings.NewReader(data.Encode())) 34 | logError("SLACK", resp, err) 35 | } 36 | 37 | func TelegramMessage(message string, parseMode string) { 38 | requestBody, _ := json.Marshal(map[string]string{ 39 | "chat_id": conf.TelegramNotifyChat, 40 | "text": message, 41 | "parse_mode": parseMode, 42 | }) 43 | apiUrl := "https://api.telegram.org/bot" + conf.TelegramBotToken + "/sendMessage" 44 | resp, err := http.Post(apiUrl, "application/json", bytes.NewBuffer(requestBody)) 45 | logError("TELEGRAM", resp, err) 46 | } 47 | 48 | func logError(method string, resp *http.Response, err error) { 49 | if err != nil || (resp != nil && resp.StatusCode != 200) { 50 | println("========= BEGIN " + method + " MESSAGE ERROR =========") 51 | if resp != nil { 52 | println("Resp status ", resp.Status) 53 | bodyBytes, e := ioutil.ReadAll(resp.Body) 54 | if e == nil { 55 | bodyString := string(bodyBytes) 56 | println(bodyString) 57 | } 58 | } 59 | if err != nil { 60 | println(err.Error()) 61 | } 62 | println("========= END " + method + " MESSAGE ERROR =========") 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/reader.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "log" 7 | "os" 8 | "regexp" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | var ( 14 | ipReg = regexp.MustCompile(`([0-9]{1,3}[\.]){3}[0-9]{1,3}`) 15 | acceptReg = regexp.MustCompile(`sshd[[0-9]+]:.Accepted`) 16 | ) 17 | 18 | func Tail(filename string) { 19 | skipRows := true 20 | for { 21 | tailUntilRotate(filename, &skipRows) 22 | } 23 | } 24 | 25 | func tailUntilRotate(fileName string, skipRows *bool) { 26 | f, err := os.Open(fileName) 27 | if err != nil { 28 | LogMessage(err.Error(), "impossible open log file", fileName) 29 | return 30 | } 31 | defer f.Close() 32 | r := bufio.NewReader(f) 33 | info, err := f.Stat() 34 | if err != nil { 35 | LogMessage(err.Error(), "impossible get init file info", fileName) 36 | return 37 | } 38 | oldSize := info.Size() 39 | for { 40 | for line, _, err := r.ReadLine(); err != io.EOF; line, _, err = r.ReadLine() { 41 | if *skipRows { 42 | continue 43 | } 44 | searchMatch(string(line)) 45 | } 46 | pos, err := f.Seek(0, io.SeekCurrent) 47 | if err != nil { 48 | panic(err) 49 | } 50 | for { 51 | *skipRows = false 52 | time.Sleep(time.Second) 53 | newInfo, err := f.Stat() 54 | if err != nil { 55 | LogMessage(err.Error(), "impossible get file info", fileName) 56 | return 57 | } 58 | newSize := newInfo.Size() 59 | if newSize == oldSize { 60 | if checkFileMoved(fileName, info) { 61 | println("files are not equal, reopen file") 62 | return 63 | } 64 | continue 65 | } 66 | if newSize < oldSize { 67 | f.Seek(0, 0) 68 | } else { 69 | f.Seek(pos, io.SeekStart) 70 | } 71 | r = bufio.NewReader(f) 72 | oldSize = newSize 73 | break 74 | } 75 | } 76 | } 77 | 78 | func checkFileMoved(fileName string, info os.FileInfo) bool { 79 | //println("checking file changed") 80 | ff, err := os.Open(fileName) 81 | if err != nil { 82 | log.Fatal(err) 83 | } 84 | defer ff.Close() 85 | infoNew, _ := ff.Stat() 86 | //inoOld := getFileIno(info) 87 | //inoNew := getFileIno(infoNew) 88 | return !os.SameFile(info, infoNew) 89 | } 90 | 91 | func searchMatch(row string) { 92 | if matched := acceptReg.MatchString(row); !matched { 93 | return 94 | } 95 | log.Print("Found matches in auth log", row) 96 | var titleMsg string 97 | if len(conf.KnownIps) > 0 { 98 | titleMsg = "UNKNOWN IP login on server" 99 | if ipReg.MatchString(row) { 100 | connectedIp := strings.TrimSpace((ipReg.FindAllString(row, -1))[0]) 101 | if knownServerName, ok := conf.KnownIps[connectedIp]; ok { 102 | titleMsg = knownServerName + " login on server" 103 | } 104 | } 105 | } else { 106 | titleMsg = "New server login" 107 | } 108 | LogMessage(titleMsg, conf.ServerName, row) 109 | } 110 | -------------------------------------------------------------------------------- /ip_spec.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abergasov/ssh_notify/bc2ad3cd8c4f08c72bb3fa8777c827538cdf52f8/ip_spec.png -------------------------------------------------------------------------------- /log_by_key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abergasov/ssh_notify/bc2ad3cd8c4f08c72bb3fa8777c827538cdf52f8/log_by_key.png -------------------------------------------------------------------------------- /log_by_pass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abergasov/ssh_notify/bc2ad3cd8c4f08c72bb3fa8777c827538cdf52f8/log_by_pass.png -------------------------------------------------------------------------------- /ssh_notify.conf: -------------------------------------------------------------------------------- 1 | SSHLogFile = /var/log/auth.log 2 | ServerName = test_test_server_name 3 | 4 | # telegram settings (optional, just do not set if not need) 5 | TelegramBotToken = YOUR_TELEGRAM_BOT_TOKEN_HERE 6 | TelegramNotifyChat = YOUR_TELEGRAM_CHAT_HERE 7 | 8 | # slack settings (optional, just do not set if not need) 9 | SlackBotToken = YOUR_SLACK_BOT_TOKEN_HERE 10 | SlackTargetChannel = YOUR_SLACK_CHANNEL_HERE 11 | 12 | KnownIps = SET_KNOWN_IP_LIST_HERE -------------------------------------------------------------------------------- /ssh_notify.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=notify on every ssh_login 3 | 4 | [Service] 5 | Type=simple 6 | Restart=always 7 | RestartSec=5s 8 | ExecStart=/usr/bin/ssh_notify 9 | 10 | [Install] 11 | WantedBy=multi-user.target --------------------------------------------------------------------------------