├── .gitignore ├── .gitmodules ├── README.md ├── client ├── config.yaml.example └── main.py ├── dialogflow └── Linux-Control.zip ├── run-client.sh ├── run-server.sh ├── server ├── application.py ├── base.py ├── config.yaml.example ├── dialogflow.py ├── main.py ├── oauth2_login.py ├── oauth2_provider.py ├── site_account.py ├── site_main.py └── websocket.py └── sync.sh /.gitignore: -------------------------------------------------------------------------------- 1 | run.sh 2 | *.yaml 3 | __pycache__ 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "pywakeonlan"] 2 | path = pywakeonlan 3 | url = https://github.com/remcohaszing/pywakeonlan 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | linux-control 2 | ============= 3 | Allows you to setup a server on a computer (e.g. Raspberry Pi) that your Linux 4 | desktop and laptop computers will connect to and then allow some remote 5 | commands to be run via Google Assistant, e.g. power on via Wake-on-LAN, lock or 6 | unlock the screen, put to sleep, open or close a program, etc. 7 | 8 | See the [demo video on Youtube](https://youtu.be/luBkZoSbxm4). 9 | 10 | ## Summary / Why this isn't simple 11 | 12 | Before you start, you need to know how much work this entails: 13 | 14 | * Create Google Actions project ("Google Action Project" section) 15 | * Setup port forwarding on your router to some internal server 16 | * Setup Linux Control server on that internal server ("Setup Server" section), 17 | HTTPS is required 18 | * On your laptop and/or desktop, setup the Linux Control client ("Setup 19 | Client" section) 20 | 21 | Google Actions aren't designed for each individual user of the action to have 22 | their own server to process the request. Normally if you create the app (e.g. 23 | me) you'd have a server somewhere to handle all of your users. However, in this 24 | case: 25 | 26 | * Nobody would really want to trust somebody else's server that could lock, 27 | unlock, turn on, power off, etc. all of your computers. If you use my 28 | server, I could modify it to command your computer whenever I wanted. 29 | * I don't have the Internet bandwidth anyway nor the power on my Raspberry Pi 30 | to support that many users. 31 | * Wake-on-LAN requires that your server be on the same LAN as the computers 32 | you want to wake. 33 | 34 | Thus, at the moment, the only way I'm aware of doing this is each person 35 | creates their own Google Action project. Feel free to alert me to better ways 36 | of doing it. 37 | 38 | ## Currently supported commands 39 | Each of these works for "laptop" or "desktop", but I'll use "laptop" for these 40 | examples. You first would say "Okay Google, talk to Linux Control" or prepend 41 | these with "Ask Linux Control," e.g. "Ask Linux Control turn on my laptop." 42 | Note the exact wording here is not required, but these will give the general 43 | idea. 44 | 45 | * Commands 46 | - Turn on my laptop. *(via Wake-on-LAN, i.e. only works if your laptop/desktop is on the same LAN as the server, and you need to set the MAC address via the website)* 47 | - Power off / suspend / reboot my laptop. *(via Systemd)* 48 | - Lock/unlock my laptop. *(for Gnome)* 49 | - Open Firefox on my laptop. *(looks up .desktop files in Gnome Tracker database)* 50 | - Locate filename on my laptop. *(looks up file in Gnome Tracker database)* 51 | - Fetch 3 *(fetch item 3 from locate results, symlink into Dropbox)* 52 | - Set volume to 50% on my laptop. *(via Pulseaudio)* 53 | - Take a picture on my laptop. *(adds to Dropbox)* 54 | - Take a screenshot on my laptop. *(adds to Dropbox)* 55 | * Queries 56 | - Where is my laptop? *(either "at home" if on same network as server or does GeoIP lookup from IP it's connected to server from)* 57 | - What is the memory usage of my laptop? 58 | - What is the disk usage of my laptop? 59 | - What is the battery on my laptop? 60 | - What is the CPU usage of my laptop? 61 | - Is Firefox open on my laptop? 62 | * Exit with "quit" 63 | 64 | Note: this is more proof-of-concept and won't necessarily work with all system 65 | setups. For example, the locking/unlocking is for Gnome at the moment and 66 | opening app code searches Gnome Tracker database to find .desktop files. 67 | 68 | ## Google Action Project 69 | Create a new [Google Actions project](https://console.actions.google.com/). 70 | 71 | Setup Dialogflow: 72 | * After naming your Actions project, click on "BUILD" under Dialogflow. 73 | * Click "Dialogflow V2 API" when Dialogflow opens. Click "CREATE" at the top. 74 | * Then, click the settings button, the gear icon at the top left. 75 | * Select the "Export and Import" tab. 76 | * "Restore from Zip" the *dialogflow/Linux-Control.zip* included in this repo. 77 | * Click "Fullfillment" tab on the left. Change "example.com:443" to whatever 78 | your domain and port are. 79 | * Fill out the BASIC AUTH password to whatever you wish. Then fill in the 80 | *server/config.yaml* that you'll create in the Server Setup section with 81 | this same password. 82 | 83 | Setup Oauth2 for Assistant to log into your server: 84 | * On your Google Actions project, select "Account linking (optional)" and click ADD. 85 | * Select "Authorization Code". Next. 86 | * Fill out: 87 | - Client ID -- e.g. google-assistant 88 | - Client Secret -- generate with `pwgen 30 1` for example 89 | - Auth URL -- *https://example.com:443/linux-control/oauth/auth* 90 | - Token URL -- *https://example.com:443/linux-control/oauth/token* 91 | * Under Server Setup, fill these in as *oauth_google_{id,secret,uri}* in your 92 | *server/config.yaml* file. 93 | 94 | Setup Oauth2 for users to login to website via Google: 95 | * Go to [Google API Console](https://console.developers.google.com/apis/credentials) 96 | or [Google Cloud Platform](https://console.cloud.google.com/apis/credentials) and 97 | create an OAuth client ID for your project. 98 | * Copy the client ID and client secret into your "server/config.yaml" file as 99 | *oauth_client_{id,secret}*. 100 | * Set the authorized Javascript origin to your website, e.g. 101 | *https://example.com:443* 102 | * Set the authorized redirect URIs to your login path, e.g. 103 | *https://example.com:443/linux-control/auth/login* 104 | 105 | Fill out app information: 106 | * On your Google Actions project, select Deploy --> "Directory information." 107 | * Fill out name, pronunciation, description, etc. 108 | * Fill out the invocations, e.g. "Talk to Linux Control", "Linux Control", and 109 | "Ask Linux Control". 110 | 111 | When you are ready to test it (i.e., after you follow the Setup Server section): 112 | * On Dialogflow, click "Integrations" tab on the left. 113 | * Click big "Google Assistant". 114 | * Explicit invocation: Default Welcome Intent. 115 | * Implicit invocation: Computer Command and Computer Query. 116 | * Check "Sign in required" on all. 117 | * Click "TEST". 118 | * Pull out your phone, linked to your account. Say "Talk to Linux Control." 119 | * If you've set up the server and everything, it should say that it's not 120 | linked to your account and give a button to click that'll take you to your 121 | login page. Click on that. Click "login" when it says to login and then 122 | reload the page. Link to your Google account. Then click the back button to 123 | get you back to the login then reload page. Click "reload." Then it should 124 | be linked to your account. 125 | * Say something like, "Ask Linux Control where is my laptop" 126 | 127 | For longer-term testing: 128 | * Go to your [Actions on Google](https://console.actions.google.com) project. 129 | Click "Release" under "Deploy". 130 | * Under Alpha Release select "Submit for Alpha Release." Then whitelist the 131 | emails of up to 20 users who you want to use this release. Share the "Opt-in 132 | link" (under whitelisting users) with whoever you want to use it. 133 | * Wait a few hours for it to deploy. 134 | * If you make changes to DialogFlow, then you'll want to submit another alpha 135 | release to update it. 136 | * Open the opt-in link on your phone, e.g. in Firefox. However, if you just 137 | click "Send to device" it'll probably ask you to try it but will then say 138 | "Sorry, I couldn't find that." You need to first open it in Google 139 | Assistant, which in Firefox can be done by clicking the little android icon 140 | in the address bar. Then scroll down to the alpha test message and click 141 | "I'm in." At that point you should be able to use the alpha release, 142 | e.g. by saying "talk to linux control" in the Google Assistant. 143 | 144 | ## Raspberry Pi Setup 145 | For this example, I'll be showing how to set it up on a Raspberry Pi running 146 | Arch Linux. If you already have a computer to use as the server, skip to the 147 | Server Setup section. 148 | 149 | ### Installing Arch 150 | 151 | Follow Arch Linux ARM 152 | [instructions](https://archlinuxarm.org/platforms/armv6/raspberry-pi) for the 153 | Raspberry Pi version you have. I recommend setting up [Google 154 | Authenticator](https://wiki.archlinux.org/index.php/Google_Authenticator) on 155 | the RPi as well if you plan on allowing password logins from the outside world. 156 | 157 | ssh alarm@alarmpi 158 | su #default password: root 159 | pacman -S sudo 160 | groupadd sudo 161 | useradd -ms /bin/bash -g users -G sudo YOURUSERNAME 162 | echo '%sudo ALL=(ALL) ALL' >> /etc/sudoers 163 | passwd 164 | passwd YOURUSERNAME 165 | rm /etc/localtime 166 | ln -s /usr/share/zoneinfo/US/Pacific /etc/localtime 167 | systemctl enable systemd-resolved 168 | systemctl start systemd-resolved 169 | pacman -Syu htop tmux vim libpam-google-authenticator qrencode wol sshguard 170 | 171 | Transfer your SSH public key to allow login without password: 172 | 173 | ssh alarmpi 'mkdir -p .ssh; cat >> .ssh/authorized_keys' < .ssh/id_rsa.pub 174 | 175 | Interestingly, SSH Guard appears to only work with IPv4, so if you SSH in via 176 | IPv6, then it won't care. Really, doesn't matter since coming in from the 177 | Internet will be IPv4, but you can enable systemd-resolve to get sshing locally 178 | to use IPv4 and then if you want require SSH via only IPv4 with AddressFamily 179 | inet. 180 | 181 | Allow installing from the AUR: 182 | 183 | sudo pacman --needed -S base-devel vifm parallel expac devtools aria2 repose 184 | 185 | */etc/pacman.d/custom*: 186 | 187 | [options] 188 | CacheDir = /var/cache/pacman/pkg 189 | CacheDir = /var/cache/pacman/custom 190 | CleanMethod = KeepCurrent 191 | 192 | [custom] 193 | SigLevel = Optional TrustAll 194 | Server = file:///var/cache/pacman/custom 195 | 196 | Then finish setup, something like this: 197 | 198 | echo "Include = /etc/pacman.d/custom" | sudo tee -a /etc/pacman.conf 199 | sudo install -d /var/cache/pacman/custom -o $USER 200 | repo-add /var/cache/pacman/custom/custom.db.tar 201 | sudo pacman -Syu 202 | 203 | echo "PKGDEST=/var/cache/pacman/custom" | sudo tee -a /etc/makepkg.conf 204 | 205 | mkdir build 206 | cd build 207 | curl -o aurutils.tar.gz https://aur.archlinux.org/cgit/aur.git/snapshot/aurutils.tar.gz 208 | tar xzf aurutils.tar.gz 209 | cd aurutils 210 | makepkg -s 211 | gpg --recv-keys 212 | 213 | sudo pacman -Syu aurutils 214 | 215 | ## Server Setup 216 | I'll show how to use Nginx with the Linux Control program using Tornado: 217 | 218 | ### Nginx 219 | Install *nginx*: 220 | 221 | sudo pacman -S python nginx 222 | sudo systemctl enable nginx 223 | sudo systemctl start nginx 224 | 225 | Setup HTTPS using Let's Encrypt. In my case, my ISP blocks ports 80 and 443, so 226 | I have to use DNS verification. If this is the case for you too, you might try 227 | [Lego](https://lincolnloop.com/blog/letsencrypt-dns-challenge/). I used the 228 | [Zero SSL](https://zerossl.com/free-ssl/#crt) 229 | website since Namecheap hasn't approved my API access even though I requested 230 | it ages ago and they say give it a few business days. 231 | 232 | First time: 233 | * Enter email 234 | * Enter domains: example.com www.example.com 235 | * Check DNS verification 236 | 237 | Note, if you use Namecheap, make sure you don't put the "domain.tld" part of 238 | the string. That's implied in the "Host" column of the Advanced DNS entries. 239 | 240 | Renewing: 241 | * Enter email 242 | * Put in previous key 243 | * Put in previous CSR 244 | 245 | Install certificate: 246 | 247 | sudo mkdir /etc/lets-encrypt 248 | 249 | Put the *domain-crt.txt* in *fullchain.pem* and the domain-key.txt in 250 | */etc/lets-encryptprivkey.pem*. 251 | 252 | sudo chmod 0600 /etc/lets-encrypt 253 | sudo chown -R root:root /etc/lets-encrypt/ 254 | 255 | Setup the *nginx.conf* similar to [Tornado's 256 | example](http://www.tornadoweb.org/en/stable/guide/running.html). Or, look at 257 | the one below similar to what I used, making sure to replace your domain names 258 | and port 9999 with whatever external port you use. I also have a separate 259 | website on the root / and put Linux Control under */linux-control*. If you 260 | change this, then in your *config.yaml* files set *root* to this directory, 261 | making sure to prepend with a / if it's not blank. Note that the 262 | */linux-control/con* is for the Websocket that the clients will connect to. 263 | 264 | worker_processes 1; 265 | 266 | events { 267 | worker_connections 1024; 268 | use epoll; 269 | } 270 | 271 | http { 272 | # Enumerate all the Tornado servers here 273 | upstream frontends { 274 | server 127.0.0.1:8888; 275 | } 276 | 277 | include /etc/nginx/mime.types; 278 | default_type application/octet-stream; 279 | 280 | keepalive_timeout 65; 281 | proxy_read_timeout 200; 282 | sendfile on; 283 | tcp_nopush on; 284 | tcp_nodelay on; 285 | gzip on; 286 | gzip_min_length 1000; 287 | gzip_proxied any; 288 | gzip_types text/plain text/html text/css text/xml 289 | application/x-javascript application/xml 290 | application/atom+xml text/javascript; 291 | 292 | # Only retry if there was a communication error, not a timeout 293 | # on the Tornado server (to avoid propagating "queries of death" 294 | # to all frontends) 295 | proxy_next_upstream error; 296 | 297 | server { 298 | listen 443 ssl default_server; 299 | listen 9999 ssl default_server; 300 | server_name domain.tld www.domain.tld; 301 | ssl_certificate /etc/lets-encrypt/fullchain.pem; 302 | ssl_certificate_key /etc/lets-encrypt/privkey.pem; 303 | 304 | location / { 305 | root /srv/http/www; 306 | index index.html index.htm; 307 | } 308 | 309 | location /linux-control { 310 | proxy_pass_header Server; 311 | proxy_set_header Host $http_host; 312 | proxy_redirect off; 313 | proxy_set_header X-Real-IP $remote_addr; 314 | proxy_set_header X-Scheme $scheme; 315 | proxy_pass http://frontends; 316 | } 317 | 318 | location /linux-control/con { 319 | proxy_pass_header Server; 320 | proxy_set_header Host $http_host; 321 | proxy_redirect off; 322 | proxy_set_header X-Real-IP $remote_addr; 323 | proxy_set_header X-Scheme $scheme; 324 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 325 | proxy_pass http://frontends; 326 | 327 | proxy_http_version 1.1; 328 | proxy_set_header Upgrade $http_upgrade; 329 | proxy_set_header Connection "upgrade"; 330 | proxy_read_timeout 600; 331 | } 332 | 333 | error_page 404 /404.html; 334 | error_page 500 502 503 504 /50x.html; 335 | location = /50x.html { 336 | root /usr/share/nginx/html; 337 | } 338 | } 339 | 340 | # If internal, then port 443 works 341 | server { 342 | listen 80; 343 | server_name localhost; 344 | return 301 https://$host$request_uri; 345 | } 346 | 347 | # If connecting from external port 8080, then we're probably not on 348 | # the local network, so we need to access from external HTTPS port 349 | server { 350 | listen 8080; # External HTTP port, if you have it 351 | server_name localhost; 352 | return 301 https://$host:9999$request_uri; 353 | } 354 | } 355 | 356 | Finally, restart *nginx*: 357 | 358 | sudo systemctl restart nginx 359 | 360 | ### Tornado 361 | Install Tornado and other dependencies of the Linux Control server: 362 | 363 | sudo pacman -S python-tornado python-pip python-redis python-yaml \ 364 | geoip-database-extra python-geoip 365 | pip install --user tornado-http-auth python-oauth2 366 | 367 | sudo systemctl enable redis 368 | sudo systemctl start redis 369 | 370 | Create a place to put the Linux Control files, e.g. in */srv/http*: 371 | 372 | sudo mkdir /srv/http/linux-control 373 | sudo chown USER:GROUP /srv/http/linux-control 374 | 375 | Copy the server run script and modify the path to the directory: 376 | 377 | cp /srv/http/linux-control/run-server.sh /srv/http/linux-control/run.sh 378 | 379 | Service file to start Tornado Linux Control server 380 | */etc/systemd/system/tornado.service*, making sure to adjust the user and group 381 | to run as: 382 | 383 | [Unit] 384 | Description=Tornado 385 | [Service] 386 | ExecStart=/srv/http/linux-control/run.sh 387 | User=USER 388 | Group=GROUP 389 | [Install] 390 | WantedBy=multi-user.target 391 | 392 | Last of all, copy the example config, edit it, then start tornado: 393 | 394 | cp /srv/http/linux-control/server/config.yaml{.example,} 395 | # edit /srv/http/linux-control/server/config.yaml 396 | sudo systemctl start tornado 397 | 398 | ## Client Setup 399 | Install appropriate dependencies: 400 | 401 | sudo pacman -S python-psutil dex python-yaml 402 | aursync python-pulse-control-git 403 | pip install --user plocate python-libxdo 404 | 405 | Then, copy the example config and edit it: 406 | 407 | cp client/config.yaml{.example,} 408 | # edit client/config.yaml 409 | 410 | Make sure you set the server, root, and cookie secret. Get the OAuth2 client 411 | id/secret from Google. Set the OAuth2 provider id/secret to what you gave to 412 | Google in the Google Actions Project instructions earlier. Make sure you set 413 | the URI to point to your project id (see Project ID under the settings of your 414 | Google Actions Project, gear at top left). Set the HTTP BASIC AUTH user/pass 415 | to what you gave Dialogflow earlier. 416 | 417 | You'll have to visit your Linux Control website to get the ID and TOKEN that 418 | you'll need for the client. It'll show you your user ID and then a token to 419 | identify your laptop and one to identify your desktop (so it can differentiate 420 | which computer connection is which). 421 | 422 | ### Client using Graphical Environment 423 | If you're using a graphical environment and want Linux Control to work when you log in, then, first: 424 | 425 | mkdir -p ~/.config/systemd/user/ 426 | cp run-client.sh run.sh 427 | 428 | Then edit the path in *run.sh* and create the Systemd service 429 | *~/.config/systemd/user/linux-control.service*: 430 | 431 | [Unit] 432 | Description=Linux Control 433 | [Service] 434 | ExecStart=/path/to/linux-control/run.sh 435 | Restart=always 436 | RestartSec=3 437 | [Install] 438 | WantedBy=default.target 439 | 440 | Make it auto start: 441 | 442 | systemctl --user enable linux-control.service 443 | systemctl --user start linux-control.service 444 | 445 | ### Client not using Graphical Environment 446 | At times you may not be using a graphical environment or want Linux Control to 447 | work on boot without having to have the user log in. Then, use this service 448 | file in */etc/systemd/system/linux-control.service* making sure to fill in the 449 | user/group you want to run as: 450 | 451 | [Unit] 452 | Description=Linux Control 453 | [Service] 454 | Environment=DISPLAY=:0 455 | ExecStart=/path/to/linux-control/run.sh 456 | Restart=always 457 | RestartSec=3 458 | User=USERNAME 459 | Group=GROUP 460 | [Install] 461 | WantedBy=multi-user.target 462 | 463 | However, then it won't have permission to reboot, shutdown, etc. unless you 464 | allow it via polkit */etc/polkit-1/rules.d/00-allow-poweroff.rules* 465 | ([src](https://gist.github.com/wooptoo/4013294/ccacedd69d54de7f2fd5881b546d5192d6a2bddb)): 466 | 467 | polkit.addRule(function(action, subject) { 468 | if (action.id.match("org.freedesktop.login1.") && subject.isInGroup("power")) { 469 | return polkit.Result.YES; 470 | } 471 | }); 472 | 473 | Then make sure you're in the *power* group and enable with: 474 | 475 | sudo systemctl restart polkit 476 | sudo systemctl enable linux-control 477 | sudo systemctl restart linux-control 478 | -------------------------------------------------------------------------------- /client/config.yaml.example: -------------------------------------------------------------------------------- 1 | # https and websocket URL, (wss|https)://server/root 2 | # Note: if root is not blank, it needs a / at the beginning 3 | server: domain.tld:port 4 | root: /linux-control 5 | # The user ID given in the web browser for your account 6 | id: ID 7 | # Get from logging in from a web browser 8 | token: TOKEN 9 | -------------------------------------------------------------------------------- /client/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | import json 5 | import yaml 6 | import logging 7 | import tornado.gen 8 | import tornado.ioloop 9 | import tornado.websocket 10 | import tornado.httpclient 11 | from tornado.escape import url_escape, url_unescape 12 | 13 | import tornado.queues 14 | from tornado.concurrent import run_on_executor 15 | from concurrent.futures import ThreadPoolExecutor 16 | 17 | # For commands and queries 18 | import cv2 19 | import dbus 20 | import time 21 | import psutil 22 | import plocate 23 | import pulsectl 24 | import datetime 25 | import subprocess 26 | from xdo import Xdo 27 | from plocate import plocate 28 | 29 | import gi 30 | gi.require_version('Tracker', '2.0') 31 | from gi.repository import Tracker 32 | 33 | class WSClient: 34 | def __init__(self, url, ping_interval=60, ping_timeout=60*3, max_workers=4): 35 | self.url = url 36 | self.ping_interval = ping_interval 37 | self.ping_timeout = ping_timeout 38 | self.ioloop = tornado.ioloop.IOLoop.instance() 39 | self.ws = None 40 | self.connect() 41 | self.executor = ThreadPoolExecutor(max_workers=max_workers) 42 | 43 | # Keep track of which files have been found, so you can fetch them 44 | self.locateResults = {} 45 | 46 | # Keep connecting if it dies, every minute 47 | tornado.ioloop.PeriodicCallback(self.keep_alive, 60000, io_loop=self.ioloop).start() 48 | 49 | self.ioloop.start() 50 | 51 | @tornado.gen.coroutine 52 | def connect(self): 53 | try: 54 | self.ws = yield tornado.websocket.websocket_connect(self.url, 55 | ping_interval=self.ping_interval, # make sure we're still connected 56 | ping_timeout=self.ping_timeout) 57 | except tornado.httpclient.HTTPError: 58 | logging.error("HTTP error - could not connect to websocket") 59 | else: 60 | logging.info("Connection opened") 61 | self.run() 62 | 63 | @tornado.gen.coroutine 64 | def run(self): 65 | try: 66 | while True: 67 | result = None 68 | longResult = None 69 | 70 | msg = yield self.ws.read_message() 71 | 72 | # If closed, break; otherwise, load message JSON data 73 | if msg is None: 74 | logging.info("Connection closed") 75 | self.ws = None 76 | break 77 | else: 78 | msg = json.loads(msg) 79 | 80 | # Process message 81 | if "error" in msg: 82 | logging.error(msg["error"]) 83 | break 84 | elif "query" in msg: 85 | value = msg["query"]["value"] 86 | x = msg["query"]["x"] 87 | result, longResult = yield self.processQuery(value, x) 88 | elif "command" in msg: 89 | command = msg["command"]["command"] 90 | x = msg["command"]["x"] 91 | url = msg["command"]["url"] 92 | number = msg["command"]["number"] 93 | result, longResult = yield self.processCommand(command, x, url, number) 94 | else: 95 | logging.warning("Unknown message: " + str(msg)) 96 | 97 | # Send results back 98 | if result and longResult: 99 | self.ws.write_message(json.dumps({ 100 | "response": result, 101 | "longResponse": longResult 102 | })) 103 | elif result: 104 | self.ws.write_message(json.dumps({ 105 | "response": result, 106 | })) 107 | except KeyboardInterrupt: 108 | pass 109 | 110 | def keep_alive(self): 111 | if self.ws is None: 112 | logging.info("Reconnecting") 113 | self.connect() 114 | 115 | @tornado.gen.coroutine 116 | def processQuery(self, value, x): 117 | msg = "Unknown query" 118 | longMsg = None 119 | 120 | if value == "memory": 121 | msg = "Memory usage is "+"%.1f"%psutil.virtual_memory().percent+"%" 122 | elif value == "disk": 123 | partitions = psutil.disk_partitions() 124 | msg = "Disk usage is " 125 | 126 | for p in partitions: 127 | d = psutil.disk_usage(p.mountpoint) 128 | msg += p.mountpoint + " " + "%.1f"%d.percent + "% " 129 | elif value == "battery": 130 | msg = "Battery is "+"%.1f"%psutil.sensors_battery().percent+"%" 131 | elif value == "processor": 132 | msg = "CPU usage is "+"%.1f"%psutil.cpu_percent(interval=0.5)+"%" 133 | pass 134 | elif value == "open": 135 | found = False 136 | search = x.strip().lower() 137 | 138 | for proc in psutil.process_iter(attrs=["name"]): 139 | if search in proc.info["name"].lower(): 140 | found = True 141 | break 142 | 143 | if found: 144 | msg = "Yes, "+search+" is running" 145 | else: 146 | msg = "No, "+search+" is not running" 147 | 148 | return msg, longMsg 149 | 150 | @tornado.gen.coroutine 151 | def processCommand(self, command, x, url, number): 152 | msg = "Unknown command" 153 | longMsg = None 154 | 155 | if command == "power off": 156 | if self.can_poweroff(): 157 | self.ioloop.add_timeout(datetime.timedelta(seconds=3), self.cmd_poweroff) 158 | msg = "Powering off" 159 | else: 160 | msg = "Cannot power off" 161 | elif command == "sleep": 162 | if self.can_sleep(): 163 | self.ioloop.add_timeout(datetime.timedelta(seconds=3), self.cmd_sleep) 164 | msg = "Sleeping" 165 | else: 166 | msg = "Cannot sleep" 167 | elif command == "reboot": 168 | if self.can_reboot(): 169 | self.ioloop.add_timeout(datetime.timedelta(seconds=3), self.cmd_reboot) 170 | msg = "Rebooting" 171 | else: 172 | msg = "Cannot reboot" 173 | elif command == "lock": 174 | self.cmd_lock() 175 | msg = "Locking" 176 | elif command == "unlock": 177 | self.cmd_unlock() 178 | msg = "Unlocking" 179 | elif command == "open": 180 | if x: 181 | results = yield self.cmd_findApp(x.strip().lower()) 182 | 183 | if len(results) > 0: 184 | fn = results[0][7:] # remove file:// 185 | name = yield self.getAppName(fn) 186 | if name: 187 | msg = "Opening "+name 188 | longMsg = "Opening "+name+": "+fn 189 | else: 190 | msg = "Opening" 191 | longMsg = "Opening "+fn 192 | self.ioloop.add_callback(lambda: self.cmd_openApp(fn, name)) 193 | else: 194 | msg = "No results found" 195 | else: 196 | msg = "Missing program to start" 197 | elif command == "close": 198 | msg = "Not implemented yet" 199 | elif command == "kill": 200 | msg = "Not implemented yet" 201 | elif command == "locate": 202 | if x: 203 | # Search might be slow 204 | try: 205 | results = yield tornado.gen.with_timeout(datetime.timedelta(seconds=3.5), self.cmd_locateDB(x)) 206 | except tornado.gen.TimeoutError: 207 | msg = "Timed out" 208 | else: 209 | self.locateResults = {} 210 | 211 | if results: 212 | msg = "Found "+str(len(results))+" results" 213 | longMsg = "Results:\n" 214 | 215 | for i, r in enumerate(results): 216 | self.locateResults[i+1] = url_unescape(r) 217 | longMsg += str(i+1) + ") "+r+"\n" 218 | else: 219 | msg = "No results found" 220 | else: 221 | msg = "Missing search query" 222 | 223 | elif command == "fetch": 224 | if number: 225 | try: 226 | item = int(re.search(r'\d+', number).group()) 227 | except ValueError: 228 | msg = "Invalid item number: "+number 229 | except AttributeError: 230 | msg = "Invalid item number: "+number 231 | else: 232 | if item in self.locateResults: 233 | # Input filename, what we saved from the locate command 234 | inputFile = self.locateResults[item] 235 | 236 | # Output filename 237 | ext = os.path.splitext(inputFile)[-1] 238 | fn = datetime.datetime.now().strftime( 239 | "LinuxControl-Fetch-%Y-%m-%d-%Hh-%Mm-%Ss")+ext 240 | outputFile = os.path.join(os.environ["HOME"], "Dropbox", fn) 241 | 242 | msg = "Fetching item "+str(item) 243 | longMsg = "Fetching item "+str(item)+": copying"+ \ 244 | inputFile+" to "+outputFile 245 | self.ioloop.add_callback(lambda: self.cmd_fetchFile( 246 | inputFile, outputFile)) 247 | else: 248 | msg = "Item not found in last locate results" 249 | else: 250 | msg = "Please specify which item of your locate command to fetch." 251 | elif command == "set volume": 252 | if number: 253 | try: 254 | volume = int(re.search(r'\d+', number).group()) 255 | except ValueError: 256 | msg = "Invalid percentage: "+number 257 | except AttributeError: 258 | msg = "Invalid percentage: "+number 259 | else: 260 | with pulsectl.Pulse('setting-volume') as pulse: 261 | for sink in pulse.sink_list(): 262 | pulse.volume_set_all_chans(sink, volume/100.0) 263 | msg = "Volume set" 264 | longMsg = "Volume set to "+str(volume)+"%" 265 | else: 266 | msg = "Please specify volume percentage" 267 | elif command == "stop": 268 | msg = "Not implemented yet" 269 | elif command == "take a picture": 270 | filename = os.path.join(os.environ["HOME"], "Dropbox", 271 | datetime.datetime.now().strftime( 272 | "LinuxControl-Picture-%Y-%m-%d-%Hh-%Mm-%Ss.png")) 273 | msg = "Taking picture, saving in Dropbox" 274 | longMsg = "Taking picture: " + filename 275 | self.ioloop.add_callback(lambda: self.cmd_image(filename)) 276 | elif command == "screenshot": 277 | filename = os.path.join(os.environ["HOME"], "Dropbox", 278 | datetime.datetime.now().strftime( 279 | "LinuxControl-Screenshot-%Y-%m-%d-%Hh-%Mm-%Ss.png")) 280 | msg = "Taking screenshot, saving in Dropbox" 281 | longMsg = "Taking screenshot: " + filename 282 | self.ioloop.add_callback(lambda: self.cmd_screenshot(filename)) 283 | elif command == "download": 284 | msg = "Not implemented yet" 285 | elif command == "start recording": 286 | msg = "Not implemented yet" 287 | elif command == "stop recording": 288 | msg = "Not implemented yet" 289 | 290 | return msg, longMsg 291 | 292 | @run_on_executor 293 | def cmd_screenshot(self, filename): 294 | """ 295 | Take Gnome screenshot 296 | """ 297 | os.system("gnome-screenshot -f '%s'" % filename) 298 | 299 | @run_on_executor 300 | def cmd_fetchFile(self, inputFile, outputFile): 301 | """ 302 | Copy file to Dropbox to make it accessible from phone 303 | """ 304 | os.symlink(inputFile, outputFile) 305 | 306 | @run_on_executor 307 | def cmd_image(self, filename): 308 | """ 309 | Capture image from webcam with OpenCV 310 | """ 311 | cap = cv2.VideoCapture(0) 312 | ret, frame = cap.read() 313 | 314 | if frame is not None: 315 | cv2.imwrite(filename, frame) 316 | 317 | @run_on_executor 318 | def cmd_locate(self, pattern): 319 | """ 320 | This searches the mlocate DB, but that most of the time times out, so 321 | instead probably use the cmd_locateDB() function. 322 | """ 323 | mlocatedb="/var/lib/mlocate/mlocate.db" 324 | results = "" 325 | 326 | with open(mlocatedb, 'rb') as db: 327 | for p in plocate.locate([pattern], db, 328 | type="file", 329 | ignore_case=True, 330 | limit=2, 331 | existing=False, 332 | match="wholename", 333 | all=False): 334 | results += p + " " 335 | 336 | return results 337 | 338 | @run_on_executor 339 | def cmd_locateDB(self, query): 340 | """ 341 | Find a file in Gnome Tracker DB 342 | """ 343 | results = [] 344 | 345 | # See: https://github.com/linuxmint/nemo/blob/master/libnemo-private/nemo-search-engine-tracker.c 346 | conn = Tracker.SparqlConnection.get(None) 347 | 348 | # Match each word in query, split on spaces, case insensitive 349 | sql = """SELECT nie:url(?urn) WHERE { 350 | ?urn a nfo:FileDataObject . 351 | FILTER (""" 352 | 353 | for q in query.lower().split(): 354 | sql += """fn:contains(lcase(nfo:fileName(?urn)),"%s") && """%(q) 355 | 356 | sql += """fn:starts-with(lcase(nie:url(?urn)),"file://")) 357 | } ORDER BY DESC(nie:url(?urn)) DESC(nfo:fileName(?urn))""" 358 | 359 | cursor = conn.query(sql, None) 360 | 361 | while cursor.next(None): 362 | results.append(cursor.get_string(0)[0].replace("file://","")) 363 | 364 | return results 365 | 366 | @run_on_executor 367 | def cmd_findApp(self, query): 368 | """ 369 | Find desktop file in Gnome Tracker DB 370 | """ 371 | results = [] 372 | 373 | # See: https://github.com/linuxmint/nemo/blob/master/libnemo-private/nemo-search-engine-tracker.c 374 | conn = Tracker.SparqlConnection.get(None) 375 | cursor = conn.query("""SELECT nie:url(?urn) WHERE { 376 | ?urn a nfo:FileDataObject . 377 | FILTER (fn:contains(lcase(nfo:fileName(?urn)),"%s") && 378 | fn:starts-with(lcase(nie:url(?urn)),"file://") && 379 | fn:ends-with(lcase(nie:url(?urn)),".desktop")) 380 | } ORDER BY DESC(nie:url(?urn)) DESC(nfo:fileName(?urn))"""%(query), None) 381 | 382 | while cursor.next(None): 383 | results.append(cursor.get_string(0)[0]) 384 | 385 | return results 386 | 387 | @tornado.gen.coroutine 388 | def getAppName(self, fn): 389 | """ 390 | Try to get the name of the program from the .desktop file 391 | """ 392 | name = None 393 | 394 | with open(fn, 'r') as f: 395 | for line in f: 396 | m = re.match(r"Name\s?=(.*)$", line) 397 | 398 | if m and len(m.groups()) > 0: 399 | name = m.groups()[0] 400 | break 401 | 402 | return name 403 | 404 | @run_on_executor 405 | def cmd_openApp(self, fn, name=None): 406 | """ 407 | Open desktop file with "dex" command, then try to focus the window 408 | """ 409 | subprocess.Popen(['dex', fn], close_fds=True) 410 | 411 | if name: 412 | # Hopefully the app has started by now 413 | time.sleep(3) 414 | 415 | # Try to bring it to the front 416 | # 417 | # Note: we can't use the pid from the Popen since 418 | # that's the pid of dex, not the program we started 419 | xdo = Xdo() 420 | for windowId in xdo.search_windows(winname=name.encode("utf-8")): 421 | xdo.activate_window(windowId) 422 | 423 | def can_poweroff(self): 424 | bus = dbus.SystemBus() 425 | obj = bus.get_object('org.freedesktop.login1', '/org/freedesktop/login1') 426 | iface = dbus.Interface(obj, 'org.freedesktop.login1.Manager') 427 | result = iface.get_dbus_method("CanPowerOff") 428 | return result() == "yes" 429 | 430 | def can_sleep(self): 431 | bus = dbus.SystemBus() 432 | obj = bus.get_object('org.freedesktop.login1', '/org/freedesktop/login1') 433 | iface = dbus.Interface(obj, 'org.freedesktop.login1.Manager') 434 | result = iface.get_dbus_method("CanSuspend") 435 | return result() == "yes" 436 | 437 | def can_reboot(self): 438 | bus = dbus.SystemBus() 439 | obj = bus.get_object('org.freedesktop.login1', '/org/freedesktop/login1') 440 | iface = dbus.Interface(obj, 'org.freedesktop.login1.Manager') 441 | result = iface.get_dbus_method("CanReboot") 442 | return result() == "yes" 443 | 444 | def cmd_poweroff(self): 445 | bus = dbus.SystemBus() 446 | obj = bus.get_object('org.freedesktop.login1', '/org/freedesktop/login1') 447 | iface = dbus.Interface(obj, 'org.freedesktop.login1.Manager') 448 | method = iface.get_dbus_method("PowerOff") 449 | method(True) 450 | 451 | def cmd_sleep(self): 452 | bus = dbus.SystemBus() 453 | obj = bus.get_object('org.freedesktop.login1', '/org/freedesktop/login1') 454 | iface = dbus.Interface(obj, 'org.freedesktop.login1.Manager') 455 | method = iface.get_dbus_method("Suspend") 456 | method(True) 457 | 458 | def cmd_reboot(self): 459 | bus = dbus.SystemBus() 460 | obj = bus.get_object('org.freedesktop.login1', '/org/freedesktop/login1') 461 | iface = dbus.Interface(obj, 'org.freedesktop.login1.Manager') 462 | method = iface.get_dbus_method("Reboot") 463 | method(True) 464 | 465 | def cmd_lock(self): 466 | bus = dbus.SessionBus() 467 | obj = bus.get_object('org.gnome.ScreenSaver', '/org/gnome/ScreenSaver') 468 | iface = dbus.Interface(obj, 'org.gnome.ScreenSaver') 469 | method = iface.get_dbus_method("SetActive") 470 | method(True) 471 | 472 | def cmd_unlock(self): 473 | bus = dbus.SessionBus() 474 | obj = bus.get_object('org.gnome.ScreenSaver', '/org/gnome/ScreenSaver') 475 | iface = dbus.Interface(obj, 'org.gnome.ScreenSaver') 476 | method = iface.get_dbus_method("SetActive") 477 | method(False) 478 | 479 | if __name__ == "__main__": 480 | # Parse config 481 | if len(sys.argv) < 2: 482 | raise RuntimeError("python3 -m client.main config.yaml") 483 | 484 | configFile = sys.argv[1] 485 | config = {} 486 | 487 | with open(configFile, "r") as f: 488 | config = yaml.load(f) 489 | 490 | assert "server" in config, "Must define server in config" 491 | assert "root" in config, "Must define root in config" 492 | assert "id" in config, "Must define id in config" 493 | assert "token" in config, "Must define token in config" 494 | 495 | # URL of web socket 496 | url = "wss://"+config["server"]+config["root"]+"/con?"+\ 497 | "id="+url_escape(str(config["id"]))+\ 498 | "&token="+url_escape(config["token"]) 499 | 500 | # For now, show info 501 | logging.getLogger().setLevel(logging.INFO) 502 | 503 | # Run the client 504 | client = WSClient(url) 505 | -------------------------------------------------------------------------------- /dialogflow/Linux-Control.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floft/linux-control/1a703c89896d079bf3882ebb9ad39802ed007bbf/dialogflow/Linux-Control.zip -------------------------------------------------------------------------------- /run-client.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd /path/to/linux-control 3 | python3 -m client.main client/config.yaml --debug 4 | -------------------------------------------------------------------------------- /run-server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd /path/to/linux-control 3 | python3 -m server.main server/config.yaml --debug 4 | -------------------------------------------------------------------------------- /server/application.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import redis 4 | import GeoIP 5 | import logging 6 | import collections 7 | import tornado.web 8 | import tornado.httpclient 9 | import oauth2.store.redisdb 10 | 11 | from tornado.options import define, options 12 | from server.site_main import MainHandler 13 | from server.site_account import AccountHandler 14 | from server.dialogflow import DialogFlowHandler 15 | from server.oauth2_provider import OAuth2Handler, OAuth2SiteAdapter 16 | from server.oauth2_login import GoogleOAuth2LoginHandler, LogoutHandler, DeniedHandler 17 | from server.websocket import ClientConnection 18 | 19 | class Application(tornado.web.Application): 20 | def __init__(self, config): 21 | # Save config, e.g. we need root/server later 22 | self.config = config 23 | 24 | # 25 | # Database 26 | # 27 | self.redis = redis.StrictRedis(host=config['redis_host'], 28 | port=config['redis_port'], db=0) 29 | 30 | # 31 | # 32 | # Dictionary of dictionaries of open websockets indexed by user id then computer name 33 | # e.g. { 1: { "laptop": ClientConnection(), "desktop": ClientConnection() ], ... } 34 | # 35 | # Recursive: https://stackoverflow.com/a/19189356/2698494 36 | rec_dd = lambda: collections.defaultdict(rec_dd) 37 | self.clients = rec_dd() 38 | 39 | # 40 | # Looking up location from IP 41 | # 42 | self.gi = GeoIP.GeoIP("/usr/share/GeoIP/GeoIPCity.dat", GeoIP.GEOIP_STANDARD) 43 | 44 | # Get external IP of server 45 | self.serverIp = None 46 | http_client = tornado.httpclient.AsyncHTTPClient() 47 | http_client.fetch("https://api.ipify.org?format=json", self._saveIP) 48 | 49 | # 50 | # OAuth2 provider 51 | # 52 | token_store = oauth2.store.redisdb.TokenStore( 53 | host=config['redis_host'], port=config['redis_port'], db=0, prefix="oauth2") 54 | client_store = oauth2.store.redisdb.ClientStore( 55 | host=config['redis_host'], port=config['redis_port'], db=0, prefix="oauth2") 56 | 57 | # Allow Google Assistant to request access 58 | client_store.add_client( 59 | client_id=config['oauth_google_id'], 60 | client_secret=config['oauth_google_secret'], 61 | redirect_uris=[ 62 | config['oauth_google_uri'], 63 | "https://developers.google.com/oauthplayground" # For debugging 64 | ], 65 | authorized_grants=[ 66 | oauth2.grant.AuthorizationCodeGrant.grant_type, 67 | oauth2.grant.RefreshToken.grant_type 68 | ], 69 | authorized_response_types=["code"] 70 | ) 71 | 72 | # Generator of tokens 73 | token_generator = oauth2.tokengenerator.Uuid4() 74 | 75 | # OAuth2 controller 76 | self.auth_controller = oauth2.Provider( 77 | access_token_store=token_store, 78 | auth_code_store=token_store, 79 | client_store=client_store, 80 | token_generator=token_generator 81 | ) 82 | self.auth_controller.authorize_path = config["root"]+"/oauth/auth" 83 | self.auth_controller.token_path = config["root"]+"/oauth/token" 84 | 85 | # Add Client Credentials to OAuth2 controller 86 | self.site_adapter = OAuth2SiteAdapter() 87 | self.auth_controller.add_grant(oauth2.grant.AuthorizationCodeGrant( 88 | expires_in=86400, site_adapter=self.site_adapter)) # 1 day 89 | # Add refresh token capability and set expiration time of access tokens to 30 days 90 | self.auth_controller.add_grant(oauth2.grant.RefreshToken( 91 | expires_in=2592000, reissue_refresh_tokens=True)) 92 | 93 | # For DialogFlow 94 | credentials = { config['http_auth_user']: config['http_auth_pass'] } 95 | 96 | # 97 | # Tornado 98 | # 99 | handlers = [ 100 | (config["root"], MainHandler), 101 | (config["root"]+"/", MainHandler), 102 | (config["root"]+"/account", AccountHandler), 103 | (config["root"]+"/dialogflow", DialogFlowHandler, dict(credentials=credentials)), 104 | (config["root"]+"/auth/login", GoogleOAuth2LoginHandler), 105 | (config["root"]+"/auth/logout", LogoutHandler), 106 | (config["root"]+"/auth/denied", DeniedHandler), 107 | (config["root"]+"/con", ClientConnection), 108 | (self.auth_controller.authorize_path, OAuth2Handler, dict(provider=self.auth_controller)), 109 | (self.auth_controller.token_path, OAuth2Handler, dict(provider=self.auth_controller)), 110 | ] 111 | settings = dict( 112 | websocket_ping_interval=60, # ping every minute 113 | websocket_ping_timeout=60*3, # close connection if no pong 114 | cookie_secret=config['cookie_secret'], 115 | xsrf_cookies=True, 116 | google_oauth={ 117 | 'key': config['oauth_client_id'], 118 | 'secret': config['oauth_client_secret'] 119 | }, 120 | login_url=config["root"]+"/auth/login", 121 | debug=options.debug, 122 | ) 123 | super(Application, self).__init__(handlers, **settings) 124 | 125 | def _saveIP(self, response): 126 | """ 127 | Callback for saving server ip 128 | """ 129 | if response.error: 130 | logging.error("Could not get server IP: "+str(response.error)) 131 | else: 132 | data = json.loads(response.body) 133 | 134 | if "ip" in data: 135 | self.serverIp = data["ip"] 136 | logging.info("Server IP: "+str(self.serverIp)) 137 | 138 | -------------------------------------------------------------------------------- /server/base.py: -------------------------------------------------------------------------------- 1 | import json 2 | import string 3 | import secrets 4 | import tornado.gen 5 | import tornado.web 6 | import tornado.template 7 | 8 | def genToken(N=30): 9 | """ 10 | Generate a crypographically secure random string of a certain length for 11 | use as a token 12 | 13 | From: https://stackoverflow.com/a/23728630/2698494 14 | """ 15 | return ''.join(secrets.choice(string.ascii_lowercase + 16 | string.ascii_uppercase + string.digits) for _ in range(N)) 17 | 18 | class BaseHandler(tornado.web.RequestHandler): 19 | @property 20 | def pool(self): 21 | return self.application.pool 22 | 23 | @property 24 | def config(self): 25 | return self.application.config 26 | 27 | @property 28 | def redis(self): 29 | return self.application.redis 30 | 31 | @property 32 | def clients(self): 33 | return self.application.clients 34 | 35 | @property 36 | def gi(self): 37 | return self.application.gi 38 | 39 | @property 40 | def serverIp(self): 41 | return self.application.serverIp 42 | 43 | def get_current_user(self): 44 | userid = None 45 | cookie = self.get_secure_cookie("id") 46 | 47 | if cookie: 48 | userid = cookie.decode("utf-8") 49 | 50 | return userid 51 | 52 | def render_from_string(self, tmpl, **kwargs): 53 | """ 54 | From: https://github.com/tornadoweb/tornado/issues/564 55 | """ 56 | namespace = self.get_template_namespace() 57 | namespace.update(kwargs) 58 | return tornado.template.Template(tmpl).generate(**namespace) 59 | 60 | def getIP(self): 61 | return self.request.headers.get('X-Forwarded-For', 62 | self.request.headers.get('X-Real-Ip', 63 | self.request.remote_ip)) 64 | 65 | @tornado.gen.coroutine 66 | def get_tokens(self, userid): 67 | """ 68 | Get the tokens for this user and if they don't exist, return None 69 | """ 70 | laptop_token = None 71 | desktop_token = None 72 | result = self.redis.get("user_"+str(userid)) 73 | 74 | if result: 75 | result = json.loads(result.decode("utf-8")) 76 | 77 | if "laptop_token" in result: 78 | laptop_token = result["laptop_token"] 79 | 80 | if "desktop_token" in result: 81 | desktop_token = result["desktop_token"] 82 | 83 | return laptop_token, desktop_token 84 | 85 | @tornado.gen.coroutine 86 | def get_macs(self, userid): 87 | """ 88 | Get MAC address for WOL packets 89 | """ 90 | laptop_mac = None 91 | desktop_mac = None 92 | result = self.redis.get("user_"+str(userid)) 93 | 94 | if result: 95 | result = json.loads(result.decode("utf-8")) 96 | 97 | if "laptop_mac" in result: 98 | laptop_mac = result["laptop_mac"] 99 | 100 | if "desktop_mac" in result: 101 | desktop_mac = result["desktop_mac"] 102 | 103 | return laptop_mac, desktop_mac 104 | 105 | @tornado.gen.coroutine 106 | def getUserID(self, email): 107 | userid = None 108 | result = self.redis.get("email_"+email) 109 | 110 | if result: 111 | result = json.loads(result.decode("utf-8")) 112 | 113 | if "id" in result: 114 | userid = result["id"] 115 | 116 | return userid 117 | 118 | @tornado.gen.coroutine 119 | def getUserEmail(self, userid): 120 | email = None 121 | result = self.redis.get("user_"+str(userid)) 122 | 123 | if result: 124 | result = json.loads(result.decode("utf-8")) 125 | 126 | if "email" in result: 127 | email = result["email"] 128 | 129 | return email 130 | 131 | @tornado.gen.coroutine 132 | def getUserIDFromToken(self, token): 133 | userid = None 134 | # TODO really should use async redis here or use tornado.gen.Task? 135 | result = self.redis.get("oauth2_"+token) 136 | 137 | if result: 138 | result = json.loads(result.decode("utf-8")) 139 | 140 | if "token" in result and "user_id" in result and result["token"] == token: 141 | userid = result["user_id"] 142 | 143 | return userid 144 | 145 | @tornado.gen.coroutine 146 | def setMACs(self, userid, laptop_mac, desktop_mac): 147 | def _setMACs(pipe): 148 | current = pipe.get("user_"+str(userid)) 149 | 150 | if current: 151 | current = json.loads(current.decode("utf-8")) 152 | current["laptop_mac"] = laptop_mac 153 | current["desktop_mac"] = desktop_mac 154 | pipe.multi() 155 | pipe.set("user_"+str(userid), json.dumps(current)) 156 | 157 | updated = False 158 | self.redis.transaction(_setMACs, "user_"+str(userid)) 159 | 160 | return userid 161 | 162 | @tornado.gen.coroutine 163 | def resetToken(self, userid, computer): 164 | def _resetToken(pipe): 165 | current = pipe.get("user_"+str(userid)) 166 | 167 | if current: 168 | current = json.loads(current.decode("utf-8")) 169 | current[computer+"_token"] = genToken() 170 | pipe.multi() 171 | pipe.set("user_"+str(userid), json.dumps(current)) 172 | 173 | updated = False 174 | self.redis.transaction(_resetToken, "user_"+str(userid)) 175 | 176 | return userid 177 | 178 | @tornado.gen.coroutine 179 | def createUser(self, email): 180 | """ 181 | Create a new user 182 | 183 | Check that the user doesn't in fact exist before this. Otherwise you'll 184 | end up with duplicate users. 185 | """ 186 | # Get user id, set to 0 if it doesn't exist 187 | userid = self.redis.incr("user_increment") 188 | 189 | # Create user account 190 | self.redis.set("user_"+str(userid), 191 | json.dumps({ 192 | "id": userid, 193 | "email": email, 194 | "laptop_token": genToken(), 195 | "desktop_token": genToken(), 196 | "laptop_mac": "", 197 | "desktop_mac": "" 198 | })) 199 | 200 | # Access to user id from email, e.g. for OAuth login via Google 201 | self.redis.set("email_"+email, 202 | json.dumps({ 203 | "id": userid 204 | })) 205 | 206 | return userid 207 | -------------------------------------------------------------------------------- /server/config.yaml.example: -------------------------------------------------------------------------------- 1 | # https and websocket URL, (wss|https)://server/root 2 | # Note: if root is not blank, it needs a / at the beginning 3 | server: domain.tld:port 4 | root: /linux-control 5 | # If not blank, only allow users with these emails to sign up, e.g. don't let 6 | # any random person who finds your server use it. 7 | whitelist_emails: 8 | - you@gmail.com 9 | # Run Tornado on this port 10 | port: 8888 11 | # Connecting to Redis 12 | redis_host: 127.0.0.1 13 | redis_port: 6379 14 | # Generate with `openssl rand -hex 30` for example 15 | cookie_secret: SECRET 16 | # Get these from https://console.cloud.google.com/apis/credentials 17 | oauth_client_id: SOMEID.apps.googleusercontent.com 18 | oauth_client_secret: SECRET 19 | # For the OAuth2 provider, generate secret with `pwgen 30 1` 20 | oauth_google_id: google-assistant 21 | oauth_google_secret: SECRET 22 | oauth_google_uri: https://oauth-redirect.googleusercontent.com/r/YOUR-ACTION-ID 23 | # For Dialogflow fullfillment 24 | http_auth_user: USER 25 | http_auth_pass: PASS 26 | -------------------------------------------------------------------------------- /server/dialogflow.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import tornado.gen 4 | 5 | from tornado_http_auth import BasicAuthMixin 6 | from pywakeonlan.wakeonlan import send_magic_packet 7 | from server.base import BaseHandler 8 | 9 | class DialogFlowHandler(BasicAuthMixin, BaseHandler): 10 | lastComputer = {} 11 | 12 | def initialize(self, credentials): 13 | self.credentials = credentials 14 | 15 | def check_xsrf_cookie(self): 16 | """ 17 | Disable check since DialogFlow logs in via basic HTTP authentication 18 | """ 19 | return True 20 | 21 | def prepare(self): 22 | self.get_authenticated_user(check_credentials_func=self.credentials.get, realm='Protected') 23 | 24 | def get(self): 25 | self.write("This is meant to be a webhook for DialogFlow") 26 | 27 | @tornado.gen.coroutine 28 | def get_wol_mac(self, userid, computer): 29 | laptop_mac, desktop_mac = yield self.get_macs(userid) 30 | 31 | if computer.strip().lower() == "laptop": 32 | return laptop_mac 33 | else: 34 | return desktop_mac 35 | 36 | @tornado.gen.coroutine 37 | def post(self): 38 | data = json.loads(self.request.body.decode('utf-8')) 39 | 40 | # Skip if already answered, e.g. saying "Hi!" will be fulfilled by "Small Talk" 41 | if 'fulfillmentText' in data['queryResult']: 42 | self.write(json.dumps({})) 43 | self.set_header("Content-type", "application/json") 44 | return 45 | 46 | # Make sure the user is logged in and provided a valid access token for a signed-up user 47 | if 'originalDetectIntentRequest' not in data or \ 48 | 'payload' not in data['originalDetectIntentRequest'] or \ 49 | 'user' not in data['originalDetectIntentRequest']['payload'] or \ 50 | 'accessToken' not in data['originalDetectIntentRequest']['payload']['user']: 51 | self.write(json.dumps({ "fulfillmentText": "You must be logged in." })) 52 | self.set_header("Content-type", "application/json") 53 | return 54 | 55 | userid = yield self.getUserIDFromToken(data['originalDetectIntentRequest']['payload']['user']['accessToken']) 56 | 57 | if not userid: 58 | logging.error("Invalid access token - userid: "+str(userid)+", data:"+str(data)) 59 | self.write(json.dumps({ "fulfillmentText": "Invalid access token." })) 60 | self.set_header("Content-type", "application/json") 61 | return 62 | 63 | response = "Sorry, I'm not sure how to answer that." 64 | longResponse = None 65 | 66 | # Determine command/query and respond appropriately 67 | try: 68 | intent = data['queryResult']['intent']['displayName'] 69 | params = data['queryResult']['parameters'] 70 | 71 | if intent == "Computer Command": 72 | command = params['Command'] 73 | computer = params['Computer'] 74 | x = params['X'] 75 | url = params['url'] 76 | number = params['number'] 77 | 78 | # Update last computer used 79 | if computer: 80 | self.lastComputer[userid] = computer 81 | # If no computer specified, use last, if available 82 | elif userid in self.lastComputer: 83 | computer = self.lastComputer[userid] 84 | 85 | # Only command we handle is the WOL packet 86 | if command == "power on": 87 | if computer: 88 | mac = yield self.get_wol_mac(userid, computer) 89 | 90 | if mac: 91 | send_magic_packet(mac, port=9) 92 | response = "Woke your "+computer 93 | else: 94 | response = "Your "+computer+" is not set up for wake-on-LAN" 95 | else: 96 | response = "Please specify which computer you are asking about" 97 | else: 98 | if userid in self.clients and computer in self.clients[userid]: 99 | self.clients[userid][computer].write_message(json.dumps({ 100 | "command": { "command": command, "x": x, "url": url, "number": number } 101 | })) 102 | response, longResponse = yield self.clients[userid][computer].wait_response() 103 | 104 | if not response: 105 | response = "Command sent to "+computer 106 | elif computer: 107 | response = "Your "+computer+" is not currently online" 108 | else: 109 | response = "Please specify which computer you are asking about" 110 | 111 | # TODO 112 | # If this takes too long, then immediately respond "Command sent to laptop" 113 | # and then do this: https://productforums.google.com/forum/#!topic/dialogflow/HeXqMLQs6ok;context-place=forum/dialogflow 114 | # saving context and later returning response or something 115 | elif intent == "Computer Query": 116 | value = params['Value'] 117 | x = params['X'] 118 | computer = params['Computer'] 119 | 120 | # Update last computer used 121 | if computer: 122 | self.lastComputer[userid] = computer 123 | # If no computer specified, use last, if available 124 | elif userid in self.lastComputer: 125 | computer = self.lastComputer[userid] 126 | 127 | # Only query we handle is the "where is my laptop/desktop" 128 | if value == "where": 129 | if computer: 130 | if userid in self.clients and computer in self.clients[userid]: 131 | ip = self.clients[userid][computer].ip 132 | response = "Unknown location for your "+computer 133 | 134 | if ip: 135 | if ip == self.serverIp: 136 | response = "Your "+computer+" is at home" 137 | else: 138 | data = self.gi.record_by_addr(ip) 139 | 140 | if data and "city" in data and "region_name" in data and "country_name" in data: 141 | city = data["city"] 142 | region = data["region_name"] 143 | country = data["country_name"] 144 | response = "Your "+computer+" is in "+city+", "+region+", "+country+" ("+ip+")" 145 | else: 146 | response = "Could not find location of your "+computer 147 | else: 148 | response = "Please specify which computer you are asking about" 149 | else: 150 | if userid in self.clients and computer in self.clients[userid]: 151 | self.clients[userid][computer].write_message(json.dumps({ 152 | "query": { "value": value, "x": x } 153 | })) 154 | response, longResponse = yield self.clients[userid][computer].wait_response() 155 | 156 | if not response: 157 | response = "Your "+computer+" did not respond" 158 | elif computer: 159 | response = "Your "+computer+" is not currently online" 160 | else: 161 | response = "Please specify which computer you are asking about" 162 | except KeyError: 163 | pass 164 | 165 | #"source": string, 166 | #"payload": { }, 167 | #"outputContexts": [ { object(Context) } ], 168 | #"followupEventInput": { object(EventInput) }, 169 | #"fulfillmentMessages": [ { response } ], 170 | 171 | # If desired, display one thing and say another. This is useful for 172 | # example when the displayed text is a file name and you only want to 173 | # read the most relevant part of it. 174 | if longResponse: 175 | json_response = json.dumps({ 176 | "fulfillmentMessages": [{ 177 | "platform": "ACTIONS_ON_GOOGLE", 178 | "simpleResponses": { 179 | "simpleResponses": [{ 180 | "textToSpeech": response, 181 | "displayText": longResponse 182 | }] 183 | } 184 | }]}) 185 | else: 186 | json_response = json.dumps({ "fulfillmentText": response }) 187 | 188 | self.write(json_response) 189 | self.set_header("Content-type", "application/json") 190 | 191 | -------------------------------------------------------------------------------- /server/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import yaml 4 | import logging 5 | import traceback 6 | import tornado.ioloop 7 | import tornado.options 8 | import tornado.httpserver 9 | 10 | from tornado.options import define, options 11 | from server.application import Application 12 | 13 | define("debug", default=False, help="run in debug mode") 14 | 15 | def main(): 16 | # Parse config 17 | if len(sys.argv) < 2: 18 | raise RuntimeError("python3 -m server.main config.yaml [--debug]") 19 | 20 | configFile = sys.argv[1] 21 | config = {} 22 | 23 | with open(configFile, "r") as f: 24 | config = yaml.load(f) 25 | 26 | assert "server" in config, "Must define server in config" 27 | assert "root" in config, "Must define root in config" 28 | assert "port" in config, "Must define port in config" 29 | assert "whitelist_emails" in config, "Must define whitelist_emails in config" 30 | assert "redis_host" in config, "Must define redis_host in config" 31 | assert "redis_port" in config, "Must define redis_port in config" 32 | assert "cookie_secret" in config, "Must define cookie_secret in config" 33 | assert "oauth_client_id" in config, "Must define oauth_client_id in config" 34 | assert "oauth_client_secret" in config, "Must define oauth_client_secret in config" 35 | assert "oauth_google_secret" in config, "Must define oauth_google_secret in config" 36 | assert "oauth_google_uri" in config, "Must define oauth_google_uri in config" 37 | assert "http_auth_user" in config, "Must define http_auth_user in config" 38 | assert "http_auth_pass" in config, "Must define http_auth_pass in config" 39 | 40 | tornado.options.parse_command_line() 41 | http_server = tornado.httpserver.HTTPServer(Application(config)) 42 | http_server.listen(config["port"]) 43 | tornado.ioloop.IOLoop.current().start() 44 | 45 | if __name__ == "__main__": 46 | # For now, show info 47 | logging.getLogger().setLevel(logging.INFO) 48 | 49 | # Run the server 50 | main() 51 | -------------------------------------------------------------------------------- /server/oauth2_login.py: -------------------------------------------------------------------------------- 1 | import tornado.gen 2 | import tornado.auth 3 | 4 | from server.base import BaseHandler 5 | 6 | class GoogleOAuth2LoginHandler(BaseHandler, 7 | tornado.auth.GoogleOAuth2Mixin): 8 | @tornado.gen.coroutine 9 | def get(self): 10 | if self.get_argument('code', False): 11 | access = yield self.get_authenticated_user( 12 | redirect_uri='https://'+self.config['server']+self.config['root']+'/auth/login', 13 | code=self.get_argument('code')) 14 | user = yield self.oauth2_request( 15 | "https://www.googleapis.com/oauth2/v1/userinfo", 16 | access_token=access["access_token"]) 17 | 18 | # If we have a whitelist, make sure the user is on it 19 | if "whitelist_emails" not in self.config or \ 20 | not isinstance(self.config["whitelist_emails"], list) or \ 21 | user['email'] in self.config["whitelist_emails"]: 22 | 23 | # Save the user 24 | userid = yield self.getUserID(user['email']) 25 | 26 | # If not, create the user 27 | if not userid: 28 | userid = yield self.createUser(user["email"]) 29 | 30 | # If user already in the database, add the ID in our cookie 31 | # (required for OAuth2 linking to user account for instance) 32 | self.set_secure_cookie('id', str(userid)) 33 | 34 | # Redirect to a particular page (probably "oauth/auth") if 35 | # specified, otherwise the account page 36 | login_redirect = self.get_secure_cookie("login_redirect") 37 | self.clear_cookie("login_redirect") 38 | 39 | if login_redirect: 40 | login_redirect = login_redirect.decode("utf-8") 41 | self.redirect(login_redirect) 42 | else: 43 | self.redirect(self.config['root']+'/account') 44 | 45 | else: 46 | self.redirect(self.config['root']+'/auth/denied') 47 | else: 48 | yield self.authorize_redirect( 49 | redirect_uri='https://'+self.config['server']+self.config['root']+'/auth/login', 50 | client_id=self.settings['google_oauth']['key'], 51 | scope=['profile', 'email'], 52 | response_type='code', 53 | extra_params={'approval_prompt': 'auto'}) 54 | 55 | class LogoutHandler(BaseHandler): 56 | def get(self): 57 | self.clear_cookie('id') 58 | self.redirect(self.config['root'] + '/') 59 | 60 | class DeniedHandler(BaseHandler): 61 | def get(self): 62 | self.write(""" 63 | 64 | Linux Control 65 | 66 |

