@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3abstract class DiffusionSSHWorkflow extends PhabricatorSSHWorkflow {
4
5 private $args;
6 private $repository;
7 private $hasWriteAccess;
8 private $shouldProxy;
9 private $baseRequestPath;
10
11 public function getRepository() {
12 if (!$this->repository) {
13 throw new Exception(pht('Repository is not available yet!'));
14 }
15 return $this->repository;
16 }
17
18 private function setRepository(PhabricatorRepository $repository) {
19 $this->repository = $repository;
20 return $this;
21 }
22
23 public function getArgs() {
24 return $this->args;
25 }
26
27 public function getEnvironment() {
28 $env = array(
29 DiffusionCommitHookEngine::ENV_USER => $this->getSSHUser()->getUsername(),
30 DiffusionCommitHookEngine::ENV_REMOTE_PROTOCOL => 'ssh',
31 );
32
33 $identifier = $this->getRequestIdentifier();
34 if ($identifier !== null) {
35 $env[DiffusionCommitHookEngine::ENV_REQUEST] = $identifier;
36 }
37
38 $remote_address = $this->getSSHRemoteAddress();
39 if ($remote_address !== null) {
40 $env[DiffusionCommitHookEngine::ENV_REMOTE_ADDRESS] = $remote_address;
41 }
42
43 return $env;
44 }
45
46 /**
47 * Identify and load the affected repository.
48 */
49 abstract protected function identifyRepository();
50 abstract protected function executeRepositoryOperations();
51 abstract protected function raiseWrongVCSException(
52 PhabricatorRepository $repository);
53
54 protected function getBaseRequestPath() {
55 return $this->baseRequestPath;
56 }
57
58 protected function writeError($message) {
59 $this->getErrorChannel()->write($message);
60 return $this;
61 }
62
63 protected function getCurrentDeviceName() {
64 $device = AlmanacKeys::getLiveDevice();
65 if ($device) {
66 return $device->getName();
67 }
68
69 return php_uname('n');
70 }
71
72 protected function shouldProxy() {
73 return $this->shouldProxy;
74 }
75
76 final protected function getAlmanacServiceRefs($for_write) {
77 $viewer = $this->getSSHUser();
78 $repository = $this->getRepository();
79
80 $is_cluster_request = $this->getIsClusterRequest();
81
82 $refs = $repository->getAlmanacServiceRefs(
83 $viewer,
84 array(
85 'neverProxy' => $is_cluster_request,
86 'protocols' => array(
87 'ssh',
88 ),
89 'writable' => $for_write,
90 ));
91
92 if (!$refs) {
93 throw new Exception(
94 pht(
95 'Failed to generate an intracluster proxy URI even though this '.
96 'request was routed as a proxy request.'));
97 }
98
99 return $refs;
100 }
101
102 final protected function getProxyCommand($for_write) {
103 $refs = $this->getAlmanacServiceRefs($for_write);
104
105 $ref = head($refs);
106
107 return $this->getProxyCommandForServiceRef($ref);
108 }
109
110 final protected function getProxyCommandForServiceRef(
111 DiffusionServiceRef $ref) {
112
113 $uri = new PhutilURI($ref->getURI());
114
115 $username = AlmanacKeys::getClusterSSHUser();
116 if ($username === null) {
117 throw new Exception(
118 pht(
119 'Unable to determine the username to connect with when trying '.
120 'to proxy an SSH request within the cluster.'));
121 }
122
123 $port = $uri->getPort();
124 $host = $uri->getDomain();
125 $key_path = AlmanacKeys::getKeyPath('device.key');
126 if (!Filesystem::pathExists($key_path)) {
127 throw new Exception(
128 pht(
129 'Unable to proxy this SSH request within the cluster: this device '.
130 'is not registered and has a missing device key (expected to '.
131 'find key at "%s").',
132 $key_path));
133 }
134
135 $options = array();
136 $options[] = '-o';
137 $options[] = 'StrictHostKeyChecking=no';
138 $options[] = '-o';
139 $options[] = 'UserKnownHostsFile=/dev/null';
140
141 // This is suppressing "added <address> to the list of known hosts"
142 // messages, which are confusing and irrelevant when they arise from
143 // proxied requests. It might also be suppressing lots of useful errors,
144 // of course. Ideally, we would enforce host keys eventually. See T13121.
145 $options[] = '-o';
146 $options[] = 'LogLevel=ERROR';
147
148 // NOTE: We prefix the command with "@username", which the far end of the
149 // connection will parse in order to act as the specified user. This
150 // behavior is only available to cluster requests signed by a trusted
151 // device key.
152
153 return csprintf(
154 'ssh %Ls -l %s -i %s -p %s %s -- %s %Ls',
155 $options,
156 $username,
157 $key_path,
158 $port,
159 $host,
160 '@'.$this->getSSHUser()->getUsername(),
161 $this->getOriginalArguments());
162 }
163
164 final public function execute(PhutilArgumentParser $args) {
165 $this->args = $args;
166
167 $viewer = $this->getSSHUser();
168 $have_diffusion = PhabricatorApplication::isClassInstalledForViewer(
169 PhabricatorDiffusionApplication::class,
170 $viewer);
171 if (!$have_diffusion) {
172 throw new Exception(
173 pht(
174 'You do not have permission to access the Diffusion application, '.
175 'so you can not interact with repositories over SSH.'));
176 }
177
178 $repository = $this->identifyRepository();
179 $this->setRepository($repository);
180
181 // NOTE: Here, we're just figuring out if this is a proxyable request to
182 // a clusterized repository or not. We don't (and can't) use the URI we get
183 // back directly.
184
185 // For example, we may get a read-only URI here but be handling a write
186 // request. We only care if we get back `null` (which means we should
187 // handle the request locally) or anything else (which means we should
188 // proxy it to an appropriate device).
189
190 $is_cluster_request = $this->getIsClusterRequest();
191 $uri = $repository->getAlmanacServiceURI(
192 $viewer,
193 array(
194 'neverProxy' => $is_cluster_request,
195 'protocols' => array(
196 'ssh',
197 ),
198 ));
199 $this->shouldProxy = (bool)$uri;
200
201 try {
202 return $this->executeRepositoryOperations();
203 } catch (Exception $ex) {
204 $this->writeError(get_class($ex).': '.$ex->getMessage());
205 return 1;
206 }
207 }
208
209 protected function loadRepositoryWithPath($path, $vcs) {
210 $viewer = $this->getSSHUser();
211
212 $info = PhabricatorRepository::parseRepositoryServicePath($path, $vcs);
213 if ($info === null) {
214 throw new Exception(
215 pht(
216 'Unrecognized repository path "%s". Expected a path like "%s", '.
217 '"%s", or "%s".',
218 $path,
219 '/diffusion/X/',
220 '/diffusion/123/',
221 '/source/thaumaturgy.git'));
222 }
223
224 $identifier = $info['identifier'];
225 $base = $info['base'];
226
227 $this->baseRequestPath = $base;
228
229 $repository = id(new PhabricatorRepositoryQuery())
230 ->setViewer($viewer)
231 ->withIdentifiers(array($identifier))
232 ->needURIs(true)
233 ->executeOne();
234 if (!$repository) {
235 throw new Exception(
236 pht('No repository "%s" exists!', $identifier));
237 }
238
239 $is_cluster = $this->getIsClusterRequest();
240
241 $protocol = PhabricatorRepositoryURI::BUILTIN_PROTOCOL_SSH;
242 if (!$repository->canServeProtocol($protocol, false, $is_cluster)) {
243 throw new Exception(
244 pht(
245 'This repository ("%s") is not available over SSH.',
246 $repository->getDisplayName()));
247 }
248
249 if ($repository->getVersionControlSystem() != $vcs) {
250 $this->raiseWrongVCSException($repository);
251 }
252
253 return $repository;
254 }
255
256 protected function requireWriteAccess($protocol_command = null) {
257 if ($this->hasWriteAccess === true) {
258 return;
259 }
260
261 $repository = $this->getRepository();
262 $viewer = $this->getSSHUser();
263
264 if ($viewer->isOmnipotent()) {
265 throw new Exception(
266 pht(
267 'This request is authenticated as a cluster device, but is '.
268 'performing a write. Writes must be performed with a real '.
269 'user account.'));
270 }
271
272 if ($repository->isReadOnly()) {
273 throw new Exception($repository->getReadOnlyMessageForDisplay());
274 }
275
276 $protocol = PhabricatorRepositoryURI::BUILTIN_PROTOCOL_SSH;
277 if ($repository->canServeProtocol($protocol, true)) {
278 $can_push = PhabricatorPolicyFilter::hasCapability(
279 $viewer,
280 $repository,
281 DiffusionPushCapability::CAPABILITY);
282 if (!$can_push) {
283 throw new Exception(
284 pht('You do not have permission to push to this repository.'));
285 }
286 } else {
287 if ($protocol_command !== null) {
288 throw new Exception(
289 pht(
290 'This repository is read-only over SSH (tried to execute '.
291 'protocol command "%s").',
292 $protocol_command));
293 } else {
294 throw new Exception(
295 pht('This repository is read-only over SSH.'));
296 }
297 }
298
299 $this->hasWriteAccess = true;
300 return $this->hasWriteAccess;
301 }
302
303 protected function shouldSkipReadSynchronization() {
304 $viewer = $this->getSSHUser();
305
306 // Currently, the only case where devices interact over SSH without
307 // assuming user credentials is when synchronizing before a read. These
308 // synchronizing reads do not themselves need to be synchronized.
309 if ($viewer->isOmnipotent()) {
310 return true;
311 }
312
313 return false;
314 }
315
316 protected function newPullEvent() {
317 $viewer = $this->getSSHUser();
318 $repository = $this->getRepository();
319 $remote_address = $this->getSSHRemoteAddress();
320
321 return id(new PhabricatorRepositoryPullEvent())
322 ->setEpoch(PhabricatorTime::getNow())
323 ->setRemoteAddress($remote_address)
324 ->setRemoteProtocol(PhabricatorRepositoryPullEvent::PROTOCOL_SSH)
325 ->setPullerPHID($viewer->getPHID())
326 ->setRepositoryPHID($repository->getPHID());
327 }
328
329}