/** * This file is part of git-as-svn. It is subject to the license terms * in the LICENSE file found in the top-level directory of this distribution * and at http://www.gnu.org/licenses/gpl-2.0.html. No part of git-as-svn, * including this file, may be copied, modified, propagated, or distributed * except according to the terms contained in the LICENSE file. */ package svnserver.server; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.tmatesoft.svn.core.SVNErrorCode; import org.tmatesoft.svn.core.SVNErrorMessage; import org.tmatesoft.svn.core.SVNException; import svnserver.auth.AnonymousAuthenticator; import svnserver.auth.Authenticator; import svnserver.auth.User; import svnserver.auth.UserDB; import svnserver.config.Config; import svnserver.context.SharedContext; import svnserver.parser.MessageParser; import svnserver.parser.SvnServerParser; import svnserver.parser.SvnServerToken; import svnserver.parser.SvnServerWriter; import svnserver.parser.token.ListBeginToken; import svnserver.parser.token.ListEndToken; import svnserver.repository.RepositoryInfo; import svnserver.repository.VcsAccess; import svnserver.repository.VcsRepository; import svnserver.repository.VcsRepositoryMapping; import svnserver.server.command.*; import svnserver.server.msg.AuthReq; import svnserver.server.msg.ClientInfo; import svnserver.server.step.Step; import java.io.BufferedOutputStream; import java.io.EOFException; import java.io.File; import java.io.IOException; import java.net.*; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; /** * Сервер для предоставления доступа к git-у через протокол subversion. * * @author Artem V. Navrotskiy <bozaro@users.noreply.github.com> */ public class SvnServer extends Thread { @NotNull private static final Logger log = LoggerFactory.getLogger(SvnServer.class); private static final long FORCE_SHUTDOWN = TimeUnit.SECONDS.toMillis(5); @NotNull private static final Set<SVNErrorCode> WARNING_CODES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(new SVNErrorCode[]{ SVNErrorCode.CANCELLED, SVNErrorCode.ENTRY_NOT_FOUND, SVNErrorCode.FS_NOT_FOUND, SVNErrorCode.RA_NOT_AUTHORIZED, SVNErrorCode.REPOS_HOOK_FAILURE, SVNErrorCode.WC_NOT_UP_TO_DATE, }))); @NotNull private final Map<String, BaseCmd<?>> commands = new HashMap<>(); @NotNull private final Map<Long, Socket> connections = new ConcurrentHashMap<>(); @NotNull private final VcsRepositoryMapping repositoryMapping; @NotNull private final Config config; @NotNull private final ServerSocket serverSocket; @NotNull private final ExecutorService poolExecutor; @NotNull private final AtomicBoolean stopped = new AtomicBoolean(false); @NotNull private final AtomicLong lastSessionId = new AtomicLong(); @NotNull private final SharedContext context; public SvnServer(@NotNull File basePath, @NotNull Config config) throws IOException, SVNException { setDaemon(true); this.config = config; context = SharedContext.create(basePath, config.getCacheConfig().createCache(basePath), config.getShared()); context.add(UserDB.class, config.getUserDB().create(context)); commands.put("commit", new CommitCmd()); commands.put("diff", new DeltaCmd(DiffParams.class)); commands.put("get-locations", new GetLocationsCmd()); commands.put("get-location-segments", new GetLocationSegmentsCmd()); commands.put("get-latest-rev", new GetLatestRevCmd()); commands.put("get-dated-rev", new GetDatedRevCmd()); commands.put("get-dir", new GetDirCmd()); commands.put("get-file", new GetFileCmd()); commands.put("get-iprops", new GetIPropsCmd()); commands.put("log", new LogCmd()); commands.put("reparent", new ReparentCmd()); commands.put("check-path", new CheckPathCmd()); commands.put("replay", new ReplayCmd()); commands.put("replay-range", new ReplayRangeCmd()); commands.put("rev-prop", new RevPropCmd()); commands.put("rev-proplist", new RevPropListCmd()); commands.put("stat", new StatCmd()); commands.put("status", new DeltaCmd(StatusParams.class)); commands.put("switch", new DeltaCmd(SwitchParams.class)); commands.put("update", new DeltaCmd(UpdateParams.class)); commands.put("lock", new LockCmd()); commands.put("lock-many", new LockManyCmd()); commands.put("unlock", new UnlockCmd()); commands.put("unlock-many", new UnlockManyCmd()); commands.put("get-lock", new GetLockCmd()); commands.put("get-locks", new GetLocksCmd()); repositoryMapping = config.getRepositoryMapping().create(context); context.add(VcsRepositoryMapping.class, repositoryMapping); repositoryMapping.initRevisions(); serverSocket = new ServerSocket(); serverSocket.setReuseAddress(config.getReuseAddress()); serverSocket.bind(new InetSocketAddress(InetAddress.getByName(config.getHost()), config.getPort())); poolExecutor = Executors.newCachedThreadPool(); log.info("Server bind: {}", serverSocket.getLocalSocketAddress()); context.ready(); } public int getPort() { return serverSocket.getLocalPort(); } @NotNull public SharedContext getContext() { return context; } @Override public void run() { log.info("Server is ready on port: {}", serverSocket.getLocalPort()); while (!stopped.get()) { final Socket client; try { client = this.serverSocket.accept(); } catch (IOException e) { if (stopped.get()) { log.info("Server Stopped"); break; } log.error("Error accepting client connection", e); continue; } long sessionId = lastSessionId.incrementAndGet(); poolExecutor.execute(() -> { log.info("New connection from: {}", client.getRemoteSocketAddress()); try (Socket clientSocket = client) { connections.put(sessionId, client); serveClient(clientSocket); } catch (EOFException | SocketException ignore) { // client disconnect is not a error } catch (SVNException | IOException e) { log.info("Client error:", e); } finally { connections.remove(sessionId); log.info("Connection from {} closed", client.getRemoteSocketAddress()); } }); } } public void serveClient(@NotNull Socket socket) throws IOException, SVNException { socket.setTcpNoDelay(true); final SvnServerWriter writer = new SvnServerWriter(new BufferedOutputStream(socket.getOutputStream())); final SvnServerParser parser = new SvnServerParser(socket.getInputStream()); final ClientInfo clientInfo = exchangeCapabilities(parser, writer); final RepositoryInfo repositoryInfo = repositoryMapping.getRepository(clientInfo.getUrl()); if (repositoryInfo == null) { BaseCmd.sendError(writer, SVNErrorMessage.create(SVNErrorCode.RA_SVN_REPOS_NOT_FOUND, "Repository not found: " + clientInfo.getUrl())); return; } final SessionContext context = new SessionContext(parser, writer, this, repositoryInfo, clientInfo); context.authenticate(hasAnonymousAuthenticator(repositoryInfo)); final VcsRepository repository = context.getRepository(); repository.updateRevisions(); sendAnnounce(writer, repositoryInfo); while (!isInterrupted()) { try { Step step = context.poll(); if (step != null) { step.process(context); continue; } final SvnServerToken token = parser.readToken(); if (token != ListBeginToken.instance) { throw new IOException("Unexpected token: " + token); } final String cmd = parser.readText(); BaseCmd command = commands.get(cmd); if (command != null) { log.debug("Receive command: {}", cmd); Object param = MessageParser.parse(command.getArguments(), parser); parser.readToken(ListEndToken.class); //noinspection unchecked command.process(context, param); } else { log.warn("Unsupported command: {}", cmd); BaseCmd.sendError(writer, SVNErrorMessage.create(SVNErrorCode.RA_SVN_UNKNOWN_CMD, "Unsupported command: " + cmd)); parser.skipItems(); } } catch (SVNException e) { if (WARNING_CODES.contains(e.getErrorMessage().getErrorCode())) { log.warn("Command execution error: {}", e.getMessage()); } else { log.error("Command execution error", e); } BaseCmd.sendError(writer, e.getErrorMessage()); } } } private ClientInfo exchangeCapabilities(SvnServerParser parser, SvnServerWriter writer) throws IOException, SVNException { // Анонсируем поддерживаемые функции. writer .listBegin() .word("success") .listBegin() .number(2) .number(2) .listBegin() .listEnd() .listBegin(); // Begin capabilities block. writer .word("edit-pipeline") // This is required. .word("absent-entries") // We support absent-dir and absent-dir editor commands //.word("commit-revprops") // We don't currently have _any_ revprop support //.word("mergeinfo") // Nope, not yet .word("depth") .word("inherited-props") // Need for .gitattributes and .gitignore .word("log-revprops"); // svn log --with-all-revprops if (config.isCompressionEnabled()) { writer.word("svndiff1"); // We support svndiff1 (compression) } // End capabilities block. writer .listEnd() .listEnd() .listEnd(); // Читаем информацию о клиенте. final ClientInfo clientInfo = MessageParser.parse(ClientInfo.class, parser); if (clientInfo.getProtocolVersion() != 2) { throw new SVNException(SVNErrorMessage.create(SVNErrorCode.VERSION_MISMATCH, "Unsupported protocol version: " + clientInfo.getProtocolVersion() + " (expected: 2)")); } return clientInfo; } @NotNull public User authenticate(@NotNull SvnServerParser parser, @NotNull SvnServerWriter writer, @NotNull RepositoryInfo repositoryInfo, boolean allowAnonymous) throws IOException, SVNException { // Отправляем запрос на авторизацию. final List<Authenticator> authenticators = new ArrayList<>(context.sure(UserDB.class).authenticators()); if (allowAnonymous) { authenticators.add(0, AnonymousAuthenticator.get()); } writer .listBegin() .word("success") .listBegin() .listBegin() .word(String.join(" ", authenticators.stream().map(Authenticator::getMethodName).toArray(String[]::new))) .listEnd() .string(config.getRealm().isEmpty() ? repositoryInfo.getRepository().getUuid() : config.getRealm()) .listEnd() .listEnd(); while (true) { // Читаем выбранный вариант авторизации. final AuthReq authReq = MessageParser.parse(AuthReq.class, parser); final Optional<Authenticator> authenticator = authenticators.stream().filter(o -> o.getMethodName().equals(authReq.getMech())).findAny(); if (!authenticator.isPresent()) { sendError(writer, "unknown auth type: " + authReq.getMech()); continue; } final User user = authenticator.get().authenticate(parser, writer, authReq.getToken()); if (user == null) { sendError(writer, "incorrect credentials"); continue; } writer .listBegin() .word("success") .listBegin() .listEnd() .listEnd(); log.info("User: {}", user); return user; } } private boolean hasAnonymousAuthenticator(RepositoryInfo repositoryInfo) throws IOException { try { repositoryInfo.getRepository().getContext().sure(VcsAccess.class).checkRead(User.getAnonymous(), null); return true; } catch (SVNException e) { return false; } } private void sendAnnounce(@NotNull SvnServerWriter writer, @NotNull RepositoryInfo repositoryInfo) throws IOException { writer .listBegin() .word("success") .listBegin() .string(repositoryInfo.getRepository().getUuid()) .string(repositoryInfo.getBaseUrl().toString()) .listBegin() //.word("mergeinfo") .listEnd() .listEnd() .listEnd(); } private static void sendError(SvnServerWriter writer, String msg) throws IOException { writer .listBegin() .word("failure") .listBegin() .string(msg) .listEnd() .listEnd(); } public void startShutdown() throws IOException { if (stopped.compareAndSet(false, true)) { log.info("Shutdown server"); serverSocket.close(); poolExecutor.shutdown(); } } public void shutdown(long millis) throws InterruptedException, IOException { startShutdown(); if (!poolExecutor.awaitTermination(millis, TimeUnit.MILLISECONDS)) { forceShutdown(); } join(millis); context.close(); log.info("Server shutdowned"); } private void forceShutdown() throws IOException, InterruptedException { for (Socket socket : connections.values()) { socket.close(); } poolExecutor.awaitTermination(FORCE_SHUTDOWN, TimeUnit.MILLISECONDS); } public boolean isCompressionEnabled() { return config.isCompressionEnabled(); } }