Linux Control: Access Denied

67 | 68 |
Your email does not appear to be in the whitelist, so you are not 69 | allowed to create an account on this server.
70 | 71 |
Try logging in again
72 | 73 | 74 | """.format(root=self.config["root"])) 75 | -------------------------------------------------------------------------------- /server/oauth2_provider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import tornado.template 3 | import oauth2.grant 4 | import oauth2.web.tornado 5 | import oauth2.tokengenerator 6 | 7 | from oauth2.web import AuthorizationCodeGrantSiteAdapter 8 | from server.base import BaseHandler 9 | 10 | class OAuth2Handler(BaseHandler, oauth2.web.tornado.OAuth2Handler): 11 | """ 12 | Require the user to be authenticated when going to the authorization page 13 | """ 14 | def check_xsrf_cookie(self): 15 | """ 16 | Only check via our auth form, not when Google gets refresh tokens, etc. 17 | 18 | Note: lazy evaluation means token_path check has to be first 19 | """ 20 | return self.request.path == self.provider.token_path or \ 21 | super(OAuth2Handler, self).check_xsrf_cookie() 22 | 23 | def get(self): 24 | # Only require login for auth, not regenerating tokens 25 | if self.request.path == self.provider.token_path or self.get_current_user(): 26 | response = self._dispatch_request() 27 | self._map_response(response) 28 | else: 29 | self.set_secure_cookie("login_redirect", self.request.uri) 30 | self.redirect(self.config["root"]+"/auth/login") 31 | 32 | def post(self): 33 | if self.request.path == self.provider.token_path or self.get_current_user(): 34 | response = self._dispatch_request() 35 | self._map_response(response) 36 | else: 37 | self.set_secure_cookie("login_redirect", self.request.uri) 38 | self.redirect(self.config["root"]+"/auth/login") 39 | 40 | class OAuth2SiteAdapter(AuthorizationCodeGrantSiteAdapter): 41 | """ 42 | This adapter renders a confirmation page so the user can confirm the auth 43 | request. 44 | 45 | From: http://python-oauth2.readthedocs.io/en/latest/tornado.html 46 | """ 47 | 48 | CONFIRMATION_TEMPLATE = """ 49 | 50 | 51 | OAuth2 Authorization 52 | 66 | 67 | 68 |

