├── .gitattributes
├── media
└── icon.png
├── uninstall.sh
├── scripts
└── vpn-toggle.sh
├── src
├── usr
│ ├── share
│ │ └── polkit-1
│ │ │ └── actions
│ │ │ └── vpn-manager.policy
│ └── local
│ │ └── bin
│ │ ├── __vpn_connect
│ │ └── __vpn_manager
└── etc
│ ├── NetworkManager
│ └── dispatcher.d
│ │ └── vpn-manager-refresh
│ └── keyd
│ └── vpn-manager.conf
├── .github
└── workflows
│ └── ci.yml
├── LICENSE.md
├── patches
└── custom-dns-resolver.patch
├── install-steamos.sh
├── .gitignore
├── CODE_OF_CONDUCT.md
├── README.md
└── install.sh
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/media/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Kira-NT/outline-cli/HEAD/media/icon.png
--------------------------------------------------------------------------------
/uninstall.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #
3 | # Uninstall the Outline Client CLI.
4 |
5 | ./install.sh -u
6 |
--------------------------------------------------------------------------------
/scripts/vpn-toggle.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | zenity --info --title "Outline" --text "$(pkexec /usr/local/bin/__vpn_manager connect)" --width 300 2> /dev/null
4 | pkexec /usr/local/bin/__vpn_manager disconnect > /dev/null 2>& 1
5 |
--------------------------------------------------------------------------------
/src/usr/share/polkit-1/actions/vpn-manager.policy:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 | Kira-NT
8 | https://github.com/Kira-NT/outline-cli
9 | network-wireless
10 |
11 |
12 | VPN
13 | Authentication is required to change or query the current VPN status
14 |
15 | auth_admin
16 | yes
17 | yes
18 |
19 | /usr/local/bin/__vpn_manager
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 |
3 | on: [ push, pull_request, workflow_dispatch ]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - uses: actions/checkout@v4
11 |
12 | - name: Install `outline-cli`
13 | run: sudo ./install.sh -y
14 |
15 | - name: Ensure `outline-cli` is accessible
16 | run: |
17 | sudo vpn --help && \
18 | sudo vpn ls && \
19 | sudo vpn add "ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTo1UkVmeFRqbHR6Mkw@outline-server.example.com:17178" && \
20 | sudo vpn ls && \
21 | sudo vpn add "ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTo1UkVmeFRqbHR6Mkw@127.0.0.1:8000" localhost && \
22 | sudo vpn ls && \
23 | sudo vpn rm 1 && \
24 | sudo vpn ls
25 |
26 | - name: Uninstall `outline-cli`
27 | run: sudo ./uninstall.sh
28 |
29 | - name: Ensure `outline-cli` is no longer accessible
30 | run: "! sudo vpn --help"
31 |
--------------------------------------------------------------------------------
/src/etc/NetworkManager/dispatcher.d/vpn-manager-refresh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #
3 | # Refresh a connection to a Shadowsocks server
4 | # in response to network events.
5 |
6 | # We only need to act when a wired or wireless
7 | # connection is activated or deactivated.
8 | # Exit early for all other types of network events.
9 | [ "${2}" = "up" ] || [ "${2}" = "down" ] || exit 0
10 | grep -sqE "type=(eth|wifi)" "${CONNECTION_FILENAME}" || exit 0
11 |
12 | case "${2}" in
13 | # If the network went up, resume a suspended
14 | # connection to a Shadowsocks server, if any.
15 | up)
16 | /usr/local/bin/__vpn_manager status -q
17 | if [ $? -ne 1 ]; then
18 | /usr/local/bin/__vpn_manager connect -q &
19 | fi
20 | ;;
21 |
22 | # If the network went down, suspend a connection
23 | # to a Shadowsocks server, if any, to prevent
24 | # the system from reporting an interface with
25 | # no internet access.
26 | down) /usr/local/bin/__vpn_manager disconnect -s -q & ;;
27 | esac
28 |
29 | # Always return a success status code
30 | # to avoid confusing the NetworkManager.
31 | exit 0
32 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024-2025 Kira NT
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/patches/custom-dns-resolver.patch:
--------------------------------------------------------------------------------
1 | From 0dee551ff51ea6721858ee5fe4d58b48b6b1827a Mon Sep 17 00:00:00 2001
2 | From: Kira-NT
3 | Date: Sun, 16 Mar 2024 17:28:49 +0000
4 | Subject: [PATCH] Added a way to specify a custom DNS resolver
5 |
6 | See Jigsaw-Code/outline-apps#568
7 | ---
8 | x/examples/outline-cli/main.go | 8 ++++++++
9 | 1 file changed, 8 insertions(+)
10 |
11 | diff --git a/x/examples/outline-cli/main.go b/x/examples/outline-cli/main.go
12 | index 1be0096..82501c4 100644
13 | --- a/x/examples/outline-cli/main.go
14 | +++ b/x/examples/outline-cli/main.go
15 | @@ -19,6 +19,7 @@ import (
16 | "fmt"
17 | "io"
18 | "log"
19 | + "net/url"
20 | "os"
21 | )
22 |
23 | @@ -49,6 +50,13 @@ func main() {
24 | }
25 | flag.Parse()
26 |
27 | + if transportUrl, _ := url.Parse(*app.TransportConfig); transportUrl != nil {
28 | + if dns := transportUrl.Query().Get("dns"); dns != "" {
29 | + app.RoutingConfig.DNSServerIP = dns
30 | + }
31 | + }
32 | + logging.Info.Printf("updated system DNS resolver: %v\n", app.RoutingConfig.DNSServerIP)
33 | +
34 | if err := app.Run(); err != nil {
35 | logging.Err.Printf("%v\n", err)
36 | }
37 | --
38 | 2.44.0
39 |
--------------------------------------------------------------------------------
/install-steamos.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | error() {
4 | echo "${0}: ${1}" >& 2
5 | return 1
6 | }
7 |
8 | is_root() {
9 | [ "$(id -u)" -eq 0 ]
10 | }
11 |
12 | is_steamos() {
13 | [ -f "/etc/os-release" ] &&
14 | . "/etc/os-release" &&
15 | [ "${ID}" = "steamos" ]
16 | }
17 |
18 | is_installed() {
19 | [ -f "/usr/local/bin/__vpn_connect" ] && [ -x "/usr/local/bin/__vpn_connect" ] &&
20 | [ -f "/usr/local/bin/__vpn_manager" ] && [ -x "/usr/local/bin/__vpn_manager" ] &&
21 | [ -f "/usr/share/polkit-1/actions/vpn-manager.policy" ]
22 | }
23 |
24 | install_local() {
25 | "${@}"
26 | }
27 |
28 | install_remote() {
29 | curl -Ls "https://github.com/Kira-NT/outline-cli/blob/master/install.sh?raw=true" | sh -s -- "${@}"
30 | }
31 |
32 | install() {
33 | local install_filename="$(dirname "$(realpath -m "${0}")")/install.sh"
34 | local expected_header="#!/bin/sh Install the Outline Client CLI."
35 | local header="$(sed -n '1,3N;N;s/\n#//g;1,3p;q' "${install_filename}" 2> /dev/null)"
36 | if [ -x "${install_filename}" ] && [ "${header}" = "${expected_header}" ]; then
37 | install_local "${install_filename}" "${@}" <& 1
38 | else
39 | install_remote "${@}" <& 1
40 | fi
41 | }
42 |
43 | install_steamos_packages() {
44 | steamos-readonly disable 2> /dev/null
45 | pacman-key --init 2> /dev/null
46 | pacman-key --populate holo 2> /dev/null
47 | pacman -S --noconfirm git base-devel linux-neptune-headers glibc linux-api-headers 2> /dev/null
48 | }
49 |
50 | main() {
51 | is_installed && return
52 | is_steamos || error "cannot perform the installation: ${ID:-"unknown"}: Distribution is not supported" || return
53 | is_root || error "cannot perform the installation: Permission denied" || return
54 |
55 | install_steamos_packages
56 | install "${@}"
57 | }
58 |
59 | main "${@}"
60 |
--------------------------------------------------------------------------------
/src/etc/keyd/vpn-manager.conf:
--------------------------------------------------------------------------------
1 | [ids]
2 | *
3 |
4 | # Configured binds:
5 | #
6 | # Ctrl+Super+v = Toggle the current connection
7 | # Ctrl+Super+v+- = Disconnect from the current server
8 | # Ctrl+Super+v+= = Connect to a server you were last connected to
9 | # Ctrl+Super+v+0 = Show the current connection status
10 | # Ctrl+Super+v+1 = Connect to a server using the 1st access key
11 | # Ctrl+Super+v+2 = Connect to a server using the 2nd access key
12 | # Ctrl+Super+v+3 = Connect to a server using the 3rd access key
13 | # Ctrl+Super+v+4 = Connect to a server using the 4th access key
14 | # Ctrl+Super+v+5 = Connect to a server using the 5th access key
15 | # Ctrl+Super+v+6 = Connect to a server using the 6th access key
16 | # Ctrl+Super+v+7 = Connect to a server using the 7th access key
17 | # Ctrl+Super+v+8 = Connect to a server using the 8th access key
18 | # Ctrl+Super+v+9 = Connect to a server using the 9th access key
19 | #
20 | # To remove Ctrl from all the binds, thereby reducing the number of
21 | # keys needed to be pressed, simply delete "control+" from the line
22 | # below this comment section.
23 | #
24 | # This is not done by default because it will interfere with
25 | # the `Super+v` hotkey (which displays the clipboard history) often
26 | # present on some desktops. But if you don't use this (good!), then
27 | # there's nothing to worry about.
28 |
29 | [control+meta]
30 | v = overload(vpn_manager, command(/usr/local/bin/__vpn_manager toggle -n &))
31 |
32 | [vpn_manager]
33 | 1 = command(/usr/local/bin/__vpn_manager connect 1 -n &)
34 | 2 = command(/usr/local/bin/__vpn_manager connect 2 -n &)
35 | 3 = command(/usr/local/bin/__vpn_manager connect 3 -n &)
36 | 4 = command(/usr/local/bin/__vpn_manager connect 4 -n &)
37 | 5 = command(/usr/local/bin/__vpn_manager connect 5 -n &)
38 | 6 = command(/usr/local/bin/__vpn_manager connect 6 -n &)
39 | 7 = command(/usr/local/bin/__vpn_manager connect 7 -n &)
40 | 8 = command(/usr/local/bin/__vpn_manager connect 8 -n &)
41 | 9 = command(/usr/local/bin/__vpn_manager connect 9 -n &)
42 | 0 = command(/usr/local/bin/__vpn_manager status -n &)
43 | - = command(/usr/local/bin/__vpn_manager disconnect -n &)
44 | = = command(/usr/local/bin/__vpn_manager connect -n &)
45 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | $tf/
2 | **/*.DesktopClient/GeneratedArtifacts
3 | **/*.DesktopClient/ModelManifest.xml
4 | **/*.HTMLClient/GeneratedArtifacts
5 | **/*.Server/GeneratedArtifacts
6 | **/*.Server/ModelManifest.xml
7 | **/packages/*
8 | *.[Cc]ache
9 | *.[Pp]ublish.xml
10 | *.[Rr]e[Ss]harper
11 | *.aps
12 | *.azurePubxml
13 | *.bim.layout
14 | *.bim_*.settings
15 | *.build.csdef
16 | *.cachefile
17 | *.dbmdl
18 | *.dbproj.schemaview
19 | *.dotCover
20 | *.DotSettings.user
21 | *.GhostDoc.xml
22 | *.gpState
23 | *.ilk
24 | *.jfm
25 | *.ldf
26 | *.log
27 | *.mdf
28 | *.meta
29 | *.mm.*
30 | *.ncb
31 | *.nuget.props
32 | *.nuget.targets
33 | *.nupkg
34 | *.nuspec
35 | *.obj
36 | *.opendb
37 | *.opensdf
38 | *.opt
39 | *.pch
40 | *.pdb
41 | *.pfx
42 | *.pgc
43 | *.pgd
44 | *.pidb
45 | *.plg
46 | *.psess
47 | *.publishproj
48 | *.publishsettings
49 | *.pubxml
50 | *.pyc
51 | *.rdl.data
52 | *.rsp
53 | *.sap
54 | *.sbr
55 | *.scc
56 | *.sdf
57 | *.sln.docstates
58 | *.sln.iml
59 | *.suo
60 | *.suppress
61 | *.svclog
62 | *.tlb
63 | *.tlh
64 | *.tli
65 | *.tmp
66 | *.tmp_proj
67 | *.user
68 | *.userosscache
69 | *.userprefs
70 | *.VC.db
71 | *.VC.VC.opendb
72 | *.VisualState.xml
73 | *.vsp
74 | *.vspscc
75 | *.vspx
76 | *.vssscc
77 | *_i.c
78 | *_i.h
79 | *_p.c
80 | *~
81 | .*crunch*.local.xml
82 | .builds
83 | .cr/
84 | .env
85 | .fake/
86 | .gradle/
87 | .idea/
88 | .JustCode
89 | .ntvs_analysis.dat
90 | .paket/paket.exe
91 | .sass-cache/
92 | .vs/
93 | /node_modules
94 | /run
95 | /wwwroot/dist/
96 | [Bb]in/
97 | [Bb]uild[Ll]og.*
98 | [Dd]ebug/
99 | [Dd]ebugPS/
100 | [Dd]ebugPublic/
101 | [Ee]xpress/
102 | [Ll]og/
103 | [Ll]ogs/
104 | [Oo]bj/
105 | [Pp]ublish/
106 | [Rr]elease/
107 | [Rr]eleasePS/
108 | [Rr]eleases/
109 | [Tt]est[Rr]esult*/
110 | __pycache__/
111 | _Chutzpah*
112 | _NCrunch_*
113 | _pkginfo.txt
114 | _Pvt_Extensions
115 | _ReSharper*/
116 | _TeamCity*
117 | _UpgradeReport_Files/
118 | ~$*
119 | ApplicationInsights.config
120 | AppPackages/
121 | appsettings.*.json
122 | artifacts/
123 | AutoTest.Net/
124 | Backup*/
125 | bin/
126 | Bin/
127 | bld/
128 | build/
129 | BundleArtifacts/
130 | ClientBin/
131 | csx/
132 | dist/
133 | dlldata.c
134 | DocProject/buildhelp/
135 | DocProject/Help/*.hhc
136 | DocProject/Help/*.hhk
137 | DocProject/Help/*.hhp
138 | DocProject/Help/*.HxC
139 | DocProject/Help/*.HxT
140 | DocProject/Help/html
141 | DocProject/Help/Html2
142 | ecf/
143 | FakesAssemblies/
144 | Generated_Code/
145 | internal_test/
146 | ipch/
147 | nCrunchTemp_*
148 | node_modules/
149 | orleans.codegen.cs
150 | Package.StoreAssociation.xml
151 | paket-files/
152 | project.fragment.lock.json
153 | project.lock.json
154 | PublishScripts/
155 | rcf/
156 | TestResult.xml
157 | UpgradeLog*.htm
158 | UpgradeLog*.XML
159 | x64/
160 | x86/
161 | !src/**/[Bb]in/
162 |
--------------------------------------------------------------------------------
/src/usr/local/bin/__vpn_connect:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #
3 | # Connect to a Shadowsocks server.
4 |
5 | #################################################
6 | # Configure internal implementation details. #
7 | #################################################
8 | CONNECTION_PID=""
9 |
10 | #################################################
11 | # Connects to a Shadowsocks server using
12 | # the specified access key.
13 | # Arguments:
14 | # $1. The access key to use.
15 | #################################################
16 | connect() {
17 | echo "${0}: unsupported operation" >& 2 # CONNECT_TEMPLATE &
18 | CONNECTION_PID=$!
19 | [ -n "${CONNECTION_PID}" ] && wait "${CONNECTION_PID}"
20 | }
21 |
22 | #################################################
23 | # Disconnects from the currently active server,
24 | # if any. Otherwise, does nothing.
25 | # Arguments:
26 | # None
27 | #################################################
28 | disconnect() {
29 | [ -n "${CONNECTION_PID}" ] && kill -TERM "${CONNECTION_PID}"
30 | }
31 |
32 | #################################################
33 | # Prints a brief help message.
34 | # Arguments:
35 | # None
36 | # Outputs:
37 | # Writes the help message to stdout.
38 | #################################################
39 | help() {
40 | echo "Usage: ${0} [] []"
41 | echo
42 | echo "Connect to a Shadowsocks server."
43 | echo
44 | echo "Examples:"
45 | echo " ${0} -transport \"ss://...\""
46 | echo
47 | echo "Options:"
48 | echo " -h, -help Display this help text and exit"
49 | echo " -transport The server access key (usually starts with \"ss://\")"
50 | }
51 |
52 | #################################################
53 | # Formats and prints the provided error message,
54 | # displays the help page, and terminates the
55 | # process.
56 | # Arguments:
57 | # $1. The error message to format and print.
58 | # Outputs:
59 | # Writes the formatted error message to stderr.
60 | # Returns:
61 | # Never returns (exits with a status of 1).
62 | #################################################
63 | fatal_error() {
64 | echo "${0}: ${1}" >& 2
65 | help >& 2
66 | exit 1
67 | }
68 |
69 | #################################################
70 | # The main entry point for the script.
71 | # Arguments:
72 | # ... A list of the command line arguments.
73 | #################################################
74 | main() {
75 | local transport=""
76 |
77 | # Parse the arguments and options.
78 | while [ -n "${1}" ]; do
79 | case "${1}" in
80 | -h|-help|--help) help; exit 0 ;;
81 | -t|-transport|--transport) transport="${2}"; shift ;;
82 | -*) fatal_error "invalid option: ${1}" ;;
83 | *) fatal_error "invalid argument: ${1}" ;;
84 | esac
85 | shift 2> /dev/null
86 | done
87 |
88 | connect "${transport}"
89 | }
90 |
91 | trap disconnect HUP INT QUIT TERM
92 | main "${@}"
93 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, caste, color, religion, or sexual
10 | identity and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the overall
26 | community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or advances of
31 | any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email address,
35 | without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | [kira.canary@proton.me](mailto:kira.canary@proton.me).
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series of
86 | actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or permanent
93 | ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within the
113 | community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.1, available at
119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120 |
121 | Community Impact Guidelines were inspired by
122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123 |
124 | For answers to common questions about this code of conduct, see the FAQ at
125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126 | [https://www.contributor-covenant.org/translations][translations].
127 |
128 | [homepage]: https://www.contributor-covenant.org
129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130 | [Mozilla CoC]: https://github.com/mozilla/diversity
131 | [FAQ]: https://www.contributor-covenant.org/faq
132 | [translations]: https://www.contributor-covenant.org/translations
133 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Outline CLI
2 |
3 | [](https://github.com/Kira-NT/outline-cli/actions/workflows/ci.yml)
4 | [](https://github.com/Kira-NT/outline-cli/releases/latest)
5 | [](LICENSE.md)
6 |
7 |
8 |
9 | [Outline](https://getoutline.org/), developed by [Jigsaw](https://jigsaw.google.com/), is a great project aimed at simplifying the task of creating self-hosted VPN servers down to a matter of seconds. However, while the server deployment with Outline is commendable, its client-side experience falls short. Currently, the primary method of connecting to an [Outline server](https://github.com/Jigsaw-Code/outline-server/) is via the [Outline Client](https://github.com/Jigsaw-Code/outline-apps/) - an Electron *(or Cordova for mobile devices)* app - which unfortunately presents lots of issues across different platforms. It is somewhat buggy on Windows, notably unreliable on macOS, and entirely non-functional on Linux *(at least if Linux means more than just Ubuntu to you)*.
10 |
11 | Fortunately, at least for Linux users, there's an alternative - a [CLI solution](https://github.com/Jigsaw-Code/outline-sdk/tree/main/x/examples/outline-cli/) hidden within the "experimental" section of the [outline-sdk](https://github.com/Jigsaw-Code/outline-sdk/) repo, which only supports Linux at the moment of writing. Despite its minimalistic design, the CLI performs its sole function - establishing a connection to a selected server and rerouting all traffic through it - quite efficiently and reliably. However, it may not be very user-friendly to manually launch/restart it, manage your access keys, and forcibly stop it when you wish to disconnect from the VPN server, and so on. This is where this project comes in.
12 |
13 | This project, essentially a wrapper over the official [outline-cli](https://github.com/Jigsaw-Code/outline-sdk/tree/main/x/examples/outline-cli/) provided by [Jigsaw](https://github.com/Jigsaw-Code/), aims to make its usage easier. Comprised of just a few shell scripts, it enhances the default experience into a near-perfect one - unachievable by a buggy Electron app ;)
14 |
15 | ----
16 |
17 | ## Features
18 |
19 | Obviously, the official CLI provides you with the ability to connect to servers deployed by the Outline Manager, or to any Shadowsocks server, for that matter. Here is what this project offers on top of that:
20 |
21 | - Access key management
22 | - [`ssconf://`](https://www.reddit.com/r/outlinevpn/wiki/index/dynamic_access_keys/) support
23 | - Option to specify a custom DNS resolver instead of the hardcoded one
24 | - Ability to connect to devices on your local network while the VPN is active
25 | - Automatic reconnection to the VPN server upon reboot/network status change *(requires `NetworkManager`)*
26 | - Easy integration with other tools, thanks to the highly scriptable nature of this project
27 |
28 | ----
29 |
30 | ## Installation
31 |
32 | The installation process is quite straightforward: just clone this repo and run the `install.sh` script.
33 |
34 | ```sh
35 | git clone https://github.com/Kira-NT/outline-cli
36 | cd outline-cli
37 | sudo ./install.sh -y
38 | ```
39 |
40 | Alternatively, for `curl $URL | sudo bash` enjoyers, the same result can be achieved with the following one-liner:
41 |
42 | ```sh
43 | curl -Ls https://github.com/Kira-NT/outline-cli/blob/master/install.sh?raw=true | sudo bash -s -- -y
44 | ```
45 |
46 | This will:
47 |
48 | 1) Install any missing dependencies: `git`, `tar`, `jq`, `curl` or `wget`, `gcc` or `clang`
49 | 2) Build and install the official [`outline-cli`](https://github.com/Jigsaw-Code/outline-sdk/tree/main/x/examples/outline-cli/)
50 | 1) Clone [`Jigsaw-Code/outline-sdk`](https://github.com/Jigsaw-Code/outline-sdk/)
51 | 2) Apply a patch that allows you to specify a custom DNS resolver instead of the hardcoded one
52 | 3) Download the latest version of Go *(locally, not globally)*
53 | 4) Build the app
54 | 5) Copy the resulting binary to `/usr/local/bin/__vpn_connect`
55 | 6) Clean up
56 | 3) Copy the `__vpn_manager` script to `/usr/local/bin/__vpn_manager`
57 | 4) Create a symlink for `__vpn_manager`, enabling you to call it using the shorthand `vpn`
58 | 5) Create a `polkit-1` policy, enabling you to call `__vpn_manager` from scripts
59 | 6) Copy the `vpn-manager-refresh` script to `/etc/NetworkManager/dispatcher.d/vpn-manager-refresh`, ensuring it executes each time your network connection status changes
60 |
61 | If you wish to configure the process described above, you can run the installation script without the `-y` flag, allowing you to guide it and adjust the results according to your preferences.
62 |
63 | ----
64 |
65 | ## Usage
66 |
67 | ```
68 | Usage: vpn [] []
69 |
70 | Manage Shadowsocks server connections and related access keys.
71 |
72 | Examples:
73 | sudo vpn add "ss://..." "Geneva"
74 | sudo vpn connect geneva
75 | sudo vpn disconnect
76 |
77 | Commands:
78 | add [] Add a new access key
79 | remove Remove the designated access key
80 | list [-f ] List all stored access keys
81 | connect [] Connect to a server
82 | disconnect [-s] Disconnect from the current server
83 | toggle Toggle the current connection
84 | status Return the current connection status
85 |
86 | Options:
87 | -h, --help Display this help text and exit
88 | -v, --version Display version information and exit
89 | -q, --quiet Suppress all normal output
90 | -n, --notify Display a notification
91 | -s, --suspend Suspend the current connection;
92 | It will be re-established later
93 | -f, --format Print a key according to the ;
94 | The formats are: %name%, %ip%, %index%
95 | ```
96 |
97 | First and foremost, akin to the GUI app, you must add an access key. For example:
98 |
99 | ```sh
100 | sudo vpn add "ss://YFhvLUKmUBJwEsSfL65ShwAOzuLDNN0HTBh9rCb2yhIJdrMXBhgP1DXBm4y7@42.42.42.42:52683/?outline=1" "Geneva"
101 | ```
102 |
103 | You can list all stored access keys using the `sudo vpn list` command:
104 |
105 | ```
106 | 1 Geneva 42.42.42.42:52683
107 | ```
108 |
109 | Once done, you're all set! Use:
110 |
111 | ```sh
112 | sudo vpn connect "Geneva"
113 | ```
114 |
115 | or one of these alternatives:
116 |
117 | ```sh
118 | # The access key name is case-insensitive.
119 | sudo vpn connect geneva
120 |
121 | # You can also use the saved access key's ID
122 | # (i.e., its ordinal number from the `list` output).
123 | sudo vpn connect 1
124 |
125 | # You can also omit the name/ID entirely.
126 | # This will connect you to the last server you connected to.
127 | sudo vpn connect
128 | ```
129 |
130 | to connect to the desired VPN server.
131 |
132 | Whenever you wish to disconnect from the current server, simply use:
133 |
134 | ```sh
135 | sudo vpn disconnect
136 | ```
137 |
138 | And that about covers the most essential functionalities you'll need.
139 |
140 | ----
141 |
142 | ## Keyboard Shortcuts
143 |
144 | While this project doesn't provide a built-in solution for automatically creating keyboard shortcuts *(since everyone does it differently, and there's no point in even attempting to support every DE/WM/key remapping tool in existence)*, it is designed to be easily integrated with other tools and is highly scriptable.
145 |
146 | Here’s a table with some common actions you might want to perform and the commands that achieve the desired results:
147 |
148 | | Description | Command |
149 | |:--------------------------------------------------------|:----------------------------------------------------|
150 | | Toggle the current connection | `pkexec /usr/local/bin/__vpn_manager toggle -n` |
151 | | Disconnect from the current server | `pkexec /usr/local/bin/__vpn_manager disconnect -n` |
152 | | Connect to the server you were last connected to | `pkexec /usr/local/bin/__vpn_manager connect -n` |
153 | | Show the current connection status | `pkexec /usr/local/bin/__vpn_manager status -n` |
154 | | Connect to a server using the 1st access key | `pkexec /usr/local/bin/__vpn_manager connect 1 -n` |
155 | | Connect to a server using the 2nd access key | `pkexec /usr/local/bin/__vpn_manager connect 2 -n` |
156 | | Connect to a server using the 3rd access key | `pkexec /usr/local/bin/__vpn_manager connect 3 -n` |
157 | | Connect to a server using the 4th access key | `pkexec /usr/local/bin/__vpn_manager connect 4 -n` |
158 | | Connect to a server using the 5th access key | `pkexec /usr/local/bin/__vpn_manager connect 5 -n` |
159 | | Connect to a server using the 6th access key | `pkexec /usr/local/bin/__vpn_manager connect 6 -n` |
160 | | Connect to a server using the 7th access key | `pkexec /usr/local/bin/__vpn_manager connect 7 -n` |
161 | | Connect to a server using the 8th access key | `pkexec /usr/local/bin/__vpn_manager connect 8 -n` |
162 | | Connect to a server using the 9th access key | `pkexec /usr/local/bin/__vpn_manager connect 9 -n` |
163 |
164 | With this, you can easily configure keyboard shortcuts either directly via the keyboard settings provided by your DE/WM *(e.g., KDE, GNOME, etc.)*, or via a key remapping tool of your choice.
165 |
166 | For example, I use [`keyd`](https://github.com/rvaiya/keyd/) - a very nice, modular, and efficient key remapping daemon. You can check my config, which implements all the shortcuts mentioned above, here: [`src/etc/keyd/vpn-manager.conf`](src/etc/keyd/vpn-manager.conf).
167 |
168 | ----
169 |
170 | ## Custom DNS Resolver
171 |
172 | The Outline Client hardcodes its preferred DNS resolver. This has already caused many problems for numerous users in the past and will undoubtedly cause even more issues in the future.
173 |
174 | While striving for the perfect solution is understandable, "perfect" can often be the enemy of "good." This is precisely what we see here, as after all these years, there is still no option to override these hardcoded values. Hence, this project proposes a simple eight-line patch that allows you to supply a DNS resolver of your choice to the Outline Client.
175 |
176 | If you followed the standard installation script, you can specify the DNS resolver you prefer directly in your access key via the new `dns` URL parameter, as demonstrated below:
177 |
178 | ```diff
179 | - ss://.../?outline=1
180 | + ss://.../?outline=1&dns=1.1.1.1
181 | ```
182 |
183 | ----
184 |
185 | ## Legal Notice
186 |
187 | This project is an independent work and is not affiliated, endorsed, authorized, or sponsored by Jigsaw LLC or any of its subsidiaries or affiliates. Outline and the Outline logo are trademarks and/or registered trademarks of Jigsaw LLC in the U.S. and/or other countries. All rights reserved to their respective owners.
188 |
189 | The purpose of this project is purely educational and non-commercial. The code and information found within this project are for educational use only and are not intended for any kind of commercial use.
190 |
191 | Use this software at your own risk. The creators cannot be held responsible for any misuse or damages caused by this software.
192 |
193 | This notice serves as a disclaimer. Any violations of Jigsaw's policies or trademarks are not intentional.
194 |
195 | ----
196 |
197 | ## License
198 |
199 | Licensed under the terms of the [MIT License](LICENSE.md).
200 |
--------------------------------------------------------------------------------
/install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #
3 | # Install the Outline Client CLI.
4 |
5 | #################################################
6 | # The URL of the repo that contains this file.
7 | #################################################
8 | REPO="https://github.com/Kira-NT/outline-cli"
9 |
10 | #################################################
11 | # The default name for the __vpn_manager symlink.
12 | #################################################
13 | DEFAULT_VPN_MANAGER_NAME="vpn"
14 |
15 | #################################################
16 | # Indicates whether the user should be prompted
17 | # for input or if default response values should
18 | # be used instead.
19 | #################################################
20 | USE_DEFAULT_RESPONSE=false
21 |
22 | #################################################
23 | # Formats and prints the provided error message.
24 | # Arguments:
25 | # $1. The error message to format and print.
26 | # Outputs:
27 | # Writes the formatted error message to stderr.
28 | # Returns:
29 | # Always returns 1.
30 | #################################################
31 | error() {
32 | echo "${0}: ${1}" >& 2
33 | return 1
34 | }
35 |
36 | #################################################
37 | # Checks if the specified command exists.
38 | # Arguments:
39 | # $1. The command to check.
40 | # Returns:
41 | # 0 if the specified command exists;
42 | # otherwise, a non-zero status.
43 | #################################################
44 | command_exists() {
45 | command -v "${1}" > /dev/null 2>& 1
46 | }
47 |
48 | #################################################
49 | # Asks the user for confirmation.
50 | # Arguments:
51 | # $1. The message to display.
52 | # $2. The default response.
53 | # Inputs:
54 | # Reads user's input from stdin.
55 | # Outputs:
56 | # Writes the message to stderr and prompts
57 | # the user for yes/no confirmation.
58 | # Returns:
59 | # 0 if the user confirmed the action.
60 | # 1 if the user denied the action.
61 | #################################################
62 | confirm() {
63 | local confirm_response="${2}"
64 | if [ -z "${confirm_response}" ] || [ "${USE_DEFAULT_RESPONSE}" != "true" ]; then
65 | echo -n "${1} [Y/n]: " >& 2
66 | read -r confirm_response
67 | fi
68 |
69 | case "$(echo "${confirm_response}" | tr '[:upper:]' '[:lower:]')" in
70 | y|yes|yep) return 0 ;;
71 | n|no|nope) return 1 ;;
72 | *) confirm "${1}" ;;
73 | esac
74 | }
75 |
76 | #################################################
77 | # Prompts the user for input.
78 | # Arguments:
79 | # $1. The message to display.
80 | # $2. The default response.
81 | # Inputs:
82 | # Reads user's input from stdin.
83 | # Outputs:
84 | # Writes the message to stderr.
85 | # Writes the user's input to stdout.
86 | # Returns:
87 | # 0 if the response is not empty; otherwise, 1.
88 | #################################################
89 | prompt() {
90 | local prompt_response="${2}"
91 | if [ -z "${prompt_response}" ] || [ "${USE_DEFAULT_RESPONSE}" != "true" ]; then
92 | echo -n "${1}: " >& 2
93 | read -r prompt_response
94 | fi
95 |
96 | echo "${prompt_response}"
97 | [ -n "${prompt_response}" ]
98 | }
99 |
100 | #################################################
101 | # Installs the specified package(s) using the
102 | # appropriate package manager for the system.
103 | # Arguments:
104 | # ... One or more package names to install.
105 | # Returns:
106 | # 0 if the operation succeeds;
107 | # otherwise, a non-zero status if no supported
108 | # package manager is found or the installation
109 | # fails.
110 | #################################################
111 | install_package() {
112 | if command_exists apt-get; then
113 | apt-get install -y "${@}"
114 | elif command_exists dnf; then
115 | dnf install -y "${@}"
116 | elif command_exists pacman; then
117 | pacman -S --noconfirm "${@}"
118 | else
119 | return 1
120 | fi
121 | }
122 |
123 | #################################################
124 | # Gets the operating system name.
125 | # Arguments:
126 | # None
127 | # Outputs:
128 | # Writes the name of the OS to stdout.
129 | #################################################
130 | os_name() {
131 | uname -s | tr '[:upper:]' '[:lower:]'
132 | }
133 |
134 | #################################################
135 | # Determines the operating system type combining
136 | # OS name and architecture.
137 | # Arguments:
138 | # None
139 | # Outputs:
140 | # Writes the type of the OS to stdout,
141 | # formatted as <>-<>, e.g., "linux-amd64",
142 | # "darwin-arm64", etc.
143 | #################################################
144 | os_type() {
145 | case "$(uname -m)" in
146 | x86_64) echo "$(os_name)-amd64" ;;
147 | aarch64|armv8) echo "$(os_name)-arm64" ;;
148 | armv6|armv7l) echo "$(os_name)-armv6l" ;;
149 | i686|.*386.*) echo "$(os_name)-386" ;;
150 | *) echo "$(os_name)-$(uname -m)" ;;
151 | esac
152 | }
153 |
154 | #################################################
155 | # Downloads a file.
156 | # Arguments:
157 | # $1. The URL of the file to download.
158 | # $2. The destination of the downloaded file.
159 | # If not provided, the file will be written
160 | # to stdout.
161 | # Returns:
162 | # 0 if the operation succeeds;
163 | # otherwise, a non-zero status.
164 | #################################################
165 | download() {
166 | if command_exists wget; then
167 | wget -O "${2:-"-"}" "${1}"
168 | elif command_exists curl; then
169 | curl -Lo "${2:-"-"}" "${1}"
170 | fi
171 | }
172 |
173 | #################################################
174 | # Gets the latest version of Go.
175 | # Arguments:
176 | # None
177 | # Outputs:
178 | # Writes the version number of the latest
179 | # Go version (e.g., '1.20.0') to stdout.
180 | # Returns:
181 | # 0 if the operation succeeds;
182 | # otherwise, a non-zero status.
183 | #################################################
184 | go_version_latest() {
185 | download "https://go.dev/dl/?mode=json" | jq -r '.[0].version' | grep -o '[0-9\.]*' 2> /dev/null
186 | }
187 |
188 | #################################################
189 | # Downloads the specified version of Go.
190 | # Arguments:
191 | # $1. The destination of the downloaded file.
192 | # If not provided, defaults to "go.tar.gz".
193 | # $2. The version of Go to download.
194 | # If not provided, downloads the latest
195 | # version.
196 | # Returns:
197 | # 0 if the operation succeeds;
198 | # otherwise, a non-zero status.
199 | #################################################
200 | go_download() {
201 | download "https://go.dev/dl/go${2:-"$(go_version_latest)"}.$(os_type).tar.gz" "${1:-"go.tar.gz"}" >& 2
202 | }
203 |
204 | #################################################
205 | # Installs the specified version of Go.
206 | # Arguments:
207 | # $1. The Go installation directory.
208 | # If not provided, defaults to ".go".
209 | # $2. The version of Go to install.
210 | # If not provided, installs the latest
211 | # version.
212 | # Outputs:
213 | # Writes the full path of the Go binary
214 | # to stdout.
215 | # Returns:
216 | # 0 if the operation succeeds;
217 | # otherwise, a non-zero status.
218 | #################################################
219 | go_install() {
220 | local go_cwd="${PWD}"
221 | local go_path="${1:-".go"}"
222 | local go_tmp="go@${2:-"latest"}.tmp${$}.tar.gz"
223 |
224 | if [ -d "${go_path}" ] && [ -n "$(ls -A "${go_path}")" ]; then
225 | error "failed to install Go: Already installed"
226 | return 1
227 | fi
228 |
229 | if ! go_download "${go_tmp}" "${2}"; then
230 | error "failed to install Go: Could not download the binary"
231 | return 1
232 | fi
233 |
234 | mkdir -p "${go_path}"
235 | tar xf "${go_tmp}" --directory="${go_path}" --strip=1
236 | if [ $? -ne 0 ] || [ ! -x "${go_path}/bin/go" ]; then
237 | error "failed to install Go: Could not extract the binary"
238 | rm "${go_tmp}"
239 | rm -rf "${go_path}"
240 | return 1
241 | fi
242 |
243 | rm "${go_tmp}"
244 |
245 | cd "${go_path}"
246 | echo "${PWD}/bin/go"
247 | cd "${go_cwd}"
248 | }
249 |
250 | #################################################
251 | # Clones the 'outline-sdk' repo, applies patches
252 | # to it (if provided), builds the 'outline-cli'
253 | # Go binary, then cleans up the cloned repo.
254 | # Arguments:
255 | # $1. The output file name for the binary.
256 | # Defaults to "outline".
257 | # $2. The location of the Go binary to use.
258 | # Defaults to "go".
259 | # ... The patches to be applied to the repo.
260 | # Returns:
261 | # 0 if the operation succeeds;
262 | # otherwise, a non-zero status.
263 | #################################################
264 | outline_install() {
265 | local go_build_output="${1:-"outline"}"
266 | local go_binary="${2:-"go"}"
267 | shift 2
268 |
269 | git clone "https://github.com/Jigsaw-Code/outline-sdk" && \
270 | cd "./outline-sdk/x/examples/"
271 | if [ $? -ne 0 ]; then
272 | error "failed to build outline-cli: 'Jigsaw-Code/outline-sdk' is unreachable"
273 | return 1
274 | fi
275 |
276 | while [ -n "${1}" ]; do
277 | echo "Applying a patch: '${1}'..." >& 2
278 | git apply "../../../${1}"
279 | shift
280 | done
281 |
282 | local go_cache="${PWD}/.cache/go"
283 | local go_build_status=0
284 | echo "Building 'outline-cli' as '${go_build_output}'..." >& 2
285 | GOPATH="${go_cache}" GOCACHE="${go_cache}" "${go_binary}" build -o "outline-cli" "./outline-cli" >& 2
286 | go_build_status=$?
287 | cd "../../../"
288 |
289 | if [ "${go_build_status}" -eq 0 ]; then
290 | cp "./outline-sdk/x/examples/outline-cli/outline-cli" "${go_build_output}"
291 | go_build_status=$?
292 | fi
293 |
294 | rm -rf "./outline-sdk"
295 |
296 | if [ $? -ne 0 ]; then
297 | error "failed to build outline-cli: Compilation failed"
298 | fi
299 | return "${go_build_status}"
300 | }
301 |
302 | #################################################
303 | # Modifies a given template file to replace
304 | # the placeholder connect function with an actual
305 | # command used to establish a VPN connection.
306 | # The placeholder is denoted by a line containing
307 | # "# CONNECT_TEMPLATE".
308 | # Arguments:
309 | # $1. The path to the template file.
310 | # $2. The actual command used to establish
311 | # a connection, for example,
312 | # `__outline -transport "${1}"`.
313 | # Returns:
314 | # 0 if the operation succeeds;
315 | # otherwise, a non-zero status.
316 | #################################################
317 | outline_wrapper_install() {
318 | local template_line_number="$(sed -n '/# CONNECT_TEMPLATE/=' "${1}" 2> /dev/null)"
319 |
320 | [ -n "${template_line_number}" ] && \
321 | sed -i"" "${template_line_number} c\ ${2:-"__outline -transport \"\${1}\""} &" "${1}"
322 | }
323 |
324 | #################################################
325 | # Copies the source file to the target location
326 | # and sets the permissions on the copied file.
327 | # Arguments:
328 | # $1. The source file path.
329 | # $2. The target file path.
330 | # $3. The permissions to apply on
331 | # the copied file (in octal notation).
332 | # Returns:
333 | # 0 if the cp and chmod operations succeed;
334 | # otherwise, a non-zero status.
335 | #################################################
336 | cpmod() {
337 | mkdir -m=${3} -p "$(dirname "${2}")"
338 | cp "${1}" "${2}" && chmod "${3}" "${2}"
339 | }
340 |
341 | #################################################
342 | # Copies a file from the project's source
343 | # directory to the corresponding location
344 | # in the target system.
345 | # The function retrieves the destination from
346 | # the file's path by removing the initial
347 | # directory part.
348 | # Arguments:
349 | # $1. The source file path.
350 | # $2. The permissions to apply on
351 | # the copied file (in octal notation).
352 | # If not provided, defaults to 600.
353 | # Returns:
354 | # 0 if the operation succeeds;
355 | # otherwise, a non-zero status.
356 | #################################################
357 | unwrap() {
358 | cpmod "${1}" "$(echo "${1}" | sed 's/^[^/]*//')" "${2:-600}"
359 | }
360 |
361 | #################################################
362 | # Asserts that the current user is "root" (i.e.,
363 | # a superuser). Otherwise, terminates the current
364 | # process.
365 | # Arguments:
366 | # None
367 | # Outputs:
368 | # Writes the error message, if any, to stderr.
369 | # Returns:
370 | # 0 if the current user is a superuser;
371 | # otherwise, never returns (exits the shell
372 | # with a status of 1).
373 | #################################################
374 | assert_is_root() {
375 | [ "${EUID:-"$(id -u)"}" -eq 0 ] && return
376 |
377 | error "cannot perform the installation: Permission denied"
378 | exit 1
379 | }
380 |
381 | #################################################
382 | # Asserts that the current working directory
383 | # contains the script being executed.
384 | # Arguments:
385 | # None
386 | # Outputs:
387 | # Writes the error message, if any, to stderr.
388 | # Returns:
389 | # 0 if the current working directory is valid;
390 | # otherwise, never returns (exits the shell
391 | # with a status of 1).
392 | #################################################
393 | assert_valid_cwd() {
394 | [ -f "./${0##*/}" ] && return
395 |
396 | error "cannot perform the installation: Invalid working directory"
397 | exit 1
398 | }
399 |
400 | #################################################
401 | # Asserts that the provided command or one of its
402 | # substitutions is available on the current
403 | # system.
404 | # Arguments:
405 | # $1. The command name to check.
406 | # ... The alternatives to check.
407 | # Outputs:
408 | # Writes the error message, if any, to stderr.
409 | # Returns:
410 | # 0 if the provided command is available;
411 | # otherwise, never returns (exits the shell
412 | # with a status of 1).
413 | #################################################
414 | assert_installed() {
415 | local cmd_name=""
416 | for cmd_name in "${@}"; do
417 | command_exists "${cmd_name}" && return
418 | done
419 |
420 | if confirm "Do you want to install the missing dependency '${1}'?" "y"; then
421 | install_package "${1}" && return
422 | fi
423 |
424 | error "cannot perform the installation: ${1} is not installed"
425 | exit 1
426 | }
427 |
428 | #################################################
429 | # Prints a brief help message.
430 | # Arguments:
431 | # None
432 | # Outputs:
433 | # Writes the help message to stdout.
434 | #################################################
435 | help() {
436 | echo "Usage: ${0} []"
437 | echo
438 | echo "Install the Outline Client CLI."
439 | echo
440 | echo "Examples:"
441 | echo " sudo ${0} --yes"
442 | echo " sudo ${0} --undo"
443 | echo
444 | echo "Options:"
445 | echo " -h, --help Display this help text and exit"
446 | echo " -y, --yes Run the script without manual intervention"
447 | echo " -u, --undo Undo the changes made by this script"
448 | }
449 |
450 | #################################################
451 | # Formats and prints the provided error message,
452 | # displays the help page, and terminates the
453 | # process.
454 | # Arguments:
455 | # $1. The error message to format and print.
456 | # Outputs:
457 | # Writes the formatted error message to stderr.
458 | # Returns:
459 | # Never returns (exits with a status of 1).
460 | #################################################
461 | fatal_error() {
462 | error "${1}"
463 | help >& 2
464 | exit 1
465 | }
466 |
467 | #################################################
468 | # Uninstalls outline-cli by removing all
469 | # the files and directories it may have created,
470 | # and undoing the network routing rule changes.
471 | # Arguments:
472 | # $1=false. Indicates whether to also remove
473 | # local user data.
474 | #################################################
475 | uninstall() {
476 | assert_is_root
477 |
478 | # Previously, executables were located in `sbin` instead of `bin`.
479 | rm -f "/usr/local/sbin/__vpn_connect"
480 | rm -f "/usr/local/sbin/__vpn_manager"
481 | find "/usr/local/sbin/" -lname "/usr/local/sbin/__vpn_manager" -delete
482 |
483 | rm -f "/usr/local/bin/__vpn_connect"
484 | rm -f "/usr/local/bin/__vpn_manager"
485 | find "/usr/local/bin/" -lname "/usr/local/bin/__vpn_manager" -delete
486 |
487 | rm -f "/usr/share/polkit-1/actions/vpn-manager.policy"
488 | rm -f "/etc/NetworkManager/dispatcher.d/vpn-manager-refresh"
489 |
490 | if [ "${1}" = "true" ]; then
491 | rm -rf "/var/lib/outline"
492 | rm -f "/var/log/outline"
493 | fi
494 | }
495 |
496 | #################################################
497 | # Cleans up the working directory by removing
498 | # temporary files and directories that might have
499 | # been created during the runtime of
500 | # the installation script.
501 | # Arguments:
502 | # None
503 | #################################################
504 | cleanup() {
505 | git checkout -- "src/usr/local/bin/__vpn_connect"
506 | rm -rf .go
507 | }
508 |
509 | #################################################
510 | # Installs the current version of Outline CLI.
511 | # Arguments:
512 | # None
513 | # Returns:
514 | # 0 if the operation succeeds;
515 | # otherwise, a non-zero status.
516 | #################################################
517 | install_local() {
518 | # Ensure that prerequisites for the script are met.
519 | assert_is_root
520 | assert_valid_cwd
521 | assert_installed jq
522 | assert_installed git
523 | assert_installed tar
524 | assert_installed curl wget
525 | assert_installed gcc clang # Go needs a C/C++ compiler.
526 |
527 | # Clean up stuff from the previous installations.
528 | uninstall > /dev/null 2>& 1
529 |
530 | # Automatic cleanup on the process termination.
531 | trap cleanup EXIT
532 |
533 | # Prepare __vpn_connect.
534 | # It should either be replaced with outline-cli, or
535 | # modified to contain the actual logic necessary
536 | # for connecting to a VPN server.
537 | if confirm "Install 'outline-cli' by Jigsaw LLC?" "y"; then
538 | local outline_go_binary="go"
539 | local outline_go_version="$(prompt "Select a Go version to compile 'outline-cli' with [latest/local/1.22.0/...]" "latest")"
540 | if [ "${outline_go_version}" = "latest" ]; then
541 | outline_go_version=""
542 | fi
543 | if [ "${outline_go_version}" != "local" ]; then
544 | outline_go_binary="$(go_install ".go" "${outline_go_version}")"
545 | [ -n "${outline_go_binary}" ] || return
546 | fi
547 |
548 | if confirm "Apply patches from the 'patches/' directory?" "y"; then
549 | outline_install "src/usr/local/bin/__vpn_connect" "${outline_go_binary}" patches/* || return
550 | else
551 | outline_install "src/usr/local/bin/__vpn_connect" "${outline_go_binary}" || return
552 | fi
553 | else
554 | local outline_wrapper_cmd="$(prompt "Enter a command used to connect to a VPN server")"
555 | if [ -z "${outline_wrapper_cmd}" ]; then
556 | error "failed to create a wrapper: Invalid command"
557 | return
558 | fi
559 |
560 | outline_wrapper_install "src/usr/local/bin/__vpn_connect" "${outline_wrapper_cmd}" || return
561 | fi
562 |
563 | # Prompt for a name to symlink the __vpn_manager command.
564 | local vpn_manager_symlink_name="$(prompt "Enter a short name for the vpn-manager command [${DEFAULT_VPN_MANAGER_NAME}]" "${DEFAULT_VPN_MANAGER_NAME}")"
565 |
566 | # Unwrap the __vpn_connect and __vpn_manager commands, and
567 | # create a symlink for __vpn_manager with the user-specified name.
568 | unwrap "src/usr/local/bin/__vpn_connect" 500 && \
569 | unwrap "src/usr/local/bin/__vpn_manager" 500 && \
570 | ln -s "/usr/local/bin/__vpn_manager" "/usr/local/bin/${vpn_manager_symlink_name:-"${DEFAULT_VPN_MANAGER_NAME}"}"
571 | if [ $? -ne 0 ]; then
572 | uninstall
573 | return
574 | fi
575 |
576 | # Allow calls to __vpn_manager via pkexec.
577 | if command_exists pkexec; then
578 | unwrap "src/usr/share/polkit-1/actions/vpn-manager.policy" 644
579 | fi
580 |
581 | # NetworkManager integration.
582 | if command_exists NetworkManager && confirm "Enable NetworkManager integration?" "y"; then
583 | unwrap "src/etc/NetworkManager/dispatcher.d/vpn-manager-refresh" 500
584 | fi
585 | }
586 |
587 | #################################################
588 | # Installs the latest version of Outline CLI.
589 | # Arguments:
590 | # None
591 | # Returns:
592 | # 0 if the operation succeeds;
593 | # otherwise, a non-zero status.
594 | #################################################
595 | install_remote() {
596 | # Ensure that prerequisites for the script are met.
597 | assert_is_root
598 | assert_installed git
599 |
600 | # Clone the latest available tag and cd into it.
601 | git clone "${REPO}" --depth 1 --branch \
602 | "$(git ls-remote --tags --sort="-v:refname" "${REPO}" | head -n 1 | cut -d/ -f3)" && \
603 | [ -n "${REPO##*/}" ] && \
604 | [ -f "./${REPO##*/}/install.sh" ] && \
605 | cd "./${REPO##*/}" || \
606 | return
607 |
608 | # Rebuild the arguments.
609 | local inline_args=""
610 | if [ "${USE_DEFAULT_RESPONSE}" = "true" ]; then
611 | inline_args="${inline_args} -y"
612 | fi
613 |
614 | # Perform the installation.
615 | ./install.sh ${inline_args}
616 |
617 | # Delete the cloned repo.
618 | rm -rf -- "../${REPO##*/}"
619 | }
620 |
621 | #################################################
622 | # The main entry point for the script.
623 | # Arguments:
624 | # ... A list of the command line arguments.
625 | # Returns:
626 | # 0 if the operation succeeds;
627 | # otherwise, a non-zero status.
628 | #################################################
629 | main() {
630 | # Parse the arguments and options.
631 | while [ -n "${1}" ]; do
632 | case "${1}" in
633 | -h|--help) help; exit 0 ;;
634 | -y|--yes) USE_DEFAULT_RESPONSE=true ;;
635 | -u|--undo|--uninstall) uninstall true; exit 0 ;;
636 | -*) fatal_error "invalid option: ${1}" ;;
637 | *) fatal_error "invalid argument: ${1}" ;;
638 | esac
639 | shift 2> /dev/null
640 | done
641 |
642 | if [ "${0##*/}" = "install.sh" ]; then
643 | # The script is being executed from a local file,
644 | # i.e., the repository has already been cloned.
645 | # Proceed to the main entry point.
646 | install_local
647 | else
648 | # The script has been piped into whatever is executing it now.
649 | # Proceed to the stub that will clone the repository and
650 | # run the script properly.
651 | install_remote <& 1
652 | fi
653 | }
654 |
655 | main "${@}"
656 |
--------------------------------------------------------------------------------
/src/usr/local/bin/__vpn_manager:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #
3 | # Manage Shadowsocks server connections and related access keys.
4 |
5 | #################################################
6 | # Configure app details. #
7 | #################################################
8 | APP_NAME="outline"
9 | APP_VERSION="3.1.0"
10 | APP_DIRECTORY="/var/lib/${APP_NAME}"
11 |
12 | #################################################
13 | # Configure file paths. #
14 | #################################################
15 | CONFIG_FILENAME="${APP_DIRECTORY}/config.json"
16 | STATE_FILENAME="${APP_DIRECTORY}/state.json"
17 | LOG_FILENAME="${APP_DIRECTORY}/log.txt"
18 |
19 | #################################################
20 | # Configure notifications. #
21 | #################################################
22 | NOTIFICATION_TITLE=""
23 | NOTIFICATION_TIMEOUT=""
24 | NOTIFICATION_BODY_CONNECTED=""
25 | NOTIFICATION_BODY_DISCONNECTED=""
26 | NOTIFICATION_BODY_DISCONNECTED_UNKNOWN=""
27 | NOTIFICATION_ICON_SUCCESS=""
28 | NOTIFICATION_ICON_FAILURE=""
29 | NOTIFICATION_ICON_CONNECTED=""
30 | NOTIFICATION_ICON_DISCONNECTED=""
31 |
32 | #################################################
33 | # Ensures that the config file exists.
34 | # Arguments:
35 | # None
36 | #################################################
37 | init_config() {
38 | [ -f "${CONFIG_FILENAME}" ] && return
39 |
40 | mkdir -m 700 -p "$(dirname "${CONFIG_FILENAME}")" &&
41 | touch "${CONFIG_FILENAME}" &&
42 | chmod 600 "${CONFIG_FILENAME}" &&
43 | cat "${APP_DIRECTORY}/storage" 2> /dev/null | jq -Rs '{
44 | keys: .
45 | | split("\n") | map(.
46 | | split("=") | select(length > 1)
47 | | [(.[0] | gsub("%20";"=";"g")), (.[1:] | join("="))])
48 | | to_entries | map({ id: (.key + 1), name: .value[0], url: .value[1] }),
49 | remotes: [],
50 | notifications: {
51 | title: "Outline",
52 | timeout: 5000,
53 | messages: {
54 | connected: "Connected to %name% (%ip%)",
55 | disconnected: "Disconnected from %name% (%ip%)",
56 | disconnected_unknown: "Disconnected"
57 | },
58 | icons: {
59 | success: "dialog-positive",
60 | failure: "dialog-error",
61 | connected: "network-wireless",
62 | disconnected: "network-offline"
63 | }
64 | },
65 | exclude: [
66 | "192.168.0.0/16",
67 | "172.16.0.0/12",
68 | "10.0.0.0/8",
69 | "!10.233.233.0/24",
70 | ":22"
71 | ]
72 | }' > "${CONFIG_FILENAME}" &&
73 | rm -f "${APP_DIRECTORY}/storage"
74 | }
75 |
76 | #################################################
77 | # Ensures that the state file exists.
78 | # Arguments:
79 | # None
80 | #################################################
81 | init_state() {
82 | [ -f "${STATE_FILENAME}" ] && return
83 |
84 | mkdir -m 700 -p "$(dirname "${STATE_FILENAME}")" &&
85 | touch "${STATE_FILENAME}" &&
86 | chmod 600 "${STATE_FILENAME}" &&
87 | jq -n '{ status: "disconnected", key_id: null }' > "${STATE_FILENAME}" &&
88 | rm -f "${APP_DIRECTORY}/session"
89 | }
90 |
91 | #################################################
92 | # Ensures that the log file exists.
93 | # Arguments:
94 | # None
95 | #################################################
96 | init_log() {
97 | [ -f "${LOG_FILENAME}" ] && return
98 |
99 | mkdir -m 700 -p "$(dirname "${LOG_FILENAME}")" &&
100 | touch "${LOG_FILENAME}" &&
101 | chmod 640 "${LOG_FILENAME}" &&
102 | rm -f "/var/log/${APP_NAME}"
103 | }
104 |
105 | #################################################
106 | # Initializes notification settings.
107 | # Arguments:
108 | # None
109 | #################################################
110 | init_notifications() {
111 | eval "$(jq -r '
112 | (.notifications // {}) as $n |
113 | ($n.messages // {}) as $m |
114 | ($n.icons // {}) as $i |
115 | "NOTIFICATION_TITLE=\(($n.title // "Outline")|@sh)",
116 | "NOTIFICATION_TIMEOUT=\(($n.timeout // 5000)|@sh)",
117 | "NOTIFICATION_BODY_CONNECTED=\(($m.connected // "Connected to %name% (%ip%)")|@sh)",
118 | "NOTIFICATION_BODY_DISCONNECTED=\(($m.disconnected // "Disconnected from %name% (%ip%)")|@sh)",
119 | "NOTIFICATION_BODY_DISCONNECTED_UNKNOWN=\(($m.disconnected_unknown // "Disconnected")|@sh)",
120 | "NOTIFICATION_ICON_SUCCESS=\(($i.success // "dialog-positive")|@sh)",
121 | "NOTIFICATION_ICON_FAILURE=\(($i.failure // "dialog-error")|@sh)",
122 | "NOTIFICATION_ICON_CONNECTED=\(($i.connected // "network-wireless")|@sh)",
123 | "NOTIFICATION_ICON_DISCONNECTED=\(($i.disconnected // "network-offline")|@sh)"
124 | ' "${CONFIG_FILENAME}")"
125 | }
126 |
127 | #################################################
128 | # Formats and prints the provided error message.
129 | # Arguments:
130 | # $1. The error message to format and print.
131 | # Outputs:
132 | # Writes the formatted error message to stderr.
133 | # Returns:
134 | # Always returns 1.
135 | #################################################
136 | error() {
137 | echo "${0}: ${1}" >& 2
138 | return 1
139 | }
140 |
141 | #################################################
142 | # Checks if the specified command exists.
143 | # Arguments:
144 | # $1. The command to check.
145 | # Returns:
146 | # 0 if the specified command exists;
147 | # otherwise, a non-zero status.
148 | #################################################
149 | command_exists() {
150 | command -v "${1}" > /dev/null 2>& 1
151 | }
152 |
153 | #################################################
154 | # Overwrites a specified file with the contents
155 | # from stdin, maintaining the file permissions.
156 | # Arguments:
157 | # $1. The filename to overwrite.
158 | # Inputs:
159 | # Reads new content from stdin.
160 | # Outputs:
161 | # Writes the new content to the specified file.
162 | #################################################
163 | overwrite() {
164 | awk 'BEGIN{RS="";getline<"-";print>ARGV[1]}' "${1}"
165 | }
166 |
167 | #################################################
168 | # Downloads a file.
169 | # Arguments:
170 | # $1. The URL of the file to download.
171 | # $2. The destination of the downloaded file.
172 | # If not provided, the file will be written
173 | # to stdout.
174 | # Returns:
175 | # 0 if the operation succeeds;
176 | # otherwise, a non-zero status.
177 | #################################################
178 | download() {
179 | if command_exists wget; then
180 | wget -O "${2:-"-"}" "${1}"
181 | elif command_exists curl; then
182 | curl -Lo "${2:-"-"}" "${1}"
183 | fi
184 | }
185 |
186 | #################################################
187 | # Decodes a percent-encoded URI component.
188 | # Arguments:
189 | # None
190 | # Inputs:
191 | # Reads the encoded URI component from stdin.
192 | # Outputs:
193 | # Writes the decoded string to stdout.
194 | #################################################
195 | urid() {
196 | printf "%b" "$(sed -E 's/%([a-fA-F0-9]{2})/\\\x\1/g')"
197 | }
198 |
199 | #################################################
200 | # Sends a desktop notification.
201 | # Arguments:
202 | # $1. The title of the notification.
203 | # $2="". The body of the notification.
204 | # $3="". The icon of the notification.
205 | # $4=3000. The duration of the notification.
206 | # Returns:
207 | # 0 if the notification has been sent;
208 | # otherwise, a non-zero status.
209 | #################################################
210 | send_notification() {
211 | local display=":0"
212 | local display_user="$(users | sed 's/\s.*//g')"
213 | local display_user_id="$(id -u "${display_user}" 2> /dev/null)"
214 |
215 | if command_exists notify-send; then
216 | sudo -u "${display_user}" \
217 | DISPLAY="${display}" \
218 | DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${display_user_id}/bus \
219 | notify-send -a "${1}" -i "${3}" -t "${4:-3000}" "${1}" "${2}"
220 | elif command_exists kdialog; then
221 | sudo -u "${display_user}" \
222 | DISPLAY="${display}" \
223 | DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${display_user_id}/bus \
224 | kdialog --passivepopup "${2}" "$((${4:-3000} / 1000))" --title "${1}" --icon "${3}"
225 | else
226 | return 1
227 | fi > /dev/null 2>& 1
228 | }
229 |
230 | #################################################
231 | # Asserts that the current user is "root" (i.e.,
232 | # a superuser). Otherwise, terminates the current
233 | # process.
234 | # Arguments:
235 | # None
236 | # Outputs:
237 | # Writes the error message, if any, to stderr.
238 | # Returns:
239 | # 0 if the current user is a superuser;
240 | # otherwise, never returns (exits the shell
241 | # with a status of 1).
242 | #################################################
243 | assert_is_root() {
244 | [ "${EUID:-"$(id -u)"}" -eq 0 ] && return
245 |
246 | error "cannot query current connection status: Permission denied"
247 | exit 1
248 | }
249 |
250 | #################################################
251 | # Checks if the given string is a valid
252 | # Shadowsocks URI.
253 | # Arguments:
254 | # $1. The string to check.
255 | # Returns:
256 | # 0 if the string is a valid Shadowsocks URI;
257 | # otherwise, a non-zero status.
258 | #################################################
259 | is_ss() {
260 | echo "${1}" | grep -qE '^ss://[a-zA-Z0-9+/]+={0,3}@.+:[0-9]{1,5}(/.*)?(#.*)?$'
261 | }
262 |
263 | #################################################
264 | # Checks if the given string is a valid
265 | # base64-encoded Shadowsocks URI.
266 | # Arguments:
267 | # $1. The string to check.
268 | # Returns:
269 | # 0 if the given string is a valid
270 | # base64-encoded Shadowsocks URI;
271 | # otherwise, a non-zero status.
272 | #################################################
273 | is_ss64() {
274 | echo "${1}" | grep -qE '^ss://[a-zA-Z0-9+/]+={0,3}(/.*)?(#.*)?$'
275 | }
276 |
277 | #################################################
278 | # Checks if the given string is a valid
279 | # Shadowsocks configuration URI.
280 | # Arguments:
281 | # $1. The string to check.
282 | # Returns:
283 | # 0 if the given string is a valid
284 | # Shadowsocks configuration URI;
285 | # otherwise, a non-zero status.
286 | #################################################
287 | is_ssconf() {
288 | echo "${1}" | grep -qE '^ssconf://.+'
289 | }
290 |
291 | #################################################
292 | # Checks if the given string is a valid JSON
293 | # object.
294 | # Arguments:
295 | # $1. The string to check.
296 | # Returns:
297 | # 0 if the given string is a valid JSON object;
298 | # otherwise, a non-zero status.
299 | #################################################
300 | is_json() {
301 | echo "${1}" | grep -qE '^\s*{'
302 | }
303 |
304 | #################################################
305 | # Checks if the given string is a valid transport
306 | # (i.e., the server access key).
307 | # Arguments:
308 | # $1. The transport string to check.
309 | # Returns:
310 | # 0 if the given string is a valid transport;
311 | # otherwise, a non-zero status.
312 | #################################################
313 | is_valid_transport() {
314 | is_ss "${1}" || is_ss64 "${1}" || is_ssconf "${1}"
315 | }
316 |
317 | #################################################
318 | # Normalizes the given access key by converting
319 | # it to its actual static representation.
320 | # Arguments:
321 | # $1. The key to normalize.
322 | # Outputs:
323 | # Writes the normalized key to stdout.
324 | #################################################
325 | normalize_key() {
326 | if is_ss "${1}"; then
327 | echo "${1}"
328 | elif is_ss64 "${1}"; then
329 | ss642ss "${1}"
330 | elif is_ssconf "${1}"; then
331 | ssconf2ss "${1}"
332 | elif is_json "${1}"; then
333 | json2ss "${1}"
334 | else
335 | echo "${1}"
336 | fi | sed 's/[=]*@/@/'
337 | }
338 |
339 | #################################################
340 | # Converts an encoded Shadowsocks URI to its
341 | # standard Shadowsocks URI format.
342 | # Arguments:
343 | # $1. The encoded Shadowsocks URI.
344 | # Outputs:
345 | # Writes the Shadowsocks URI to stdout.
346 | #################################################
347 | ss642ss() {
348 | jq -nr --arg key "${1}" '$key
349 | | capture("^ss://(?[a-zA-Z0-9+/]+?={0,3})(?(/|\\?|#)[^/]*)?$")
350 | | [
351 | (.val | @base64d | gsub("\\s"; "") | split(":|@"; null) | .[]),
352 | (.rem // "")
353 | ]
354 | | "ss://\("\(.[0]):\(.[1])"|@base64|gsub("=";""))@\(.[2]):\(.[3])\(.[4])"'
355 | }
356 |
357 | #################################################
358 | # Converts a dynamic access key to its actual
359 | # static representation.
360 | # Arguments:
361 | # $1. The dynamic key to convert.
362 | # Outputs:
363 | # Writes the normalized static key to stdout.
364 | #################################################
365 | ssconf2ss() {
366 | normalize_key "$(download "$(echo "${1}" | sed 's|ssconf://|https://|')" 2> /dev/null)"
367 | }
368 |
369 | #################################################
370 | # Converts a JSON representation of a Shadowsocks
371 | # configuration to its URI format.
372 | # Arguments:
373 | # $1. The JSON string representing
374 | # the Shadowsocks configuration.
375 | # Outputs:
376 | # Writes the Shadowsocks URI to stdout.
377 | #################################################
378 | json2ss() {
379 | jq -nr --argjson key "${1}" '$key |
380 | "ss://\(
381 | "\(.method):\(.password)" | @base64 | gsub("="; "")
382 | )@\(.server):\(.server_port)\(
383 | (.prefix | select(length > 0) | "/?prefix=\(@uri)") // ""
384 | )\(
385 | (.name | select(length > 0) | "#\(@uri)") // ""
386 | )"'
387 | }
388 |
389 | #################################################
390 | # Formats and prints all access key entries based
391 | # on the provided format string.
392 | # The formats available for substitution are:
393 | # %name% - replaced with the access key name
394 | # %ip% - replaced with the access key IP
395 | # %index% - replaced with the access key ID
396 | # Arguments:
397 | # $1. A composite format string.
398 | # $2. A JSON file containing access keys.
399 | # Inputs:
400 | # If $2 is omitted, reads the access key
401 | # entries to format from stdin.
402 | #################################################
403 | format_keys() {
404 | jq -r --arg format "${1}" '
405 | (if (.id|type) == "number" then . else .keys[]? end) as $key |
406 | $format
407 | | split("%index%") | join($key.id|tostring)
408 | | split("%name%") | join($key.name)
409 | | split("%ip%") | join(($key.access_url // $key.url)
410 | | capture(".*@(?.+:[0-9]{1,5})(/|#|$).*").ip? // "N/A")' "${2:-"-"}"
411 | }
412 |
413 | #################################################
414 | # Prints the given access key entry according to
415 | # the provided format string.
416 | # Arguments:
417 | # $1. A composite format string.
418 | # $2. The access key entry to format.
419 | # Outputs:
420 | # Writes the formatted entry to stdout.
421 | #################################################
422 | format_key() {
423 | echo "${2}" | format_keys "${1}"
424 | }
425 |
426 | #################################################
427 | # Infers the name of the given key.
428 | # Arguments:
429 | # $1. The key to infer the name for.
430 | # Outputs:
431 | # Writes the inferred name to stdout.
432 | #################################################
433 | infer_key_name() {
434 | if echo "${1}" | grep -qF '#'; then
435 | echo "${1}" | sed 's/[^#]*#//' | urid
436 | else
437 | echo "${1}" | grep -oE '@.+:[0-9]{1,5}(/|#|$)' | grep -oE '[^@]+:[0-9]+'
438 | fi
439 | }
440 |
441 | #################################################
442 | # Retrieves an access key entry by name or index.
443 | # Arguments:
444 | # $1. The name/index of the access key to get.
445 | # Outputs:
446 | # Writes the found key, if any, to stdout.
447 | # Returns:
448 | # 0 if the access key was found;
449 | # otherwise, a non-zero status.
450 | #################################################
451 | get_key_entry() {
452 | jq -ce --arg query "${1}" '
453 | (try ($query|tonumber) catch ($query|ascii_downcase)) as $q |
454 | first(.keys[]? | select((.id == $q) or ((.name|ascii_downcase) == $q)))
455 | ' "${CONFIG_FILENAME}"
456 | }
457 |
458 | #################################################
459 | # Adds a new access key to the storage.
460 | # Arguments:
461 | # $1. The access key to store.
462 | # $2. The name of the access key.
463 | # $3. The access url associated with the key.
464 | # Returns:
465 | # 0 if the access key was added;
466 | # otherwise, a non-zero status.
467 | #################################################
468 | add_key() {
469 | is_valid_transport "${1}" || return
470 | local access_url="${3:-"$(normalize_key "${1}")"}"
471 | local name="${2:-"$(infer_key_name "${access_url}")"}"
472 |
473 | jq --arg name "${name}" --arg url "${1}" --arg access_url "${access_url}" '
474 | ($name|ascii_downcase) as $q |
475 | (.keys // []) as $k |
476 | (
477 | first($k[] | select((.id == $q) or ((.name|ascii_downcase) == $q))).id? //
478 | first(range(1;$k|max_by(.id).id + 2) | select(. as $i | $k | all(.id != $i)))
479 | ) as $id |
480 | .keys |= ([
481 | $k | map(select(.id != $id)) | .[],
482 | { id: $id, name: $name, url: $url, access_url: $access_url }
483 | ] | sort_by(.id))
484 | ' "${CONFIG_FILENAME}" | overwrite "${CONFIG_FILENAME}"
485 | }
486 |
487 | #################################################
488 | # Updates an existing access key entry in storage
489 | # with the provided key details, replacing any
490 | # existing entry with the same ID.
491 | # Arguments:
492 | # $1. The access key entry to update.
493 | # Returns:
494 | # 0 if the access key was updated;
495 | # otherwise, a non-zero status.
496 | #################################################
497 | update_key() {
498 | jq --argjson key "${1}" '
499 | .keys |= ([. | map(select(.id != $key.id)) | .[], $key] | sort_by(.id))
500 | ' "${CONFIG_FILENAME}" | overwrite "${CONFIG_FILENAME}"
501 | }
502 |
503 | #################################################
504 | # Removes the access key from the storage using
505 | # its name or index.
506 | # Arguments:
507 | # $1. The name/index of the key to remove.
508 | # Returns:
509 | # 0 if the access key was removed;
510 | # otherwise, a non-zero status.
511 | #################################################
512 | remove_key() {
513 | jq --arg query "${1}" '(
514 | (try ($query|tonumber) catch ($query|ascii_downcase)) as $q |
515 | (.keys|length) as $l |
516 | (.keys|map(select((.id != $q) and ((.name|ascii_downcase) != $q)))) as $k |
517 | .keys |= $k,
518 | (null | halt_error(if ($l == ($k|length)) then 1 else 0 end))
519 | )' "${CONFIG_FILENAME}" | overwrite "${CONFIG_FILENAME}"
520 | }
521 |
522 | #################################################
523 | # Lists all access keys stored in the storage.
524 | # Arguments:
525 | # $1="%index% %name% %ip%". The format to use
526 | # when printing the access keys.
527 | # Outputs:
528 | # Writes the formatted access key entries
529 | # to stdout.
530 | #################################################
531 | list_keys() {
532 | local sep=""
533 | format_keys "${1:-"%index%${sep}%name%${sep}%ip%"}" "${CONFIG_FILENAME}" | {
534 | [ -z "${1}" ] && column -s "${sep}" -t || cat -
535 | }
536 | }
537 |
538 | #################################################
539 | # Gets the status of the current connection.
540 | # Arguments:
541 | # None
542 | # Outputs:
543 | # Writes the id of the relevant access key,
544 | # if any, to stdout.
545 | # Returns:
546 | # 0 if there is an active connection;
547 | # 1 if there is no active connection;
548 | # 2 if the connection is temporarily suspended.
549 | #################################################
550 | get_status() {
551 | jq -r '(
552 | .key_id // "",
553 | (if (.key_id|type) == "number" then .status|ascii_downcase else "" end) as $s |
554 | null | halt_error(["connected", "disconnected", "suspended"] | index($s) // 1)
555 | )' "${STATE_FILENAME}"
556 | }
557 |
558 | #################################################
559 | # Generates iptables and ip rules for setting or
560 | # removing routing rules specified by the config.
561 | # Arguments:
562 | # $1="+". The action to perform:
563 | # "+" to set the rules.
564 | # "-" to unset the rules.
565 | # Outputs:
566 | # Writes the iptables and ip rules to stdout.
567 | #################################################
568 | get_routing_rules() {
569 | if ! command_exists "ip" || ! command_exists "iptables"; then
570 | return 1
571 | fi
572 |
573 | local main_table="$([ -n "$(ip rule show table main 2> /dev/null)" ] && echo "main" || echo "default")"
574 | jq -r --arg action "${1:-"+"}" --arg main_table "${main_table}" '
575 | (if ($action == "+") then ["add", "-A"] else ["delete", "-D"] end) as [$ipr, $ipt] |
576 | .exclude[]?
577 | | select(type == "string")
578 | | select(test("^!?((:\\d+)|((\\d{1,3}\\.){3}\\d{1,3}(/\\d{1,2})?)|(([a-fA-F0-9]{0,4}:){1,7}[a-fA-F0-9]{0,4}(/\\d{1,3})?))$"))
579 | | (if (startswith("!")) then ["233", 23331] else [$main_table, 23332] end) as [$table, $priority]
580 | | sub("^!";"")
581 | | if (startswith(":")) then
582 | (
583 | "iptables -t mangle \($ipt) OUTPUT -p tcp --sport \(.[1:]|@sh) -j MARK --set-mark \(.[1:]|@sh)",
584 | "iptables -t mangle \($ipt) OUTPUT -p udp --sport \(.[1:]|@sh) -j MARK --set-mark \(.[1:]|@sh)",
585 | "ip rule \($ipr) fwmark \(.[1:]|@sh) table \($table|@sh) priority \($priority|@sh)"
586 | )
587 | else
588 | "ip rule \($ipr) from all to \(@sh) table \($table|@sh) priority \($priority|@sh)"
589 | end
590 | | if ($action == "+") then . else "while \(.) 2> /dev/null; do :; done" end
591 | ' "${CONFIG_FILENAME}"
592 | }
593 |
594 | #################################################
595 | # Terminates or forcefully kills a process
596 | # by its full name.
597 | # Arguments:
598 | # $1. The name of the process to terminate.
599 | # $2="0.05". The interval to wait between
600 | # termination attempts (in seconds).
601 | # $3="50". The maximum number of termination
602 | # attempts.
603 | #################################################
604 | terminate_or_kill() {
605 | # Terminate the process gracefully using SIGTERM.
606 | pkill -TERM -x "${1}"
607 |
608 | # Loop until the process is terminated or maximum attempts reached.
609 | local termination_attempt=0
610 | while pgrep -x "${1}" > /dev/null && [ "${termination_attempt}" -lt "${3:-50}" ]; do
611 | sleep "${2:-"0.05"}"
612 | termination_attempt=$((${termination_attempt} + 1))
613 | done
614 |
615 | # If process still exists, forcefully kill it using SIGKILL.
616 | pkill -KILL -x "${1}" && pidwait -x "${1}"
617 | }
618 |
619 | #################################################
620 | # Disconnects from the currently active server,
621 | # if any. Otherwise, does nothing.
622 | # Arguments:
623 | # $1="". A flag indicating wether a connection
624 | # should only be temporarily suspended.
625 | # $2="%name%". A format to use when printing
626 | # the access key used to establish the
627 | # connection.
628 | # Outputs:
629 | # Writes the name of the access key used to
630 | # establish the connection, if any, to stdout.
631 | # Returns:
632 | # 0 if there was an active connection;
633 | # otherwise, a non-zero status.
634 | #################################################
635 | disconnect() {
636 | local key_id; key_id="$(get_status)"
637 | local status_id=$?
638 | local key_entry="$(get_key_entry "${key_id}")"
639 | local status_name="disconnected"
640 | [ -n "${1}" ] && [ "${status_id}" -ne 1 ] && status_name="suspended"
641 |
642 | terminate_or_kill __vpn_connect
643 | eval "$(get_routing_rules -)"
644 |
645 | jq -n --arg status "${status_name}" --arg id "${key_id}" '{
646 | status: $status,
647 | key_id: (try ($id|tonumber) catch (null))
648 | }' > "${STATE_FILENAME}"
649 | [ -n "${key_entry}" ] && format_key "${2:-"%name%"}" "${key_entry}"
650 | }
651 |
652 | #################################################
653 | # Connects to a Shadowsocks server using
654 | # the specified access key.
655 | # If no name is specified, tries to reconnect to
656 | # the last server, if any.
657 | # Arguments:
658 | # $1="". The name of the access key to use.
659 | # $2="%name%". A format to use when printing
660 | # the access key used to establish the
661 | # connection.
662 | # Outputs:
663 | # Writes the name of the access key used to
664 | # establish the connection, if any, to stdout.
665 | # Otherwise, prints an error to stderr.
666 | # Returns:
667 | # 0 if the connection has been established;
668 | # otherwise, a non-zero status.
669 | #################################################
670 | connect() {
671 | local key_id="${1:-"$(get_status)"}"
672 | local key_entry="$(get_key_entry "${key_id:-1}")"
673 | if [ -z "${key_entry}" ]; then
674 | error "unknown key: ${key_id}"
675 | return 1
676 | fi
677 |
678 | local key_url="$(jq -rn --argjson key "${key_entry}" '$key.access_url // $key.url')"
679 | local access_url="$(normalize_key "${key_url}")"
680 | if [ "${key_url}" != "${access_url}" ]; then
681 | key_entry="$(jq -cn --argjson key "${key_entry}" --arg url "${access_url}" '$key | .access_url |= $url')"
682 | update_key "${key_entry}"
683 | fi
684 |
685 | disconnect > /dev/null 2>& 1
686 | /usr/local/bin/__vpn_connect -transport "${access_url}" > "${LOG_FILENAME}" 2>& 1 &
687 | local connection_id=$!
688 | sleep 1
689 |
690 | if ps -p "${connection_id}" > /dev/null 2>& 1; then
691 | eval "$(get_routing_rules +)"
692 | jq -n --argjson key "${key_entry}" '{ status: "connected", key_id: $key.id }' > "${STATE_FILENAME}"
693 | format_key "${2:-"%name%"}" "${key_entry}"
694 | else
695 | error "$(tail -n 1 "${LOG_FILENAME}" | sed 's/^\[ERROR\][ 0-9/:]*//')"
696 | fi
697 | }
698 |
699 | #################################################
700 | # Executes an action by its name.
701 | # Arguments:
702 | # $1. The name of the action to execute.
703 | # $2="". The first action argument.
704 | # $3="". The second action argument.
705 | # Outputs:
706 | # Writes all the relevant output to stdout.
707 | # Writes all errors to stderr.
708 | # Returns:
709 | # 0 if the action was successfully executed;
710 | # otherwise, a non-zero status.
711 | #################################################
712 | execute_action() {
713 | case "${1}" in
714 | add)
715 | add_key "${2}" "${3}" || error "invalid key: ${2}"
716 | ;;
717 |
718 | remove)
719 | remove_key "${2}" || error "unknown key: ${2}"
720 | ;;
721 |
722 | list)
723 | list_keys "${2}"
724 | ;;
725 |
726 | connect)
727 | connect "${2}" "${NOTIFICATION_BODY_CONNECTED}"
728 | ;;
729 |
730 | disconnect)
731 | disconnect "${2}" "${NOTIFICATION_BODY_DISCONNECTED}"
732 | ;;
733 |
734 | toggle)
735 | if get_status > /dev/null; then
736 | disconnect "" "${NOTIFICATION_BODY_DISCONNECTED}"
737 | else
738 | connect "" "${NOTIFICATION_BODY_CONNECTED}"
739 | fi
740 | ;;
741 |
742 | status)
743 | local key_id; key_id="$(get_status)"
744 | local status_id=$?
745 | local key_entry="$(get_key_entry "${key_id}")"
746 |
747 | if [ "${status_id}" -eq 0 ] && [ -n "${key_entry}" ]; then
748 | format_key "${NOTIFICATION_BODY_CONNECTED}" "${key_entry}"
749 | elif [ -n "${key_entry}" ]; then
750 | format_key "${NOTIFICATION_BODY_DISCONNECTED}" "${key_entry}"
751 | else
752 | echo "${NOTIFICATION_BODY_DISCONNECTED_UNKNOWN}"
753 | fi
754 | return "${status_id}"
755 | ;;
756 |
757 | *) return 1 ;;
758 | esac
759 | }
760 |
761 | #################################################
762 | # Prints version information.
763 | # Arguments:
764 | # None
765 | # Outputs:
766 | # Writes version information to stdout.
767 | #################################################
768 | version() {
769 | echo "vpn-manager ${APP_VERSION}"
770 | }
771 |
772 | #################################################
773 | # Prints a brief help message.
774 | # Arguments:
775 | # None
776 | # Outputs:
777 | # Writes the help message to stdout.
778 | #################################################
779 | help() {
780 | echo "Usage: ${0} [] []"
781 | echo
782 | echo "Manage Shadowsocks server connections and related access keys."
783 | echo
784 | echo "Examples:"
785 | echo " sudo ${0} add \"ss://...\" \"Geneva\""
786 | echo " sudo ${0} connect geneva"
787 | echo " sudo ${0} disconnect"
788 | echo
789 | echo "Commands:"
790 | echo " add [] Add a new access key"
791 | echo " remove Remove the designated access key"
792 | echo " list [-f ] List all stored access keys"
793 | echo " connect [] Connect to a server"
794 | echo " disconnect [-s] Disconnect from the current server"
795 | echo " toggle Toggle the current connection"
796 | echo " status Return the current connection status"
797 | echo
798 | echo "Options:"
799 | echo " -h, --help Display this help text and exit"
800 | echo " -v, --version Display version information and exit"
801 | echo " -q, --quiet Suppress all normal output"
802 | echo " -n, --notify Display a notification"
803 | echo " -s, --suspend Suspend the current connection;"
804 | echo " It will be re-established later"
805 | echo " -f, --format Print a key according to the ;"
806 | echo " The formats are: %name%, %ip%, %index%"
807 | }
808 |
809 | #################################################
810 | # Formats and prints the provided error message,
811 | # displays the help page, and terminates the
812 | # process.
813 | # Arguments:
814 | # $1. The error message to format and print.
815 | # Outputs:
816 | # Writes the formatted error message to stderr.
817 | # Returns:
818 | # Never returns (exits with a status of 1).
819 | #################################################
820 | fatal_error() {
821 | error "${1}"
822 | help >& 2
823 | exit 1
824 | }
825 |
826 | #################################################
827 | # The main entry point for the script.
828 | # Arguments:
829 | # ... A list of the command line arguments.
830 | #################################################
831 | main() {
832 | local quiet=""
833 | local notify=""
834 | local suspend=""
835 | local format=""
836 | local action=""
837 | local arg0=""
838 | local arg1=""
839 |
840 | # Parse the arguments and options.
841 | while [ -n "${1}" ]; do
842 | case "${1}" in
843 | -h|--help) help; exit 0 ;;
844 | -v|--version) version; exit 0 ;;
845 | -q|--quiet) quiet="-q" ;;
846 | -n|--notify) notify="-n" ;;
847 | -s|--suspend) suspend="-s" ;;
848 | -f|--format) format="${2}"; shift ;;
849 | -*) fatal_error "invalid option: ${1}" ;;
850 | *)
851 | if [ -z "${action}" ]; then
852 | action="${1}"
853 | elif [ -z "${arg0}" ]; then
854 | arg0="${1}"
855 | elif [ -z "${arg1}" ]; then
856 | arg1="${1}"
857 | else
858 | fatal_error "invalid argument: ${1}"
859 | fi
860 | ;;
861 | esac
862 | shift 2> /dev/null
863 | done
864 |
865 | # Initialize everything we need.
866 | init_log
867 | init_state
868 | init_config
869 | init_notifications
870 |
871 | # Validate the parsed arguments and normalize the action name.
872 | case "${action}" in
873 | add|a)
874 | action="add"
875 | [ -n "${arg0}" ] || fatal_error "missing argument: "
876 | ;;
877 |
878 | remove|rm|r)
879 | action="remove"
880 | [ -n "${arg0}" ] || fatal_error "missing argument: "
881 | ;;
882 |
883 | list|ls|l) action="list" ;;
884 |
885 | connect|cd|c)
886 | action="connect"
887 | NOTIFICATION_ICON_SUCCESS="${NOTIFICATION_ICON_CONNECTED}"
888 | ;;
889 |
890 | disconnect|exit|d)
891 | action="disconnect"
892 | NOTIFICATION_ICON_SUCCESS="${NOTIFICATION_ICON_DISCONNECTED}"
893 | ;;
894 |
895 | toggle|t)
896 | action="toggle"
897 | if get_status > /dev/null; then
898 | NOTIFICATION_ICON_SUCCESS="${NOTIFICATION_ICON_DISCONNECTED}"
899 | else
900 | NOTIFICATION_ICON_SUCCESS="${NOTIFICATION_ICON_CONNECTED}"
901 | fi
902 | ;;
903 |
904 | status|s)
905 | action="status"
906 | NOTIFICATION_ICON_SUCCESS="${NOTIFICATION_ICON_CONNECTED}"
907 | NOTIFICATION_ICON_FAILURE="${NOTIFICATION_ICON_DISCONNECTED}"
908 | ;;
909 |
910 | *) fatal_error "invalid command: ${1}" ;;
911 | esac
912 |
913 | # Everything we do requires superuser privileges.
914 | # So, there is no real reason to proceed without those.
915 | assert_is_root
916 |
917 | # Redirect the output to a variable, so we can decide how to display it later.
918 | local action_result; action_result="$(execute_action "${action}" "${arg0:-"${format:-"${suspend}"}"}" "${arg1}" 2>& 1)"
919 | local action_code=$?
920 |
921 | # Display the output.
922 | # Either via a notification if the "--notify" flag has been provided, or
923 | # just write it back to stdout.
924 | if [ -n "${action_result}" ]; then
925 | if [ -n "${notify}" ]; then
926 | local action_icon="$([ "${action_code}" -eq 0 ] && echo "${NOTIFICATION_ICON_SUCCESS}" || echo "${NOTIFICATION_ICON_FAILURE}")"
927 | send_notification "${NOTIFICATION_TITLE}" "${action_result}" "${action_icon}"
928 | elif [ -z "${quiet}" ]; then
929 | echo "${action_result}"
930 | fi
931 | fi
932 | exit "${action_code}"
933 | }
934 |
935 | main "${@}"
936 |
--------------------------------------------------------------------------------