├── .gitignore
├── dialogflow
└── Linux-Control.zip
├── run-client.sh
├── run-server.sh
├── .gitmodules
├── sync.sh
├── client
├── config.yaml.example
└── main.py
├── server
├── site_main.py
├── config.yaml.example
├── main.py
├── oauth2_login.py
├── site_account.py
├── websocket.py
├── oauth2_provider.py
├── application.py
├── base.py
└── dialogflow.py
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | run.sh
2 | *.yaml
3 | __pycache__
4 |
--------------------------------------------------------------------------------
/dialogflow/Linux-Control.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/floft/linux-control/HEAD/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 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "pywakeonlan"]
2 | path = pywakeonlan
3 | url = https://github.com/remcohaszing/pywakeonlan
4 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
18 |
19 |
20 | """.format(root=self.config["root"]))
21 |
22 |
--------------------------------------------------------------------------------
/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/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 |
72 |
73 |
74 | """.format(root=self.config["root"]))
75 |
--------------------------------------------------------------------------------
/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 |
27 |
28 |
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/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 |
--------------------------------------------------------------------------------
/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 |
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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------