Do you want to allow Google Assistant access?

69 | 70 |
71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | {% module xsrf_form_html() %} 79 |
80 | 81 | 82 | """ 83 | def render_from_string(self, request, tmpl, **kwargs): 84 | """ 85 | From: https://github.com/tornadoweb/tornado/issues/564 86 | """ 87 | namespace = request.handler.get_template_namespace() 88 | namespace.update(kwargs) 89 | return tornado.template.Template(tmpl).generate(**namespace) 90 | 91 | def render_auth_page(self, request, response, environ, scopes, client): 92 | scope = request.get_param("scope") 93 | state = request.get_param("state") 94 | redirect_uri = request.get_param("redirect_uri") 95 | response_type = request.get_param("response_type") 96 | client_id = request.get_param("client_id") 97 | 98 | if scope: 99 | scope = tornado.escape.xhtml_escape(scope) 100 | else: 101 | scope = "" 102 | 103 | if state: 104 | state = tornado.escape.xhtml_escape(state) 105 | else: 106 | state = "" 107 | 108 | if redirect_uri: 109 | redirect_uri = tornado.escape.xhtml_escape(redirect_uri) 110 | else: 111 | redirect_uri = "" 112 | 113 | if response_type: 114 | response_type = tornado.escape.xhtml_escape(response_type) 115 | else: 116 | response_type = "" 117 | 118 | if client_id: 119 | client_id = tornado.escape.xhtml_escape(client_id) 120 | else: 121 | client_id = "" 122 | 123 | response.body = self.render_from_string(request, self.CONFIRMATION_TEMPLATE, 124 | url=request.path, 125 | scope=scope, 126 | state=state, 127 | redirect_uri=redirect_uri, 128 | response_type=response_type, 129 | client_id=client_id) 130 | 131 | return response 132 | 133 | def authenticate(self, request, environ, scopes, client): 134 | if request.method == "GET": 135 | if request.get_param("confirm") == "Confirm": 136 | # Must be a tuple with the second an integer user id 137 | # https://github.com/wndhydrnt/python-oauth2/blob/3645093f653d5527f83767f8bb5161f9fd03ad83/oauth2/grant.py#L319 138 | return ({}, request.handler.get_current_user()) 139 | raise oauth2.error.UserNotAuthenticated 140 | 141 | def user_has_denied_access(self, request): 142 | if request.method == "GET": 143 | if request.get_param("deny") == "Deny": 144 | return True 145 | return False 146 | -------------------------------------------------------------------------------- /server/site_account.py: -------------------------------------------------------------------------------- 1 | import tornado.gen 2 | import tornado.web 3 | import tornado.escape 4 | 5 | from server.base import BaseHandler 6 | 7 | class AccountHandler(BaseHandler): 8 | TEMPLATE = """ 9 | 10 | Linux Control 11 | 12 |

Linux Control

13 |
Logged in as: {{ email }}
14 | 15 |

Tokens

16 |
User ID: {{ userid }}
17 |
Laptop token: {{ laptop_token }} (reset)
18 |
Desktop token: {{ desktop_token }} (reset)
19 | 20 |

Wake on LAN

21 |
22 | Laptop MAC:
23 | Desktop MAC:
24 | 25 | {% module xsrf_form_html() %} 26 |
27 | 28 |
Logout
29 | 30 | 31 | """ 32 | 33 | @tornado.gen.coroutine 34 | @tornado.web.authenticated 35 | def get(self): 36 | userid = self.get_current_user() 37 | email = yield self.getUserEmail(userid) 38 | 39 | reset = self.get_argument("reset", "") 40 | 41 | if reset: 42 | if reset == "laptop": 43 | yield self.resetToken(userid, reset) 44 | elif reset == "desktop": 45 | yield self.resetToken(userid, reset) 46 | 47 | # To get rid of the "?reset=" in the request so we don't keep on 48 | # reseting it each time you reload the page 49 | self.redirect(self.request.path) 50 | else: 51 | # Check that this user is in the database and there are tokens for the 52 | # laptop and desktop computers 53 | laptop_token, desktop_token = yield self.get_tokens(userid) 54 | laptop_mac, desktop_mac = yield self.get_macs(userid) 55 | 56 | if laptop_mac: 57 | laptop_mac = tornado.escape.xhtml_escape(laptop_mac) 58 | else: 59 | laptop_mac = "" 60 | 61 | if desktop_mac: 62 | desktop_mac = tornado.escape.xhtml_escape(desktop_mac) 63 | else: 64 | desktop_mac = "" 65 | 66 | self.write(self.render_from_string(self.TEMPLATE, 67 | userid=userid, 68 | email=tornado.escape.xhtml_escape(email), 69 | laptop_token=laptop_token, 70 | desktop_token=desktop_token, 71 | laptop_mac=laptop_mac, 72 | desktop_mac=desktop_mac, 73 | root=self.config["root"], 74 | )) 75 | 76 | @tornado.gen.coroutine 77 | @tornado.web.authenticated 78 | def post(self): 79 | userid = self.get_current_user() 80 | laptop_mac = self.get_argument("laptop_mac", "") 81 | desktop_mac = self.get_argument("desktop_mac", "") 82 | 83 | yield self.setMACs(userid, laptop_mac, desktop_mac) 84 | 85 | self.redirect(self.request.uri) 86 | -------------------------------------------------------------------------------- /server/site_main.py: -------------------------------------------------------------------------------- 1 | from server.base import BaseHandler 2 | 3 | class MainHandler(BaseHandler): 4 | def get(self): 5 | userid = self.get_current_user() 6 | 7 | # If already logged in, forward to the account page 8 | if userid: 9 | self.redirect(self.config["root"]+"/account") 10 | else: 11 | self.write(""" 12 | 13 | Linux Control 14 | 15 |

Linux Control

16 | 17 |
Login
18 | 19 | 20 | """.format(root=self.config["root"])) 21 | 22 | -------------------------------------------------------------------------------- /server/websocket.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import datetime 4 | import tornado.gen 5 | import tornado.queues 6 | import tornado.ioloop 7 | import tornado.websocket 8 | 9 | from server.base import BaseHandler 10 | 11 | class ClientConnection(BaseHandler, 12 | tornado.websocket.WebSocketHandler): 13 | ip = None 14 | userid = None 15 | computer = None 16 | messages = tornado.queues.Queue(maxsize=1) 17 | 18 | @tornado.gen.coroutine 19 | def get_current_user(self): 20 | """ 21 | See if the email/token is valid 22 | """ 23 | if self.userid and self.computer: 24 | return self.userid, self.computer 25 | else: 26 | userid = self.get_argument('id') 27 | token = self.get_argument('token') 28 | 29 | # Check that token is in database for this email 30 | laptop_token, desktop_token = yield self.get_tokens(userid) 31 | 32 | if token == laptop_token: 33 | self.userid = userid 34 | self.computer = "laptop" 35 | elif token == desktop_token: 36 | self.userid = userid 37 | self.computer = "desktop" 38 | else: 39 | self.userid = None 40 | self.computer = None 41 | self.write_message(json.dumps({ 42 | "error": "Permission Denied" 43 | })) 44 | self.close() 45 | 46 | return self.userid, self.computer 47 | 48 | def check_xsrf_cookie(self): 49 | """ 50 | Disable check since the client won't be sending cookies 51 | """ 52 | return True 53 | 54 | @tornado.gen.coroutine 55 | def open(self): 56 | userid, computer = yield self.get_current_user() 57 | 58 | if userid: 59 | self.ip = self.getIP() 60 | self.clients[userid][computer] = self # Note: overwrite previous socket from user 61 | logging.info("WebSocket opened by "+str(userid)+" for "+computer+" on "+self.ip) 62 | else: 63 | logging.warning("WebSocket permission denied") 64 | 65 | @tornado.gen.coroutine 66 | def on_message(self, msg): 67 | userid, computer = yield self.get_current_user() 68 | 69 | if userid: 70 | if msg: 71 | msg = json.loads(msg) 72 | logging.info("Got message "+str(msg)+" from "+str(userid)+" on "+computer) 73 | self.messages.put(msg) 74 | else: 75 | logging.warning("WebSocket message permission denied") 76 | 77 | def on_close(self): 78 | found = False 79 | 80 | for userid, computers in self.clients.items(): 81 | for computer, socket in computers.items(): 82 | if socket == self: 83 | found = True 84 | del self.clients[userid][computer] 85 | break 86 | 87 | logging.info("WebSocket closed, did " + ("" if found else "not ") + "find in list of saved sockets") 88 | 89 | #def on_pong(self, data): 90 | # logging.info("Got pong") 91 | 92 | @tornado.gen.coroutine 93 | def wait_response(self): 94 | """ 95 | Wait for the response for a certain time, if it comes, return it. 96 | If it doesn't come before the timeout, return None 97 | """ 98 | response = None 99 | longResponse = None 100 | timeout = datetime.timedelta(seconds=4) # DialogFlow's timeout is 5 seconds 101 | 102 | try: 103 | msg = yield self.messages.get(timeout=timeout) 104 | except tornado.gen.TimeoutError: 105 | pass 106 | else: 107 | if "response" in msg: 108 | response = msg["response"] 109 | 110 | if "longResponse" in msg: 111 | longResponse = msg["longResponse"] 112 | 113 | return response, longResponse 114 | -------------------------------------------------------------------------------- /sync.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | rsync -av --info=progress2 --exclude=.git --exclude=run.sh \ 3 | /home/garrett/Documents/Github/linux-control/ rpi:/srv/http/linux-control/ 4 | --------------------------------------------------------------------------------